@clfhhc/bmad-methods-skills 0.3.1 → 0.3.2

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/README.md CHANGED
@@ -17,7 +17,12 @@ pnpm dlx @clfhhc/bmad-methods-skills init
17
17
 
18
18
  This installs `bootstrap-bmad-skills` and `enhance-bmad-skills`. Then open your AI tool and use the **`BS`** command to start the guided installation workflow.
19
19
 
20
- > **Note:** For fully automated installation without prompts, use `init --bootstrap` instead.
20
+ > **Note:** For fully automated installation without prompts, run the conversion and installation commands together (and cleanup):
21
+ ```bash
22
+ npx @clfhhc/bmad-methods-skills --output-dir .temp/converted-skills && \
23
+ npx @clfhhc/bmad-methods-skills install --from=.temp/converted-skills --force && \
24
+ rm -rf .temp
25
+ ```
21
26
 
22
27
  ## What It Does
23
28
 
package/config.json CHANGED
@@ -6,6 +6,78 @@
6
6
  "modules": ["bmm", "core"],
7
7
  "outputStructure": "flat",
8
8
  "sourcePathPrefixesToStrip": ["src/modules/", "src/"],
9
+ "configHeaderTemplate": "# {{moduleName}} Module Configuration\n# Generated by BMAD Skills Converter\n# Date: {{date}}\n#\n# Customize these values for your project.\n# Run the bootstrap-bmad-skills skill for guided configuration.\n\n",
10
+ "workflowParsing": {
11
+ "frontmatterRegex": "^---\\s*\\n([\\s\\S]*?)\\n---\\s*\\n([\\s\\S]*)$",
12
+ "markdownHeadingRegex": "^(#+)",
13
+ "xmlNameAttributeRegex": "name=\"([^\"]+)\"",
14
+ "xmlStandaloneAttributeRegex": "standalone=\"([^\"]+)\"",
15
+ "taskTagRegex": "<task\\s+([^>]+)>",
16
+ "llmCriticalTagRegex": "<llm\\s+critical=\"true\">([\\s\\S]*?)<\\/llm>",
17
+ "integrationTagRegex": "<integration\\s+description=\"([^\"]+)\">([\\s\\S]*?)<\\/integration>",
18
+ "stepTagRegex": "<step\\s+n=\"(\\d+)\"(?:\\s+title=\"([^\"]+)\")?(?:\\s+goal=\"([^\"]+)\")?[^>]*>",
19
+ "caseTagRegex": "<case\\s+n=\"([^\"]+)\">([\\s\\S]*?)<\\/case>",
20
+ "descTagRegex": "<desc>([^<]+)<\\/desc>",
21
+ "instructionItemTagRegex": "<i>([\\s\\S]*?)<\\/i>",
22
+ "actionTagRegex": "<action>([^<]+)<\\/action>",
23
+ "askTagRegex": "<ask>([^<]+)<\\/ask>",
24
+ "criticalTagRegex": "<critical>([^<]+)<\\/critical>",
25
+ "outputTagRegex": "<output>([^<]+)<\\/output>",
26
+ "checkIfTagRegex": "<check\\s+if=\"([^\"]+)\">([\\s\\S]*?)<\\/check>",
27
+ "gotoAnchorTagRegex": "<goto\\s+anchor=\"([^\"]+)\"\\s*\\/>",
28
+ "invokeWorkflowTagRegex": "<invoke-workflow>([^<]+)<\\/invoke-workflow>",
29
+ "templateOutputTagRegex": "<template-output>([^<]+)<\\/template-output>",
30
+ "anyTagRegex": "<[^>]+>",
31
+ "multipleNewlinesRegex": "\\n{3,}",
32
+ "markdownStepTagRegex": "<step\\s+n=\"(\\d+)\"(?:\\s+goal=\"([^\"]+)\")?>",
33
+ "markdownAskTagRegex": "<ask>(.*?)<\\/ask>",
34
+ "markdownActionTagRegex": "<action>(.*?)<\\/action>",
35
+ "markdownCheckTagRegex": "<check>(.*?)<\\/check>",
36
+ "markdownInvokeWorkflowTagRegex": "<invoke-workflow>(.*?)<\\/invoke-workflow>",
37
+ "markdownTemplateOutputTagRegex": "<template-output>(.*?)<\\/template-output>",
38
+ "taskCloseTagRegex": "<\\/task>",
39
+ "llmOpenTagRegex": "<llm[^>]*>",
40
+ "llmCloseTagRegex": "<\\/llm>",
41
+ "flowOpenTagRegex": "<flow>",
42
+ "flowCloseTagRegex": "<\\/flow>",
43
+ "stepCloseTagRegex": "<\\/step>",
44
+ "checkCloseTagRegex": "<\\/check>",
45
+ "whitespaceRegex": "\\s+",
46
+ "frontmatterSimpleRegex": "^---\\s*\\n([\\s\\S]*?)\\n---",
47
+ "xmlNameRegex": "name=\"([^\"]+)\"",
48
+ "namedSections": [
49
+ "csv-structure",
50
+ "context-analysis",
51
+ "smart-selection",
52
+ "format",
53
+ "response-handling",
54
+ "validation",
55
+ "critical-context",
56
+ "halt-conditions"
57
+ ],
58
+ "sectionRegexTemplate": "<{{section}}[^>]*>([\\s\\S]*?)<\\/{{section}}>",
59
+ "sectionTemplatePlaceholderRegex": "{{section}}"
60
+ },
61
+ "agentParsing": {
62
+ "principleLineRegex": "^-\\s*['\"]?",
63
+ "principleTrailingQuoteRegex": "['\"]\\s*$",
64
+ "triggerSanitizationRegex": "\\s+",
65
+ "workflowCodeRegex": "\\[(\\w+)\\]",
66
+ "workflowCodeGlobalRegex": "\\[(\\w+)\\]/g",
67
+ "bracketRemovalRegex": "[[\\]]",
68
+ "hyphenRegex": "-",
69
+ "underscoreRegex": "_"
70
+ },
71
+ "sanitization": {
72
+ "invalidCharsRegex": "[^a-z0-9-]",
73
+ "multipleHyphensRegex": "-+",
74
+ "leadingTrailingHyphensRegex": "^-|-$",
75
+ "trailingHyphensRegex": "-+$"
76
+ },
77
+ "fallbackModuleExtractionPatterns": [
78
+ { "pattern": "^src\\/modules\\/([^/]+)\\/", "group": 1 },
79
+ { "pattern": "^src\\/([^/]+)\\/", "group": 1 }
80
+ ],
9
81
  "moduleExtractionPatterns": [
10
82
  { "pattern": "^src\\/modules\\/([^/]+)\\/", "group": 1 },
11
83
  { "pattern": "^src\\/([^/]+)\\/", "group": 1 }
@@ -59,7 +131,9 @@
59
131
  ],
60
132
  "skillMap": {
61
133
  "sourcePrefix": "\\{project-root\\}/_bmad/",
62
- "dirLookahead": "(?=['\\s`]|$)",
134
+ "dirLookahead": "(?=['\\s`/]|$)",
135
+ "fileLookahead": "(?=['\\s`]|$)",
136
+ "escapedPathRegex": "[.*+?^${}()|[\\]\\\\]",
63
137
  "replacementPrefix": "{skill-root}"
64
138
  },
65
139
  "pathPatterns": [
package/convert.js CHANGED
@@ -58,14 +58,13 @@ output_folder: "_bmad-output"
58
58
  * @returns {Promise<string>} YAML config content
59
59
  */
60
60
  async function getModuleConfigContent(moduleName, bmadRoot = null) {
61
- const header = `# ${moduleName.toUpperCase()} Module Configuration
62
- # Generated by BMAD Skills Converter
63
- # Date: ${new Date().toISOString()}
64
- #
65
- # Customize these values for your project.
66
- # Run the bootstrap-bmad-skills skill for guided configuration.
61
+ const headerTemplate =
62
+ config.configHeaderTemplate ||
63
+ `# ${moduleName.toUpperCase()} Module Configuration\n# Generated by BMAD Skills Converter\n# Date: ${new Date().toISOString()}\n#\n# Customize these values for your project.\n# Run the bootstrap-bmad-skills skill for guided configuration.\n\n`;
67
64
 
68
- `;
65
+ const header = headerTemplate
66
+ .replace('{{moduleName}}', moduleName.toUpperCase())
67
+ .replace('{{date}}', new Date().toISOString());
69
68
 
70
69
  // Try to load config template from BMAD repo
71
70
  if (bmadRoot) {
@@ -402,7 +401,12 @@ async function main() {
402
401
  bmadRoot,
403
402
  config.agentPaths,
404
403
  config.workflowPaths,
405
- { moduleExtractionPatterns: config.moduleExtractionPatterns }
404
+ {
405
+ moduleExtractionPatterns: config.moduleExtractionPatterns,
406
+ fallbackModuleExtractionPatterns:
407
+ config.fallbackModuleExtractionPatterns,
408
+ workflowParsingPatterns: config.workflowParsing || {},
409
+ }
406
410
  );
407
411
 
408
412
  stats.agents.total = agents.length;
@@ -466,6 +470,7 @@ async function main() {
466
470
  identityCharLimit: config.enhancements.identityCharLimit ?? null,
467
471
  allAgents: agents,
468
472
  allWorkflows: workflows,
473
+ parsingPatterns: config.agentParsing || {},
469
474
  };
470
475
  for (const agent of agents) {
471
476
  try {
@@ -497,7 +502,9 @@ async function main() {
497
502
  // Step 5: Convert workflows
498
503
  if (workflows.length > 0) {
499
504
  console.log('⚙️ Converting workflows...');
500
- const workflowOptions = {};
505
+ const workflowOptions = {
506
+ parsingPatterns: config.workflowParsing || {},
507
+ };
501
508
  for (const workflow of workflows) {
502
509
  try {
503
510
  const skillContent = await convertWorkflowToSkill(
@@ -14,13 +14,21 @@ This tool converts BMAD-METHOD (Breakthrough Method for Agile AI-Driven Developm
14
14
 
15
15
  ### Option A: One-Liner (Recommended)
16
16
 
17
- The fastest way to get all BMAD skills installed:
17
+ The fastest way to get all BMAD skills installed (auto-detects tool):
18
18
 
19
19
  ```bash
20
- npx @clfhhc/bmad-methods-skills init --tool=[TOOL] --bootstrap
20
+ npx @clfhhc/bmad-methods-skills --output-dir .temp/converted-skills && \
21
+ npx @clfhhc/bmad-methods-skills install --from=.temp/converted-skills --force && \
22
+ rm -rf .temp
21
23
  ```
22
24
 
23
- Replace `[TOOL]` with `antigravity`, `cursor`, or `claude`.
25
+ For a specific tool (e.g., Cursor):
26
+
27
+ ```bash
28
+ npx @clfhhc/bmad-methods-skills --output-dir .temp/converted-skills && \
29
+ npx @clfhhc/bmad-methods-skills install --from=.temp/converted-skills --tool=cursor --force && \
30
+ rm -rf .temp
31
+ ```
24
32
 
25
33
  This single command:
26
34
  1. ✅ Fetches and converts all BMAD agents/workflows
@@ -52,7 +60,7 @@ The `BS` command starts an AI-guided workflow that:
52
60
  2. Asks if you want global or project-specific installation
53
61
  3. Walks you through custom configuration options
54
62
 
55
- > **Note**: The `BS` workflow now recommends using `--bootstrap` for the actual installation.
63
+ > **Note**: The `BS` workflow now uses a unified conversion and installation process.
56
64
 
57
65
  ## Prerequisites
58
66
 
@@ -222,7 +230,6 @@ pnpm clean:all
222
230
  |---------|-------------|
223
231
  | `npx @clfhhc/bmad-methods-skills` | Run the converter (fetch + convert) |
224
232
  | `npx @clfhhc/bmad-methods-skills init` | Install bootstrap skills only |
225
- | `npx @clfhhc/bmad-methods-skills init --bootstrap` | **Full install**: fetch, convert, and install |
226
233
  | `npx @clfhhc/bmad-methods-skills install --from=<path>` | Install from a local directory |
227
234
 
228
235
  ### Init Options
@@ -230,5 +237,4 @@ pnpm clean:all
230
237
  | Option | Description |
231
238
  |--------|-------------|
232
239
  | `--tool=<name>` | Target tool: `antigravity`, `cursor`, or `claude` |
233
- | `--bootstrap` | Auto-fetch, convert, and install the full BMAD suite |
234
240
  | `--force` | Overwrite existing skills |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clfhhc/bmad-methods-skills",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "Convert BMAD-METHOD agents and workflows to Claude Skills format",
5
5
  "type": "module",
6
6
  "repository": {
@@ -13,112 +13,153 @@ description: Bootstrap and install BMAD-METHOD skills for Claude Code, Cursor, A
13
13
 
14
14
  ## When You Run BS (Guided Flow)
15
15
 
16
- For **`BS`** or **`bootstrap-skills`**: (1) Ask Step 1 (Tool), Step 2 (Scope), and **Configure Installation**—offer defaults. (2) Summarize and **confirm** before running commands. (3) Run the one-liner or Manual (Steps 3–5) as appropriate; write the user’s config to `{skill-root}/_config/core.yaml` and `{skill-root}/_config/bmm.yaml` after install.
16
+ Follow these phases in order. The SAME steps apply whether installing to 1 tool or multiple tools.
17
17
 
18
- ---
19
-
20
- ## Quick Start (Unattended)
18
+ ### Phase 1: Gather Information
21
19
 
22
- Use only when the user explicitly wants to **skip prompts** (e.g. “just run it” or “use defaults”):
23
-
24
- ```bash
25
- npx @clfhhc/bmad-methods-skills init --tool=[TOOL] --bootstrap
26
- ```
20
+ **Step 1 - Tool Selection:** Ask which tool(s) the user is using. Multiple selections allowed:
27
21
 
28
- Replace `[TOOL]` with `antigravity`, `cursor`, or `claude`.
22
+ - [ ] Cursor
23
+ - [ ] Antigravity
24
+ - [ ] Claude Code
25
+ - [ ] Other (specify)
29
26
 
30
- Afterward, the user can edit `{skill-root}/_config/core.yaml` and `{skill-root}/_config/bmm.yaml`, or you can run the **Configure Installation** questions and apply the answers to those files.
27
+ **Step 2 - Scope Selection:** For EACH selected tool, ask:
31
28
 
32
- ---
29
+ - [ ] Project-specific (skills in current project only)
30
+ - [ ] Global (skills available in all projects)
33
31
 
34
- ## Manual Workflow
32
+ **Step 3 - Configuration:** Ask each setting, offer defaults:
35
33
 
36
- Use when the user wants a guided experience, needs **global** install, or when you must run Fetch & Convert and Install as separate steps.
34
+ | Setting | Question | Default |
35
+ |---------|----------|---------|
36
+ | `user_name` | What should agents call you? | BMad |
37
+ | `communication_language` | Language for AI chat? | English |
38
+ | `document_output_language` | Language for documents? | English |
39
+ | `output_folder` | Where to save output files? | `_bmad-output` |
40
+ | `project_name` | What is your project called? | *(directory name)* |
41
+ | `user_skill_level` | Your development experience? | `intermediate` |
42
+ | `planning_artifacts` | Where to store planning docs? | `{output_folder}/planning-artifacts` |
43
+ | `implementation_artifacts` | Where to store implementation docs? | `{output_folder}/implementation-artifacts` |
44
+ | `project_knowledge` | Where to store project knowledge? | `docs` |
37
45
 
38
- ### Step 1: Tool Selection
46
+ If user says "use defaults", skip individual questions and use all defaults.
39
47
 
40
- Ask which tool(s) the user is using. Multiple selections are allowed:
48
+ ### Phase 2: Confirm
41
49
 
42
- - [ ] Claude Code
43
- - [ ] Cursor
44
- - [ ] Antigravity
45
- - [ ] Other (Specify)
50
+ Summarize the plan showing:
46
51
 
47
- ### Step 2: Installation Scope
52
+ 1. Each tool + scope + resolved `{skill-root}` path
53
+ 2. Configuration values to apply
54
+ 3. Exact commands to run
48
55
 
49
- Ask whether to install skills **globally** or **project-specific**:
56
+ **Wait for explicit "yes" or confirmation before proceeding.**
50
57
 
51
- | Scope | Description | Destination |
52
- |-------|-------------|-------------|
53
- | **Global** | Skills available across all projects | Cursor: `~/.cursor/skills/`; Antigravity: `~/.gemini/antigravity/skills/`; Claude Code: `~/.claude/skills/` |
54
- | **Project-Specific** | Skills limited to current repo | Cursor: `.cursor/skills/`; Antigravity: `.agent/skills/`; Claude Code: `.claude/skills/` |
58
+ ### Phase 3: Execute (Unified Steps)
55
59
 
56
- **Note:** On **install**, use `--scope=project` (default) or `--scope=global` / `--global`. Project: `.{tool}/skills/` under cwd (run from project root). Global: `~/.cursor/skills` etc.; `--tool` required.
60
+ **IMPORTANT:** Use these SAME steps for 1 tool or multiple tools. No branching.
57
61
 
58
- ### Step 3: Fetch & Convert
62
+ **Step A - Convert once:**
59
63
 
60
64
  ```bash
61
65
  npx @clfhhc/bmad-methods-skills --output-dir .temp/converted-skills
62
66
  ```
63
67
 
64
- ### Step 4: Install
68
+ Wait for this to complete before proceeding.
69
+
70
+ **Step B - Install to EACH tool:**
65
71
 
66
- **Project-specific** (default; run from project root):
72
+ For each tool+scope the user selected, run:
67
73
 
68
74
  ```bash
69
- npx @clfhhc/bmad-methods-skills install --from=.temp/converted-skills --tool=[TOOL] --force
75
+ npx @clfhhc/bmad-methods-skills install --from=.temp/converted-skills --tool=[TOOL] [--scope=global] --force
70
76
  ```
71
77
 
72
- **Global** (`--tool` required; works from any directory):
78
+ Replace `[TOOL]` with: `cursor`, `antigravity`, or `claude`
79
+
80
+ Include `--scope=global` only if user chose global scope for that tool.
81
+
82
+ **Step C - Cleanup:**
73
83
 
74
84
  ```bash
75
- npx @clfhhc/bmad-methods-skills install --from=.temp/converted-skills --tool=[TOOL] --force --scope=global
85
+ rm -rf .temp
76
86
  ```
77
87
 
78
- (`--global` = `--scope=global`.)
88
+ ### Phase 4: Update Configuration
79
89
 
80
- ### Step 5: Clean up
90
+ For EACH `{skill-root}` (one per tool installed), update the config files with user's answers:
81
91
 
82
- ```bash
83
- rm -rf .temp
92
+ **Update `{skill-root}/_config/core.yaml`:**
93
+
94
+ ```yaml
95
+ user_name: '[user_name value]'
96
+ communication_language: [communication_language value]
97
+ document_output_language: [document_output_language value]
98
+ output_folder: "[output_folder value]"
84
99
  ```
85
100
 
86
- ---
101
+ **Update `{skill-root}/_config/bmm.yaml`:**
87
102
 
88
- ## Configure Installation
103
+ ```yaml
104
+ project_name: '[project_name value]'
105
+ user_skill_level: [user_skill_level value]
106
+ planning_artifacts: "[planning_artifacts value]"
107
+ implementation_artifacts: "[implementation_artifacts value]"
108
+ project_knowledge: "[project_knowledge value]"
89
109
 
90
- Prompt the user for each configuration setting. Offer the defaults shown:
110
+ # Inherited core values
111
+ user_name: '[user_name value]'
112
+ communication_language: [communication_language value]
113
+ document_output_language: [document_output_language value]
114
+ output_folder: "[output_folder value]"
115
+ ```
91
116
 
92
- ### Core Configuration (`{skill-root}/_config/core.yaml`)
117
+ ### Phase 5: Verify
93
118
 
94
- | Setting | Question | Default |
95
- |---------|----------|---------|
96
- | `user_name` | What should agents call you? (Use your name or a team name) | BMad |
97
- | `communication_language` | What language should agents use when chatting with you? | English |
98
- | `document_output_language` | Preferred document output language? | English |
99
- | `output_folder` | Where should output files be saved? | `_bmad-output` |
119
+ For each installed tool, confirm:
100
120
 
101
- ### BMM Configuration (`{skill-root}/_config/bmm.yaml`)
121
+ 1. Skills directory exists with skill folders (e.g., `bmm-analyst/SKILL.md`)
122
+ 2. `{skill-root}/_config/core.yaml` exists with user's values
123
+ 3. `{skill-root}/_config/bmm.yaml` exists with user's values
102
124
 
103
- | Setting | Question | Default |
104
- |---------|----------|---------|
105
- | `project_name` | What is your project called? | *(directory name)* |
106
- | `user_skill_level` | What is your development experience level? | `intermediate` |
107
- | `planning_artifacts` | Where should planning artifacts be stored? (Brainstorming, Briefs, PRDs, UX Designs, Architecture, Epics) | `{output_folder}/planning-artifacts` |
108
- | `implementation_artifacts` | Where should implementation artifacts be stored? (Sprint status, stories, reviews, retrospectives, Quick Flow output) | `{output_folder}/implementation-artifacts` |
109
- | `project_knowledge` | Where should long-term project knowledge be stored? (docs, research, references) | `docs` |
125
+ Report results to user.
126
+
127
+ ---
128
+
129
+ ## Skill Root Reference
130
+
131
+ `{skill-root}` resolves to different paths based on tool and scope:
132
+
133
+ | Tool | Project Scope | Global Scope |
134
+ |------|---------------|--------------|
135
+ | **Cursor** | `.cursor/skills/` | `~/.cursor/skills/` |
136
+ | **Antigravity** | `.agent/skills/` | `~/.gemini/antigravity/skills/` |
137
+ | **Claude Code** | `.claude/skills/` | `~/.claude/skills/` |
138
+
139
+ **Important:** Config files MUST be inside `{skill-root}/_config/`, NOT at project root.
110
140
 
111
141
  ---
112
142
 
113
- ## Verify
143
+ ## Quick Start (Unattended)
144
+
145
+ Skip prompts and use defaults. Replace `[TOOL]` with `cursor`, `antigravity`, or `claude`.
114
146
 
115
- 1. Skills installed at the correct destination
116
- 2. Config files exist: `_config/core.yaml`, `_config/bmm.yaml`
117
- 3. Paths under `_config` use the `{skill-root}` variable
147
+ ```bash
148
+ npx @clfhhc/bmad-methods-skills --output-dir .temp/converted-skills && \
149
+ npx @clfhhc/bmad-methods-skills install --from=.temp/converted-skills --tool=[TOOL] --force && \
150
+ rm -rf .temp
151
+ ```
152
+
153
+ *Omit `--tool` to auto-detect. Add `--scope=global` for global.*
154
+
155
+ After installation, run **BS** to configure settings, or manually edit `{skill-root}/_config/core.yaml` and `{skill-root}/_config/bmm.yaml`.
156
+
157
+ ---
118
158
 
119
159
  ## Guidelines
120
160
 
121
- - **Guided Flow**: For `BS`, follow **When You Run BS (Guided Flow)** above.
122
- - **Confirmation**: Always summarize the plan and ask for confirmation before executing automated installation commands.
123
- - **Defaults**: Offer defaults—don’t force the user to answer every question if they accept the suggested values.
124
- - **Overwrite**: Ask before overwriting existing skills unless `--force` is used.
161
+ - **Always confirm** before executing installation commands
162
+ - **Offer defaults** - don't force user to answer every question
163
+ - **No branching** - use the same convert+install steps for 1 or many tools
164
+ - **Handle errors gracefully** - if a command fails, report it and continue if possible
165
+ - **Verify installation** - always check that config files exist in the correct location
@@ -1,5 +1,6 @@
1
1
  import fs from 'fs-extra';
2
2
  import yaml from 'js-yaml';
3
+ import { sanitizeSkillName } from '../utils/sanitizer.js';
3
4
 
4
5
  /**
5
6
  * Converts a BMAD agent.yaml file to Claude Skills SKILL.md format
@@ -43,7 +44,9 @@ export async function convertAgentToSkill(agentPath, options = {}) {
43
44
  const startupMessage = agentData.startup_message || '';
44
45
 
45
46
  // Extract and sanitize name
46
- const name = sanitizeName(metadata.id || metadata.name || 'unknown-agent');
47
+ const name = sanitizeSkillName(
48
+ metadata.id || metadata.name || 'unknown-agent'
49
+ );
47
50
 
48
51
  // Build description from role and identity
49
52
  const role = persona.role || 'Agent';
@@ -75,6 +78,7 @@ export async function convertAgentToSkill(agentPath, options = {}) {
75
78
  allAgents: options.allAgents || [],
76
79
  allWorkflows: options.allWorkflows || [],
77
80
  currentModule: options.currentModule || null,
81
+ parsingPatterns: options.parsingPatterns || {},
78
82
  });
79
83
 
80
84
  return skillContent;
@@ -83,19 +87,6 @@ export async function convertAgentToSkill(agentPath, options = {}) {
83
87
  }
84
88
  }
85
89
 
86
- /**
87
- * Sanitizes a name for use in SKILL.md frontmatter
88
- * @param {string} name - Original name
89
- * @returns {string} Sanitized name
90
- */
91
- function sanitizeName(name) {
92
- return name
93
- .toLowerCase()
94
- .replace(/[^a-z0-9-]/g, '-')
95
- .replace(/-+/g, '-')
96
- .replace(/^-|-$/g, '');
97
- }
98
-
99
90
  /**
100
91
  * Builds the complete SKILL.md content for an agent
101
92
  */
@@ -110,6 +101,7 @@ function buildAgentSkillContent({
110
101
  allAgents,
111
102
  allWorkflows,
112
103
  currentModule,
104
+ parsingPatterns = {},
113
105
  }) {
114
106
  const displayName = metadata.name || metadata.title || name;
115
107
  const role = persona.role || 'Agent';
@@ -121,11 +113,19 @@ function buildAgentSkillContent({
121
113
  principles = principlesRaw;
122
114
  } else if (typeof principlesRaw === 'string') {
123
115
  // Parse multiline string format: extract lines starting with "-"
116
+ const principleLineRegex =
117
+ parsingPatterns.principleLineRegex || '^-\\s*[\'"]?';
118
+ const principleTrailingQuoteRegex =
119
+ parsingPatterns.principleTrailingQuoteRegex || '[\'"]\\s*$';
124
120
  principles = principlesRaw
125
121
  .split('\n')
126
122
  .map((line) => line.trim())
127
123
  .filter((line) => line.length > 0 && line.startsWith('-'))
128
- .map((line) => line.replace(/^-\s*['"]?/, '').replace(/['"]\s*$/, ''));
124
+ .map((line) =>
125
+ line
126
+ .replace(new RegExp(principleLineRegex), '')
127
+ .replace(new RegExp(principleTrailingQuoteRegex), '')
128
+ );
129
129
  }
130
130
  const communicationStyle = persona.communication_style || '';
131
131
 
@@ -189,7 +189,7 @@ ${menu
189
189
  const desc = item.description || 'No description';
190
190
  // Format command with better structure
191
191
  const commandCode = `\`${trigger}\``;
192
- return `- **${commandCode}** or fuzzy match on \`${trigger.toLowerCase().replace(/\s+/g, '-')}\` - ${desc}`;
192
+ return `- **${commandCode}** or fuzzy match on \`${trigger.toLowerCase().replace(new RegExp(parsingPatterns.triggerSanitizationRegex || '\\s+', 'g'), '-')}\` - ${desc}`;
193
193
  })
194
194
  .join('\n')}`;
195
195
  }
@@ -210,24 +210,34 @@ ${menu
210
210
  const trigger = item.trigger || 'unknown';
211
211
  const desc = item.description || 'No description';
212
212
  // Extract workflow code from description if it references a workflow (e.g., [WS])
213
- const workflowMatch = desc.match(/\[(\w+)\]/);
213
+ const workflowCodeRegex =
214
+ parsingPatterns.workflowCodeRegex || '\\[(\\w+)\\]';
215
+ const workflowMatch = desc.match(new RegExp(workflowCodeRegex));
214
216
  const workflowCode = workflowMatch ? workflowMatch[1] : null;
215
217
 
216
218
  // Extract just the short code (e.g., "WS" from "WS or fuzzy match on workflow-status")
217
- const shortCode = trigger.split(/\s+/)[0];
219
+ const shortCode = trigger.split(
220
+ new RegExp(parsingPatterns.triggerSanitizationRegex || '\\s+')
221
+ )[0];
218
222
 
219
223
  // Try to find the workflow
220
224
  let workflow = null;
221
225
  if (workflowCode) {
226
+ const hyphenRegex = parsingPatterns.hyphenRegex || '-';
227
+ const underscoreRegex = parsingPatterns.underscoreRegex || '_';
222
228
  workflow = allWorkflows.find(
223
229
  (w) =>
224
- w.name.toLowerCase().replace(/-/g, '') ===
225
- workflowCode.toLowerCase().replace(/_/g, '')
230
+ w.name.toLowerCase().replace(new RegExp(hyphenRegex, 'g'), '') ===
231
+ workflowCode
232
+ .toLowerCase()
233
+ .replace(new RegExp(underscoreRegex, 'g'), '')
226
234
  );
227
235
  }
228
236
 
229
237
  // Build a concise example - just description and code, no redundancy
230
- const cleanDesc = desc.replace(/\[(\w+)\]\s*/, '').trim();
238
+ const cleanDesc = desc
239
+ .replace(new RegExp(`${workflowCodeRegex}\\s*`), '')
240
+ .trim();
231
241
  content += `\n\n**${cleanDesc}**`;
232
242
 
233
243
  if (workflow) {
@@ -247,12 +257,21 @@ ${menu
247
257
  for (const item of menu) {
248
258
  const desc = item.description || '';
249
259
  // Look for workflow references in format [WS], [BP], etc.
250
- const workflowCodes = desc.match(/\[(\w+)\]/g) || [];
260
+ const workflowCodeRegex =
261
+ parsingPatterns.workflowCodeRegex || '\\[(\\w+)\\]';
262
+ const workflowCodes = desc.match(new RegExp(workflowCodeRegex, 'g')) || [];
251
263
  for (const code of workflowCodes) {
252
- const codeName = code.replace(/[[\]]/g, '').toLowerCase();
264
+ const bracketRemovalRegex =
265
+ parsingPatterns.bracketRemovalRegex || '[[\\]]';
266
+ const codeName = code
267
+ .replace(new RegExp(bracketRemovalRegex, 'g'), '')
268
+ .toLowerCase();
253
269
  // Try to match workflow names
254
270
  const matchingWorkflow = allWorkflows.find((w) => {
255
- const wName = w.name.toLowerCase().replace(/-/g, '');
271
+ const hyphenRegex = parsingPatterns.hyphenRegex || '-';
272
+ const wName = w.name
273
+ .toLowerCase()
274
+ .replace(new RegExp(hyphenRegex, 'g'), '');
256
275
  return (
257
276
  wName.includes(codeName) || codeName.includes(wName.substring(0, 2))
258
277
  );
@@ -1,6 +1,7 @@
1
1
  import path from 'node:path';
2
2
  import fs from 'fs-extra';
3
3
  import yaml from 'js-yaml';
4
+ import { sanitizeSkillName } from '../utils/sanitizer.js';
4
5
 
5
6
  /**
6
7
  * Converts a BMAD workflow to Claude Skills SKILL.md format
@@ -19,7 +20,7 @@ export async function convertWorkflowToSkill(
19
20
  _instructionsType = null,
20
21
  options = {}
21
22
  ) {
22
- const { isMarkdown } = {
23
+ const { isMarkdown, parsingPatterns = {} } = {
23
24
  isMarkdown: false,
24
25
  ...options,
25
26
  };
@@ -46,8 +47,11 @@ export async function convertWorkflowToSkill(
46
47
 
47
48
  // Parse frontmatter and content
48
49
  // More flexible regex: allows optional newlines and handles various formats
50
+ const frontmatterRegex =
51
+ parsingPatterns.frontmatterRegex ||
52
+ '^---\\s*\\n([\\s\\S]*?)\\n---\\s*\\n([\\s\\S]*)$';
49
53
  const frontmatterMatch = workflowContent.match(
50
- /^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/
54
+ new RegExp(frontmatterRegex)
51
55
  );
52
56
 
53
57
  if (frontmatterMatch) {
@@ -64,17 +68,19 @@ export async function convertWorkflowToSkill(
64
68
  // The markdown content IS the instructions
65
69
  // Normalize headings to ensure hierarchy fits under "## Instructions"
66
70
  // Downgrade all headings: # -> ###, ## -> ####
67
- instructionsContent = parseInstructions(markdownContent).replace(
68
- /^(#+)/gm,
69
- '##$1'
70
- );
71
+ const headingRegex = parsingPatterns.markdownHeadingRegex || '^(#+)';
72
+ instructionsContent = parseInstructions(
73
+ markdownContent,
74
+ parsingPatterns
75
+ ).replace(new RegExp(headingRegex, 'gm'), '##$1');
71
76
  } else {
72
77
  // No frontmatter, treat entire file as instructions
73
78
  // Try to extract basic metadata from filename or use defaults
74
- instructionsContent = parseInstructions(workflowContent).replace(
75
- /^(#+)/gm,
76
- '##$1'
77
- );
79
+ const headingRegex = parsingPatterns.markdownHeadingRegex || '^(#+)';
80
+ instructionsContent = parseInstructions(
81
+ workflowContent,
82
+ parsingPatterns
83
+ ).replace(new RegExp(headingRegex, 'gm'), '##$1');
78
84
  }
79
85
  } else if (workflowPath.endsWith('.xml')) {
80
86
  // workflow.xml format: pure XML file (like advanced-elicitation)
@@ -87,8 +93,12 @@ export async function convertWorkflowToSkill(
87
93
 
88
94
  // Extract metadata from XML task attributes
89
95
  // Example: <task id="..." name="Advanced Elicitation" standalone="true" ...>
90
- const nameMatch = xmlContent.match(/name="([^"]+)"/);
91
- const standaloneMatch = xmlContent.match(/standalone="([^"]+)"/);
96
+ const xmlNameRegex =
97
+ parsingPatterns.xmlNameAttributeRegex || 'name="([^"]+)"';
98
+ const xmlStandaloneRegex =
99
+ parsingPatterns.xmlStandaloneAttributeRegex || 'standalone="([^"]+)"';
100
+ const nameMatch = xmlContent.match(new RegExp(xmlNameRegex));
101
+ const standaloneMatch = xmlContent.match(new RegExp(xmlStandaloneRegex));
92
102
 
93
103
  workflow = {
94
104
  name: nameMatch ? nameMatch[1] : path.basename(workflowDir),
@@ -97,7 +107,7 @@ export async function convertWorkflowToSkill(
97
107
  };
98
108
 
99
109
  // Parse the XML content as instructions
100
- instructionsContent = parseXmlInstructions(xmlContent);
110
+ instructionsContent = parseXmlInstructions(xmlContent, parsingPatterns);
101
111
  } else {
102
112
  // workflow.yaml format: separate YAML file + instructions file
103
113
  const workflowContent = await fs.readFile(workflowPath, 'utf-8');
@@ -129,7 +139,7 @@ export async function convertWorkflowToSkill(
129
139
  }
130
140
 
131
141
  // Extract workflow metadata
132
- const name = sanitizeName(workflow.name || path.basename(workflowDir));
142
+ const name = sanitizeSkillName(workflow.name || path.basename(workflowDir));
133
143
  const description = workflow.description || 'Workflow';
134
144
  const standalone = workflow.standalone !== false; // Default to true
135
145
  const inputs = workflow.inputs || {};
@@ -275,34 +285,25 @@ export async function convertWorkflowToSkill(
275
285
  }
276
286
  }
277
287
 
278
- /**
279
- * Sanitizes a name for use in SKILL.md frontmatter
280
- * @param {string} name - Original name
281
- * @returns {string} Sanitized name
282
- */
283
- function sanitizeName(name) {
284
- return name
285
- .toLowerCase()
286
- .replace(/[^a-z0-9-]/g, '-')
287
- .replace(/-+/g, '-')
288
- .replace(/^-|-$/g, '');
289
- }
290
-
291
288
  /**
292
289
  * Parses XML instructions.xml to markdown
293
290
  * @param {string} xmlContent - Raw XML instructions content
294
291
  * @returns {string} Parsed markdown
295
292
  */
296
- function parseXmlInstructions(xmlContent) {
293
+ function parseXmlInstructions(xmlContent, patterns = {}) {
297
294
  let parsed = xmlContent;
298
295
 
299
296
  // Extract task-level metadata from <task> attributes
300
- const taskMatch = parsed.match(/<task\s+([^>]+)>/i);
297
+ const taskRegex = patterns.taskTagRegex || '<task\\s+([^>]+)>';
298
+ const taskMatch = parsed.match(new RegExp(taskRegex, 'i'));
301
299
  let taskHeader = '';
302
300
  if (taskMatch) {
303
301
  const attrs = taskMatch[1];
304
- const nameMatch = attrs.match(/name="([^"]+)"/);
305
- const standaloneMatch = attrs.match(/standalone="([^"]+)"/);
302
+ const xmlNameRegex = patterns.xmlNameAttributeRegex || 'name="([^"]+)"';
303
+ const xmlStandaloneRegex =
304
+ patterns.xmlStandaloneAttributeRegex || 'standalone="([^"]+)"';
305
+ const nameMatch = attrs.match(new RegExp(xmlNameRegex));
306
+ const standaloneMatch = attrs.match(new RegExp(xmlStandaloneRegex));
306
307
  if (nameMatch) {
307
308
  taskHeader = `# ${nameMatch[1]}\n\n`;
308
309
  if (standaloneMatch && standaloneMatch[1] === 'true') {
@@ -312,14 +313,18 @@ function parseXmlInstructions(xmlContent) {
312
313
  }
313
314
 
314
315
  // Remove task wrapper
315
- parsed = parsed.replace(/<task\s+[^>]+>/gi, '');
316
- parsed = parsed.replace(/<\/task>/gi, '');
316
+ parsed = parsed.replace(new RegExp(taskRegex, 'gi'), '');
317
+ const taskCloseRegex = patterns.taskCloseTagRegex || '<\\/task>';
318
+ parsed = parsed.replace(new RegExp(taskCloseRegex, 'gi'), '');
317
319
 
318
320
  // Convert <llm critical="true">...</llm> to a Critical section
321
+ const llmCriticalRegex =
322
+ patterns.llmCriticalTagRegex ||
323
+ '<llm\\s+critical="true">([\\s\\S]*?)<\\/llm>';
319
324
  parsed = parsed.replace(
320
- /<llm\s+critical="true">([\s\S]*?)<\/llm>/gi,
325
+ new RegExp(llmCriticalRegex, 'gi'),
321
326
  (_match, content) => {
322
- const items = extractInstructionItems(content);
327
+ const items = extractInstructionItems(content, patterns);
323
328
  if (items.length > 0) {
324
329
  return `\n\n## Critical Instructions\n\n${items.map((i) => `> ⚠️ ${i}`).join('\n')}\n`;
325
330
  }
@@ -328,43 +333,59 @@ function parseXmlInstructions(xmlContent) {
328
333
  );
329
334
 
330
335
  // Remove any remaining <llm> tags
331
- parsed = parsed.replace(/<llm[^>]*>/gi, '');
332
- parsed = parsed.replace(/<\/llm>/gi, '');
336
+ const llmOpenRegex = patterns.llmOpenTagRegex || '<llm[^>]*>';
337
+ const llmCloseRegex = patterns.llmCloseTagRegex || '<\\/llm>';
338
+ parsed = parsed.replace(new RegExp(llmOpenRegex, 'gi'), '');
339
+ parsed = parsed.replace(new RegExp(llmCloseRegex, 'gi'), '');
333
340
 
334
341
  // Convert <integration description="...">...</integration>
342
+ const integrationRegex =
343
+ patterns.integrationTagRegex ||
344
+ '<integration\\s+description="([^"]+)">([\\s\\S]*?)<\\/integration>';
335
345
  parsed = parsed.replace(
336
- /<integration\s+description="([^"]+)">([\s\S]*?)<\/integration>/gi,
346
+ new RegExp(integrationRegex, 'gi'),
337
347
  (_match, desc, content) => {
338
- const items = extractInstructionItems(content);
348
+ const items = extractInstructionItems(content, patterns);
339
349
  return `\n\n## ${desc}\n\n${items.map((i) => `- ${i}`).join('\n')}\n`;
340
350
  }
341
351
  );
342
352
 
343
353
  // Convert <flow>...</flow> wrapper
344
- parsed = parsed.replace(/<flow>/gi, '\n\n## Workflow Steps\n');
345
- parsed = parsed.replace(/<\/flow>/gi, '');
354
+ const flowOpenRegex = patterns.flowOpenTagRegex || '<flow>';
355
+ const flowCloseRegex = patterns.flowCloseTagRegex || '<\\/flow>';
356
+ parsed = parsed.replace(
357
+ new RegExp(flowOpenRegex, 'gi'),
358
+ '\n\n## Workflow Steps\n'
359
+ );
360
+ parsed = parsed.replace(new RegExp(flowCloseRegex, 'gi'), '');
346
361
 
347
362
  // Convert <step n="1" title="...">
363
+ const stepRegex =
364
+ patterns.stepTagRegex ||
365
+ '<step\\s+n="(\\d+)"(?:\\s+title="([^"]+)")?(?:\\s+goal="([^"]+)")?[^>]*>';
366
+ const stepCloseRegex = patterns.stepCloseTagRegex || '<\\/step>';
348
367
  parsed = parsed.replace(
349
- /<step\s+n="(\d+)"(?:\s+title="([^"]+)")?(?:\s+goal="([^"]+)")?[^>]*>/gi,
368
+ new RegExp(stepRegex, 'gi'),
350
369
  (_match, num, title, goal) => {
351
370
  const stepTitle = title || goal || `Step ${num}`;
352
371
  return `\n\n### Step ${num}: ${stepTitle}\n`;
353
372
  }
354
373
  );
355
- parsed = parsed.replace(/<\/step>/gi, '');
374
+ parsed = parsed.replace(new RegExp(stepCloseRegex, 'gi'), '');
356
375
 
357
376
  // Convert <case n="...">...</case> for response handling
377
+ const caseRegex =
378
+ patterns.caseTagRegex || '<case\\s+n="([^"]+)">([\\s\\S]*?)<\\/case>';
358
379
  parsed = parsed.replace(
359
- /<case\s+n="([^"]+)">([\s\S]*?)<\/case>/gi,
380
+ new RegExp(caseRegex, 'gi'),
360
381
  (_match, caseId, content) => {
361
- const items = extractInstructionItems(content);
382
+ const items = extractInstructionItems(content, patterns);
362
383
  return `\n\n**Case "${caseId}":**\n${items.map((i) => ` - ${i}`).join('\n')}\n`;
363
384
  }
364
385
  );
365
386
 
366
387
  // Convert named sections like <csv-structure>, <context-analysis>, <smart-selection>, <format>, <response-handling>
367
- const namedSections = [
388
+ const namedSections = patterns.namedSections || [
368
389
  'csv-structure',
369
390
  'context-analysis',
370
391
  'smart-selection',
@@ -375,8 +396,13 @@ function parseXmlInstructions(xmlContent) {
375
396
  'halt-conditions',
376
397
  ];
377
398
  for (const section of namedSections) {
399
+ const template =
400
+ patterns.sectionRegexTemplate ||
401
+ '<{{section}}[^>]*>([\\s\\S]*?)<\\/{{section}}>';
402
+ const placeholder =
403
+ patterns.sectionTemplatePlaceholderRegex || '{{section}}';
378
404
  const sectionRegex = new RegExp(
379
- `<${section}[^>]*>([\\s\\S]*?)<\\/${section}>`,
405
+ template.replace(new RegExp(placeholder, 'g'), section),
380
406
  'gi'
381
407
  );
382
408
  parsed = parsed.replace(sectionRegex, (_match, content) => {
@@ -386,7 +412,7 @@ function parseXmlInstructions(xmlContent) {
386
412
  .join(' ');
387
413
 
388
414
  // Check if content has <i> items or is plain text
389
- const items = extractInstructionItems(content);
415
+ const items = extractInstructionItems(content, patterns);
390
416
  if (items.length > 0) {
391
417
  return `\n\n**${sectionTitle}:**\n${items.map((i) => `- ${i}`).join('\n')}\n`;
392
418
  }
@@ -396,79 +422,108 @@ function parseXmlInstructions(xmlContent) {
396
422
  }
397
423
 
398
424
  // Convert any remaining <desc>...</desc>
399
- parsed = parsed.replace(/<desc>([^<]+)<\/desc>/gi, (_match, content) => {
425
+ const descRegex = patterns.descTagRegex || '<desc>([^<]+)<\\/desc>';
426
+ parsed = parsed.replace(new RegExp(descRegex, 'gi'), (_match, content) => {
400
427
  return `\n*${content.trim()}*\n`;
401
428
  });
402
429
 
403
430
  // Convert standalone <i>...</i> items (instruction items) to bullet points
404
431
  // Use [\s\S]*? to match across newlines and normalize whitespace
405
- parsed = parsed.replace(/<i>([\s\S]*?)<\/i>/gi, (_match, content) => {
406
- const normalized = content.replace(/\s+/g, ' ').trim();
407
- return `\n- ${normalized}`;
408
- });
432
+ const instructionItemRegex =
433
+ patterns.instructionItemTagRegex || '<i>([\\s\\S]*?)<\\/i>';
434
+ const whitespaceRegex = patterns.whitespaceRegex || '\\s+';
435
+ parsed = parsed.replace(
436
+ new RegExp(instructionItemRegex, 'gi'),
437
+ (_match, content) => {
438
+ const normalized = content
439
+ .replace(new RegExp(whitespaceRegex, 'g'), ' ')
440
+ .trim();
441
+ return `\n- ${normalized}`;
442
+ }
443
+ );
409
444
 
410
445
  // Convert <action>...</action> to - **Action:** ...
411
- parsed = parsed.replace(/<action>([^<]+)<\/action>/gi, (_match, content) => {
446
+ const actionRegex = patterns.actionTagRegex || '<action>([^<]+)<\\/action>';
447
+ parsed = parsed.replace(new RegExp(actionRegex, 'gi'), (_match, content) => {
412
448
  return `\n- **Action:** ${content.trim()}`;
413
449
  });
414
450
 
415
451
  // Convert <ask>...</ask> to - **Ask:** ...
416
- parsed = parsed.replace(/<ask>([^<]+)<\/ask>/gi, (_match, content) => {
452
+ const askRegex = patterns.askTagRegex || '<ask>([^<]+)<\\/ask>';
453
+ parsed = parsed.replace(new RegExp(askRegex, 'gi'), (_match, content) => {
417
454
  return `\n- **Ask:** ${content.trim()}`;
418
455
  });
419
456
 
420
457
  // Convert <critical>...</critical>
458
+ const criticalRegex =
459
+ patterns.criticalTagRegex || '<critical>([^<]+)<\\/critical>';
421
460
  parsed = parsed.replace(
422
- /<critical>([^<]+)<\/critical>/gi,
461
+ new RegExp(criticalRegex, 'gi'),
423
462
  (_match, content) => {
424
463
  return `\n> **Critical:** ${content.trim()}`;
425
464
  }
426
465
  );
427
466
 
428
467
  // Convert <output>...</output>
429
- parsed = parsed.replace(/<output>([^<]+)<\/output>/gi, (_match, content) => {
468
+ const outputRegex = patterns.outputTagRegex || '<output>([^<]+)<\\/output>';
469
+ parsed = parsed.replace(new RegExp(outputRegex, 'gi'), (_match, content) => {
430
470
  return `\n**Output:** ${content.trim()}`;
431
471
  });
432
472
 
433
473
  // Convert <check if="...">...</check>
474
+ const checkIfRegex =
475
+ patterns.checkIfTagRegex || '<check\\s+if="([^"]+)">([\\s\\S]*?)<\\/check>';
476
+ const checkCloseRegex = patterns.checkCloseTagRegex || '<\\/check>';
434
477
  parsed = parsed.replace(
435
- /<check\s+if="([^"]+)">([\s\S]*?)<\/check>/gi,
478
+ new RegExp(checkIfRegex, 'gi'),
436
479
  (_match, condition, content) => {
437
- const items = extractInstructionItems(content);
480
+ const items = extractInstructionItems(content, patterns);
438
481
  if (items.length > 0) {
439
482
  return `\n\n**Check if:** ${condition}\n${items.map((i) => `- ${i}`).join('\n')}\n`;
440
483
  }
441
484
  return `\n\n**Check if:** ${condition}\n`;
442
485
  }
443
486
  );
444
- parsed = parsed.replace(/<\/check>/gi, '');
487
+ parsed = parsed.replace(new RegExp(checkCloseRegex, 'gi'), '');
445
488
 
446
489
  // Convert <goto anchor="..."/>
490
+ const gotoAnchorRegex =
491
+ patterns.gotoAnchorTagRegex || '<goto\\s+anchor="([^"]+)"\\s*\\/>';
447
492
  parsed = parsed.replace(
448
- /<goto\s+anchor="([^"]+)"\s*\/>/gi,
493
+ new RegExp(gotoAnchorRegex, 'gi'),
449
494
  (_match, anchor) => `\n→ Go to: **${anchor}**`
450
495
  );
451
496
 
452
497
  // Convert <invoke-workflow>...</invoke-workflow>
498
+ const invokeWorkflowRegex =
499
+ patterns.invokeWorkflowTagRegex ||
500
+ '<invoke-workflow>([^<]+)<\\/invoke-workflow>';
453
501
  parsed = parsed.replace(
454
- /<invoke-workflow>([^<]+)<\/invoke-workflow>/gi,
502
+ new RegExp(invokeWorkflowRegex, 'gi'),
455
503
  (_match, content) => `\n- **Invoke Workflow:** ${content.trim()}`
456
504
  );
457
505
 
458
506
  // Convert <template-output>...</template-output>
507
+ const templateOutputRegex =
508
+ patterns.templateOutputTagRegex ||
509
+ '<template-output>([^<]+)<\\/template-output>';
459
510
  parsed = parsed.replace(
460
- /<template-output>([^<]+)<\/template-output>/gi,
511
+ new RegExp(templateOutputRegex, 'gi'),
461
512
  (_match, content) => `\n**Template Output:** ${content.trim()}`
462
513
  );
463
514
 
464
515
  // Clean up any remaining XML tags
465
- parsed = parsed.replace(/<[^>]+>/g, '');
516
+ const anyTagRegex = patterns.anyTagRegex || '<[^>]+>';
517
+ parsed = parsed.replace(new RegExp(anyTagRegex, 'g'), '');
466
518
 
467
519
  // Add task header at the beginning
468
520
  parsed = taskHeader + parsed;
469
521
 
470
522
  // Clean up whitespace
471
- parsed = parsed.replace(/\n{3,}/g, '\n\n').trim();
523
+ const multipleNewlinesRegex = patterns.multipleNewlinesRegex || '\\n{3,}';
524
+ parsed = parsed
525
+ .replace(new RegExp(multipleNewlinesRegex, 'g'), '\n\n')
526
+ .trim();
472
527
 
473
528
  return parsed;
474
529
  }
@@ -478,20 +533,25 @@ function parseXmlInstructions(xmlContent) {
478
533
  * Handles <i>...</i> and <desc>...</desc> tags
479
534
  * Normalizes whitespace to single spaces
480
535
  */
481
- function extractInstructionItems(content) {
536
+ function extractInstructionItems(content, patterns = {}) {
482
537
  const items = [];
483
538
 
484
539
  // Helper to normalize whitespace (replace newlines and multiple spaces with single space)
485
- const normalize = (text) => text.replace(/\s+/g, ' ').trim();
540
+ const whitespaceRegex = patterns.whitespaceRegex || '\\s+';
541
+ const normalize = (text) =>
542
+ text.replace(new RegExp(whitespaceRegex, 'g'), ' ').trim();
486
543
 
487
544
  // Extract <desc> first
488
- const descMatches = content.matchAll(/<desc>([\s\S]*?)<\/desc>/gi);
545
+ const descRegex = patterns.descTagRegex || '<desc>([^<]+)<\\/desc>';
546
+ const descMatches = content.matchAll(new RegExp(descRegex, 'gi'));
489
547
  for (const match of descMatches) {
490
548
  items.push(normalize(match[1]));
491
549
  }
492
550
 
493
551
  // Extract <i> items (use [\s\S] to match across newlines)
494
- const iMatches = content.matchAll(/<i>([\s\S]*?)<\/i>/gi);
552
+ const instructionItemRegex =
553
+ patterns.instructionItemTagRegex || '<i>([\\s\\S]*?)<\\/i>';
554
+ const iMatches = content.matchAll(new RegExp(instructionItemRegex, 'gi'));
495
555
  for (const match of iMatches) {
496
556
  items.push(normalize(match[1]));
497
557
  }
@@ -504,46 +564,57 @@ function extractInstructionItems(content) {
504
564
  * @param {string} instructions - Raw instructions content
505
565
  * @returns {string} Parsed markdown
506
566
  */
507
- function parseInstructions(instructions) {
567
+ function parseInstructions(instructions, patterns = {}) {
508
568
  let parsed = instructions;
509
569
 
510
570
  // Convert <step n="1" goal="..."> to ## Step 1: ...
511
- parsed = parsed.replace(
512
- /<step\s+n="(\d+)"(?:\s+goal="([^"]+)")?>/gi,
513
- (_match, num, goal) => {
514
- return goal ? `## Step ${num}: ${goal}` : `## Step ${num}:`;
515
- }
516
- );
571
+ const stepRegex =
572
+ patterns.markdownStepTagRegex ||
573
+ '<step\\s+n="(\\d+)"(?:\\s+goal="([^"]+)")?>';
574
+ parsed = parsed.replace(new RegExp(stepRegex, 'gi'), (_match, num, goal) => {
575
+ return goal ? `## Step ${num}: ${goal}` : `## Step ${num}:`;
576
+ });
517
577
 
518
578
  // Convert </step> to empty (just close the section)
519
- parsed = parsed.replace(/<\/step>/gi, '');
579
+ const stepCloseRegex = patterns.stepCloseTagRegex || '<\\/step>';
580
+ parsed = parsed.replace(new RegExp(stepCloseRegex, 'gi'), '');
520
581
 
521
582
  // Convert <ask>...</ask> to **Ask:** ...
522
- parsed = parsed.replace(/<ask>(.*?)<\/ask>/gis, (_match, content) => {
583
+ const askRegex = patterns.markdownAskTagRegex || '<ask>(.*?)</ask>';
584
+ parsed = parsed.replace(new RegExp(askRegex, 'gis'), (_match, content) => {
523
585
  return `**Ask:** ${content.trim()}`;
524
586
  });
525
587
 
526
588
  // Convert <action>...</action> to **Action:** ...
527
- parsed = parsed.replace(/<action>(.*?)<\/action>/gis, (_match, content) => {
589
+ const actionRegex =
590
+ patterns.markdownActionTagRegex || '<action>(.*?)</action>';
591
+ parsed = parsed.replace(new RegExp(actionRegex, 'gis'), (_match, content) => {
528
592
  return `**Action:** ${content.trim()}`;
529
593
  });
530
594
 
531
595
  // Convert <check>...</check> to **Check:** ...
532
- parsed = parsed.replace(/<check>(.*?)<\/check>/gis, (_match, content) => {
596
+ const checkRegex = patterns.markdownCheckTagRegex || '<check>(.*?)</check>';
597
+ parsed = parsed.replace(new RegExp(checkRegex, 'gis'), (_match, content) => {
533
598
  return `**Check:** ${content.trim()}`;
534
599
  });
535
600
 
536
601
  // Convert <invoke-workflow>...</invoke-workflow> to **Invoke Workflow:** ...
602
+ const invokeWorkflowRegex =
603
+ patterns.markdownInvokeWorkflowTagRegex ||
604
+ '<invoke-workflow>(.*?)</invoke-workflow>';
537
605
  parsed = parsed.replace(
538
- /<invoke-workflow>(.*?)<\/invoke-workflow>/gis,
606
+ new RegExp(invokeWorkflowRegex, 'gis'),
539
607
  (_match, content) => {
540
608
  return `**Invoke Workflow:** ${content.trim()}`;
541
609
  }
542
610
  );
543
611
 
544
612
  // Convert <template-output>...</template-output> to **Template Output:** ...
613
+ const templateOutputRegex =
614
+ patterns.markdownTemplateOutputTagRegex ||
615
+ '<template-output>(.*?)</template-output>';
545
616
  parsed = parsed.replace(
546
- /<template-output>(.*?)<\/template-output>/gis,
617
+ new RegExp(templateOutputRegex, 'gis'),
547
618
  (_match, content) => {
548
619
  return `**Template Output:** ${content.trim()}`;
549
620
  }
@@ -17,6 +17,7 @@ export async function findAgentsAndWorkflows(
17
17
  workflowPaths,
18
18
  options = {}
19
19
  ) {
20
+ const parsingPatterns = options.workflowParsingPatterns || {};
20
21
  if (!bmadRoot || !(await fs.pathExists(bmadRoot))) {
21
22
  throw new Error(`BMAD root directory does not exist: ${bmadRoot}`);
22
23
  }
@@ -46,7 +47,11 @@ export async function findAgentsAndWorkflows(
46
47
  }
47
48
 
48
49
  const relativePath = path.relative(bmadRoot, filePath);
49
- const module = extractModule(relativePath, moduleExtractionPatterns);
50
+ const module = extractModule(
51
+ relativePath,
52
+ moduleExtractionPatterns,
53
+ options.fallbackModuleExtractionPatterns
54
+ );
50
55
  const name = path.basename(filePath, '.agent.yaml');
51
56
 
52
57
  if (!name || name.trim() === '') {
@@ -125,7 +130,8 @@ export async function findAgentsAndWorkflows(
125
130
  const relativePath = path.relative(bmadRoot, absolutePath);
126
131
  const module = extractModule(
127
132
  relativePath,
128
- moduleExtractionPatterns
133
+ moduleExtractionPatterns,
134
+ options.fallbackModuleExtractionPatterns
129
135
  );
130
136
 
131
137
  const isMarkdown = absolutePath.endsWith('.md');
@@ -136,7 +142,10 @@ export async function findAgentsAndWorkflows(
136
142
  // Extract name from file content
137
143
  const content = await fs.readFile(absolutePath, 'utf-8');
138
144
  if (isMarkdown) {
139
- const match = content.match(/^---\s*\n([\s\S]*?)\n---/);
145
+ const frontmatterSimpleRegex =
146
+ parsingPatterns.frontmatterSimpleRegex ||
147
+ '^---\\s*\\n([\\s\\S]*?)\\n---';
148
+ const match = content.match(new RegExp(frontmatterSimpleRegex));
140
149
  if (match) {
141
150
  try {
142
151
  const frontmatter = yaml.load(match[1]);
@@ -146,7 +155,9 @@ export async function findAgentsAndWorkflows(
146
155
  }
147
156
  }
148
157
  } else if (isXml) {
149
- const match = content.match(/name="([^"]+)"/);
158
+ const xmlNameRegex =
159
+ parsingPatterns.xmlNameRegex || 'name="([^"]+)"';
160
+ const match = content.match(new RegExp(xmlNameRegex));
150
161
  if (match) name = match[1];
151
162
  } else {
152
163
  try {
@@ -247,24 +258,23 @@ export async function findAgentsAndWorkflows(
247
258
  return { agents, workflows };
248
259
  }
249
260
 
250
- function extractModule(relativePath, moduleExtractionPatterns) {
251
- if (
261
+ function extractModule(
262
+ relativePath,
263
+ moduleExtractionPatterns,
264
+ fallbackPatterns
265
+ ) {
266
+ const patterns =
252
267
  Array.isArray(moduleExtractionPatterns) &&
253
268
  moduleExtractionPatterns.length > 0
254
- ) {
255
- for (const { pattern, group } of moduleExtractionPatterns) {
269
+ ? moduleExtractionPatterns
270
+ : fallbackPatterns;
271
+
272
+ if (Array.isArray(patterns) && patterns.length > 0) {
273
+ for (const { pattern, group } of patterns) {
256
274
  const m = relativePath.match(new RegExp(pattern));
257
- if (m && m[group] != null) return m[group];
275
+ if (m && m[group] != null) return m[group].toLowerCase();
258
276
  }
259
- return null;
260
277
  }
261
278
 
262
- // Fallback when moduleExtractionPatterns not provided (backward compatibility)
263
- const modulesMatch = relativePath.match(/^src\/modules\/([^/]+)\//);
264
- if (modulesMatch) return modulesMatch[1];
265
-
266
- const srcMatch = relativePath.match(/^src\/([^/]+)\//);
267
- if (srcMatch) return srcMatch[1];
268
-
269
279
  return null;
270
280
  }
@@ -1,3 +1,5 @@
1
+ import { sanitizeSkillName } from './sanitizer.js';
2
+
1
3
  /**
2
4
  * Rewrites BMAD installation paths to relative skill paths
3
5
  * Converts {project-root}/_bmad/... references to relative skill paths
@@ -22,6 +24,7 @@ export function rewriteBmadPaths(
22
24
  const opts = skillMapOptions || {};
23
25
  const sourcePrefix = opts.sourcePrefix;
24
26
  const dirLookahead = opts.dirLookahead;
27
+ const fileLookahead = opts.fileLookahead;
25
28
  const replacementPrefix = opts.replacementPrefix;
26
29
  const outputStructure = opts.outputStructure ?? 'flat';
27
30
 
@@ -50,20 +53,32 @@ export function rewriteBmadPaths(
50
53
 
51
54
  // 1. Rewrite Workflow Files
52
55
  for (const [srcPath, { module, name }] of skillMap.entries()) {
56
+ const sanitizedName = sanitizeSkillName(name);
57
+ const sanitizedModule = sanitizeSkillName(module);
58
+
53
59
  // Create directory mapping while we're here
54
60
  // srcPath is e.g. "bmm/workflows/testarch/ci/workflow.yaml"
55
61
  const srcDir = srcPath.substring(0, srcPath.lastIndexOf('/'));
56
- dirMap.set(srcDir, { module, name });
62
+ dirMap.set(srcDir, { module: sanitizedModule, name: sanitizedName });
57
63
 
58
64
  // Replace file reference
59
65
  // Pattern: {project-root}/_bmad/{srcPath}
60
66
  // srcPath is now normalized by convert.js
61
- const escapedPath = srcPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
62
- const regex = new RegExp(`${sourcePrefix}${escapedPath}`, 'g');
67
+ const escapedPathRegex =
68
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: the curly braces are in regex
69
+ opts.escapedPathRegex || '[.*+?^${}()|[\\]\\\\]';
70
+ const escapedPath = srcPath.replace(
71
+ new RegExp(escapedPathRegex, 'g'),
72
+ '\\$&'
73
+ );
74
+ const regex = new RegExp(
75
+ `${sourcePrefix}${escapedPath}${fileLookahead || ''}`,
76
+ 'g'
77
+ );
63
78
  const fileReplacement =
64
79
  outputStructure === 'flat'
65
- ? `${replacementPrefix}/${module}-${name}/SKILL.md`
66
- : `${replacementPrefix}/${module}/${name}/SKILL.md`;
80
+ ? `${replacementPrefix}/${sanitizedModule}-${sanitizedName}/SKILL.md`
81
+ : `${replacementPrefix}/${sanitizedModule}/${sanitizedName}/SKILL.md`;
67
82
  result = result.replace(regex, fileReplacement);
68
83
  }
69
84
 
@@ -74,16 +89,23 @@ export function rewriteBmadPaths(
74
89
  );
75
90
 
76
91
  for (const srcDir of sortedDirs) {
77
- const { module, name } = dirMap.get(srcDir);
78
- const escapedDir = srcDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
92
+ const { module: sanitizedModule, name: sanitizedName } =
93
+ dirMap.get(srcDir);
94
+ const escapedPathRegex =
95
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: the curly braces are in regex
96
+ opts.escapedPathRegex || '[.*+?^${}()|[\\]\\\\]';
97
+ const escapedDir = srcDir.replace(
98
+ new RegExp(escapedPathRegex, 'g'),
99
+ '\\$&'
100
+ );
79
101
  const regex = new RegExp(
80
102
  `${sourcePrefix}${escapedDir}${dirLookahead}`,
81
103
  'g'
82
104
  );
83
105
  const dirReplacement =
84
106
  outputStructure === 'flat'
85
- ? `${replacementPrefix}/${module}-${name}`
86
- : `${replacementPrefix}/${module}/${name}`;
107
+ ? `${replacementPrefix}/${sanitizedModule}-${sanitizedName}`
108
+ : `${replacementPrefix}/${sanitizedModule}/${sanitizedName}`;
87
109
  result = result.replace(regex, dirReplacement);
88
110
  }
89
111
  }
@@ -0,0 +1,30 @@
1
+ import path from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+ import fs from 'fs-extra';
4
+
5
+ const __filename = fileURLToPath(import.meta.url);
6
+ const __dirname = path.dirname(__filename);
7
+ const configPath = path.join(__dirname, '../../config.json');
8
+ export const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
9
+
10
+ /**
11
+ * Sanitizes a skill name for use in file paths and URLs.
12
+ * Ensures lowercase, removes invalid characters, and collapses hyphens.
13
+ * @param {string} name - The original skill name
14
+ * @returns {string} The sanitized, lowercase name
15
+ */
16
+ export function sanitizeSkillName(name) {
17
+ if (!name) return '';
18
+
19
+ const patterns = config.sanitization || {};
20
+ const invalidCharsRegex = patterns.invalidCharsRegex || '[^a-z0-9-]';
21
+ const multipleHyphensRegex = patterns.multipleHyphensRegex || '-+';
22
+ const leadingTrailingHyphensRegex =
23
+ patterns.leadingTrailingHyphensRegex || '^-|-$';
24
+
25
+ return name
26
+ .toLowerCase()
27
+ .replace(new RegExp(invalidCharsRegex, 'g'), '-')
28
+ .replace(new RegExp(multipleHyphensRegex, 'g'), '-')
29
+ .replace(new RegExp(leadingTrailingHyphensRegex, 'g'), ''); // Remove leading/trailing hyphens
30
+ }
@@ -1,6 +1,7 @@
1
1
  import path from 'node:path';
2
2
  import fs from 'fs-extra';
3
3
  import { rewriteBmadPaths, shouldRewritePaths } from './path-rewriter.js';
4
+ import { config, sanitizeSkillName } from './sanitizer.js';
4
5
 
5
6
  /**
6
7
  * Writes a SKILL.md file and related resources to the output directory
@@ -31,11 +32,18 @@ export async function writeSkill(
31
32
  throw new Error('skillContent must be a non-empty string');
32
33
  }
33
34
 
34
- // Sanitize skillName to prevent directory traversal
35
- const sanitizedName = skillName
36
- .replace(/[^a-z0-9-]/gi, '-')
37
- .replace(/-+/g, '-');
38
- if (sanitizedName !== skillName) {
35
+ // Sanitize skillName to prevent directory traversal and ensure consistent casing
36
+ const sanitizedName = sanitizeSkillName(skillName);
37
+ const lowercaseName = skillName.toLowerCase();
38
+
39
+ const patterns = config.sanitization || {};
40
+ const trailingHyphensRegex = patterns.trailingHyphensRegex || '-+$';
41
+
42
+ if (
43
+ sanitizedName !== lowercaseName &&
44
+ sanitizedName !==
45
+ lowercaseName.replace(new RegExp(trailingHyphensRegex), '')
46
+ ) {
39
47
  console.warn(
40
48
  `Warning: Skill name sanitized from "${skillName}" to "${sanitizedName}"`
41
49
  );