Compare commits
4 Commits
7b4ade1085
...
4f0b3246ee
| Author | SHA1 | Date |
|---|---|---|
|
|
4f0b3246ee | |
|
|
d09363b1b2 | |
|
|
9924dc6344 | |
|
|
db7b497eeb |
|
|
@ -1926,6 +1926,112 @@ async function runTests() {
|
||||||
|
|
||||||
console.log('');
|
console.log('');
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Test Suite 34: RegistryClient GitHub API Cascade
|
||||||
|
// ============================================================
|
||||||
|
console.log(`${colors.yellow}Test Suite 34: RegistryClient GitHub API Cascade${colors.reset}\n`);
|
||||||
|
|
||||||
|
{
|
||||||
|
const { RegistryClient } = require('../tools/installer/modules/registry-client');
|
||||||
|
|
||||||
|
// Build a RegistryClient with stubbed fetch paths so we can assert on cascade behavior
|
||||||
|
// without making real network calls.
|
||||||
|
function createStubbedClient({ apiResult, rawResult }) {
|
||||||
|
const client = new RegistryClient();
|
||||||
|
const calls = [];
|
||||||
|
|
||||||
|
// Stub _fetchWithHeaders (GitHub API path)
|
||||||
|
client._fetchWithHeaders = async (url) => {
|
||||||
|
calls.push(`api:${url}`);
|
||||||
|
if (apiResult instanceof Error) throw apiResult;
|
||||||
|
return apiResult;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Stub fetch (raw CDN path) — only intercept raw.githubusercontent.com calls
|
||||||
|
const originalFetch = client.fetch.bind(client);
|
||||||
|
client.fetch = async (url, timeout) => {
|
||||||
|
if (url.includes('raw.githubusercontent.com')) {
|
||||||
|
calls.push(`raw:${url}`);
|
||||||
|
if (rawResult instanceof Error) throw rawResult;
|
||||||
|
return rawResult;
|
||||||
|
}
|
||||||
|
return originalFetch(url, timeout);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { client, calls };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- API success skips raw CDN ---
|
||||||
|
{
|
||||||
|
const { client, calls } = createStubbedClient({ apiResult: 'api-content', rawResult: 'raw-content' });
|
||||||
|
const result = await client.fetchGitHubFile('owner', 'repo', 'path/file.txt', 'main');
|
||||||
|
|
||||||
|
assert(result === 'api-content', 'RegistryClient API success returns API content');
|
||||||
|
assert(calls.length === 1, 'RegistryClient API success makes exactly one call');
|
||||||
|
assert(calls[0].startsWith('api:'), 'RegistryClient API success calls API endpoint');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- API failure falls back to raw CDN ---
|
||||||
|
{
|
||||||
|
const { client, calls } = createStubbedClient({ apiResult: new Error('HTTP 403'), rawResult: 'raw-content' });
|
||||||
|
const result = await client.fetchGitHubFile('owner', 'repo', 'path/file.txt', 'main');
|
||||||
|
|
||||||
|
assert(result === 'raw-content', 'RegistryClient API failure returns raw CDN content');
|
||||||
|
assert(calls.length === 2, 'RegistryClient API failure makes two calls');
|
||||||
|
assert(calls[0].startsWith('api:'), 'RegistryClient first call is to API');
|
||||||
|
assert(calls[1].startsWith('raw:'), 'RegistryClient second call is to raw CDN');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Both endpoints failing throws ---
|
||||||
|
{
|
||||||
|
const { client } = createStubbedClient({ apiResult: new Error('HTTP 403'), rawResult: new Error('HTTP 404') });
|
||||||
|
let threw = false;
|
||||||
|
try {
|
||||||
|
await client.fetchGitHubFile('owner', 'repo', 'path/file.txt', 'main');
|
||||||
|
} catch {
|
||||||
|
threw = true;
|
||||||
|
}
|
||||||
|
assert(threw, 'RegistryClient both endpoints failing throws an error');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- API URL construction ---
|
||||||
|
{
|
||||||
|
const { client, calls } = createStubbedClient({ apiResult: 'content', rawResult: 'content' });
|
||||||
|
await client.fetchGitHubFile('bmad-code-org', 'bmad-plugins-marketplace', 'registry/official.yaml', 'main');
|
||||||
|
|
||||||
|
const apiCall = calls[0];
|
||||||
|
assert(
|
||||||
|
apiCall.includes('api.github.com/repos/bmad-code-org/bmad-plugins-marketplace/contents/registry/official.yaml'),
|
||||||
|
'RegistryClient API URL contains correct path',
|
||||||
|
);
|
||||||
|
assert(apiCall.includes('ref=main'), 'RegistryClient API URL contains ref parameter');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Raw CDN URL construction ---
|
||||||
|
{
|
||||||
|
const { client, calls } = createStubbedClient({ apiResult: new Error('fail'), rawResult: 'content' });
|
||||||
|
await client.fetchGitHubFile('bmad-code-org', 'bmad-plugins-marketplace', 'registry/official.yaml', 'main');
|
||||||
|
|
||||||
|
const rawCall = calls[1];
|
||||||
|
assert(
|
||||||
|
rawCall.includes('raw.githubusercontent.com/bmad-code-org/bmad-plugins-marketplace/main/registry/official.yaml'),
|
||||||
|
'RegistryClient raw CDN URL contains correct path',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- fetchGitHubYaml parses YAML ---
|
||||||
|
{
|
||||||
|
const yamlContent = 'modules:\n - name: test\n description: A test module\n';
|
||||||
|
const { client } = createStubbedClient({ apiResult: yamlContent, rawResult: yamlContent });
|
||||||
|
const result = await client.fetchGitHubYaml('owner', 'repo', 'file.yaml', 'main');
|
||||||
|
|
||||||
|
assert(Array.isArray(result.modules), 'fetchGitHubYaml parses YAML correctly');
|
||||||
|
assert(result.modules[0].name === 'test', 'fetchGitHubYaml preserves YAML values');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Summary
|
// Summary
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,9 @@ const { execSync } = require('node:child_process');
|
||||||
const prompts = require('../prompts');
|
const prompts = require('../prompts');
|
||||||
const { RegistryClient } = require('./registry-client');
|
const { RegistryClient } = require('./registry-client');
|
||||||
|
|
||||||
const MARKETPLACE_BASE = 'https://raw.githubusercontent.com/bmad-code-org/bmad-plugins-marketplace/main';
|
const MARKETPLACE_OWNER = 'bmad-code-org';
|
||||||
const COMMUNITY_INDEX_URL = `${MARKETPLACE_BASE}/registry/community-index.yaml`;
|
const MARKETPLACE_REPO = 'bmad-plugins-marketplace';
|
||||||
const CATEGORIES_URL = `${MARKETPLACE_BASE}/categories.yaml`;
|
const MARKETPLACE_REF = 'main';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages community modules from the BMad marketplace registry.
|
* Manages community modules from the BMad marketplace registry.
|
||||||
|
|
@ -33,7 +33,12 @@ class CommunityModuleManager {
|
||||||
if (this._cachedIndex) return this._cachedIndex;
|
if (this._cachedIndex) return this._cachedIndex;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const config = await this._client.fetchYaml(COMMUNITY_INDEX_URL);
|
const config = await this._client.fetchGitHubYaml(
|
||||||
|
MARKETPLACE_OWNER,
|
||||||
|
MARKETPLACE_REPO,
|
||||||
|
'registry/community-index.yaml',
|
||||||
|
MARKETPLACE_REF,
|
||||||
|
);
|
||||||
if (config?.modules?.length) {
|
if (config?.modules?.length) {
|
||||||
this._cachedIndex = config;
|
this._cachedIndex = config;
|
||||||
return config;
|
return config;
|
||||||
|
|
@ -54,7 +59,7 @@ class CommunityModuleManager {
|
||||||
if (this._cachedCategories) return this._cachedCategories;
|
if (this._cachedCategories) return this._cachedCategories;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const config = await this._client.fetchYaml(CATEGORIES_URL);
|
const config = await this._client.fetchGitHubYaml(MARKETPLACE_OWNER, MARKETPLACE_REPO, 'categories.yaml', MARKETPLACE_REF);
|
||||||
if (config?.categories) {
|
if (config?.categories) {
|
||||||
this._cachedCategories = config;
|
this._cachedCategories = config;
|
||||||
return config;
|
return config;
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,9 @@ const yaml = require('yaml');
|
||||||
const prompts = require('../prompts');
|
const prompts = require('../prompts');
|
||||||
const { RegistryClient } = require('./registry-client');
|
const { RegistryClient } = require('./registry-client');
|
||||||
|
|
||||||
const REGISTRY_RAW_URL = 'https://raw.githubusercontent.com/bmad-code-org/bmad-plugins-marketplace/main/registry/official.yaml';
|
const MARKETPLACE_OWNER = 'bmad-code-org';
|
||||||
|
const MARKETPLACE_REPO = 'bmad-plugins-marketplace';
|
||||||
|
const MARKETPLACE_REF = 'main';
|
||||||
const FALLBACK_CONFIG_PATH = path.join(__dirname, 'registry-fallback.yaml');
|
const FALLBACK_CONFIG_PATH = path.join(__dirname, 'registry-fallback.yaml');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -33,8 +35,7 @@ class ExternalModuleManager {
|
||||||
|
|
||||||
// Try remote registry first
|
// Try remote registry first
|
||||||
try {
|
try {
|
||||||
const content = await this._client.fetch(REGISTRY_RAW_URL);
|
const config = await this._client.fetchGitHubYaml(MARKETPLACE_OWNER, MARKETPLACE_REPO, 'registry/official.yaml', MARKETPLACE_REF);
|
||||||
const config = yaml.parse(content);
|
|
||||||
if (config?.modules?.length) {
|
if (config?.modules?.length) {
|
||||||
this.cachedModules = config;
|
this.cachedModules = config;
|
||||||
return config;
|
return config;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,37 @@
|
||||||
const https = require('node:https');
|
const https = require('node:https');
|
||||||
const yaml = require('yaml');
|
const yaml = require('yaml');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a rich Error from a non-2xx response. Includes the URL, the GitHub
|
||||||
|
* JSON error message (or a truncated body snippet), rate-limit reset time,
|
||||||
|
* and Retry-After — anything present that would help a user recover.
|
||||||
|
*/
|
||||||
|
function buildHttpError(url, res, body) {
|
||||||
|
const parts = [`HTTP ${res.statusCode} ${url}`];
|
||||||
|
|
||||||
|
if (body) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(body);
|
||||||
|
if (parsed.message) parts.push(parsed.message);
|
||||||
|
if (parsed.documentation_url) parts.push(`(see ${parsed.documentation_url})`);
|
||||||
|
} catch {
|
||||||
|
const snippet = body.slice(0, 200).trim();
|
||||||
|
if (snippet) parts.push(snippet);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const remaining = res.headers['x-ratelimit-remaining'];
|
||||||
|
const reset = res.headers['x-ratelimit-reset'];
|
||||||
|
if (remaining === '0' && reset) {
|
||||||
|
parts.push(`rate limit exhausted; resets at ${new Date(Number(reset) * 1000).toISOString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const retryAfter = res.headers['retry-after'];
|
||||||
|
if (retryAfter) parts.push(`retry after ${retryAfter}`);
|
||||||
|
|
||||||
|
return new Error(parts.join(' — '));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared HTTP client for fetching registry data from GitHub.
|
* Shared HTTP client for fetching registry data from GitHub.
|
||||||
* Used by ExternalModuleManager, CommunityModuleManager, and CustomModuleManager.
|
* Used by ExternalModuleManager, CommunityModuleManager, and CustomModuleManager.
|
||||||
|
|
@ -12,25 +43,31 @@ class RegistryClient {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch a URL and return the response body as a string.
|
* Fetch a URL and return the response body as a string.
|
||||||
* Follows one redirect (GitHub sometimes 301s).
|
* Follows up to 3 redirects (GitHub sometimes 301s).
|
||||||
* @param {string} url - URL to fetch
|
* @param {string} url - URL to fetch
|
||||||
* @param {number} [timeout] - Timeout in ms (overrides default)
|
* @param {number} [timeout] - Timeout in ms (overrides default)
|
||||||
|
* @param {number} [maxRedirects=3] - Maximum redirects to follow
|
||||||
* @returns {Promise<string>} Response body
|
* @returns {Promise<string>} Response body
|
||||||
*/
|
*/
|
||||||
fetch(url, timeout) {
|
fetch(url, timeout, maxRedirects = 3) {
|
||||||
const timeoutMs = timeout || this.timeout;
|
const timeoutMs = timeout || this.timeout;
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const req = https
|
const req = https
|
||||||
.get(url, { timeout: timeoutMs }, (res) => {
|
.get(url, { timeout: timeoutMs }, (res) => {
|
||||||
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||||||
return this.fetch(res.headers.location, timeoutMs).then(resolve, reject);
|
if (maxRedirects <= 0) {
|
||||||
|
return reject(new Error('Too many redirects'));
|
||||||
}
|
}
|
||||||
if (res.statusCode !== 200) {
|
return this.fetch(res.headers.location, timeoutMs, maxRedirects - 1).then(resolve, reject);
|
||||||
return reject(new Error(`HTTP ${res.statusCode}`));
|
|
||||||
}
|
}
|
||||||
let data = '';
|
let data = '';
|
||||||
res.on('data', (chunk) => (data += chunk));
|
res.on('data', (chunk) => (data += chunk));
|
||||||
res.on('end', () => resolve(data));
|
res.on('end', () => {
|
||||||
|
if (res.statusCode !== 200) {
|
||||||
|
return reject(buildHttpError(url, res, data));
|
||||||
|
}
|
||||||
|
resolve(data);
|
||||||
|
});
|
||||||
})
|
})
|
||||||
.on('error', reject)
|
.on('error', reject)
|
||||||
.on('timeout', () => {
|
.on('timeout', () => {
|
||||||
|
|
@ -50,6 +87,101 @@ class RegistryClient {
|
||||||
const content = await this.fetch(url, timeout);
|
const content = await this.fetch(url, timeout);
|
||||||
return yaml.parse(content);
|
return yaml.parse(content);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a file from a GitHub repo using the Contents API first,
|
||||||
|
* falling back to raw.githubusercontent.com if the API fails.
|
||||||
|
*
|
||||||
|
* The API endpoint (`api.github.com`) is tried first because corporate
|
||||||
|
* proxies commonly block `raw.githubusercontent.com` while allowing
|
||||||
|
* `api.github.com` under the "Software Development" category.
|
||||||
|
*
|
||||||
|
* @param {string} owner - Repository owner (e.g., 'bmad-code-org')
|
||||||
|
* @param {string} repo - Repository name (e.g., 'bmad-plugins-marketplace')
|
||||||
|
* @param {string} filePath - Path within the repo (e.g., 'registry/official.yaml')
|
||||||
|
* @param {string} ref - Git ref (branch, tag, or SHA; e.g., 'main')
|
||||||
|
* @param {number} [timeout] - Timeout in ms (overrides default)
|
||||||
|
* @returns {Promise<string>} Raw file content
|
||||||
|
*/
|
||||||
|
async fetchGitHubFile(owner, repo, filePath, ref, timeout) {
|
||||||
|
const apiUrl = `https://api.github.com/repos/${owner}/${repo}/contents/${filePath}?ref=${ref}`;
|
||||||
|
const rawUrl = `https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${filePath}`;
|
||||||
|
|
||||||
|
// Try GitHub Contents API first (with raw content accept header)
|
||||||
|
try {
|
||||||
|
return await this._fetchWithHeaders(apiUrl, { Accept: 'application/vnd.github.raw+json' }, timeout);
|
||||||
|
} catch (apiError) {
|
||||||
|
// API failed — fall back to raw CDN
|
||||||
|
try {
|
||||||
|
return await this.fetch(rawUrl, timeout);
|
||||||
|
} catch (cdnError) {
|
||||||
|
throw new AggregateError([apiError, cdnError], `Both GitHub API and raw CDN failed for ${filePath}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a file from GitHub and parse as YAML.
|
||||||
|
* @param {string} owner - Repository owner
|
||||||
|
* @param {string} repo - Repository name
|
||||||
|
* @param {string} filePath - Path within the repo
|
||||||
|
* @param {string} ref - Git ref
|
||||||
|
* @param {number} [timeout] - Timeout in ms
|
||||||
|
* @returns {Promise<Object>} Parsed YAML content
|
||||||
|
*/
|
||||||
|
async fetchGitHubYaml(owner, repo, filePath, ref, timeout) {
|
||||||
|
const content = await this.fetchGitHubFile(owner, repo, filePath, ref, timeout);
|
||||||
|
return yaml.parse(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a URL with custom headers. Used for GitHub API requests.
|
||||||
|
* Follows up to 3 redirects.
|
||||||
|
* @param {string} url - URL to fetch
|
||||||
|
* @param {Object} headers - Request headers
|
||||||
|
* @param {number} [timeout] - Timeout in ms
|
||||||
|
* @param {number} [maxRedirects=3] - Maximum redirects to follow
|
||||||
|
* @returns {Promise<string>} Response body
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_fetchWithHeaders(url, headers, timeout, maxRedirects = 3) {
|
||||||
|
const timeoutMs = timeout || this.timeout;
|
||||||
|
const parsed = new URL(url);
|
||||||
|
const options = {
|
||||||
|
hostname: parsed.hostname,
|
||||||
|
path: parsed.pathname + parsed.search,
|
||||||
|
timeout: timeoutMs,
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'bmad-installer',
|
||||||
|
...headers,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = https
|
||||||
|
.get(options, (res) => {
|
||||||
|
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||||||
|
if (maxRedirects <= 0) {
|
||||||
|
return reject(new Error('Too many redirects'));
|
||||||
|
}
|
||||||
|
return this._fetchWithHeaders(res.headers.location, headers, timeoutMs, maxRedirects - 1).then(resolve, reject);
|
||||||
|
}
|
||||||
|
let data = '';
|
||||||
|
res.on('data', (chunk) => (data += chunk));
|
||||||
|
res.on('end', () => {
|
||||||
|
if (res.statusCode !== 200) {
|
||||||
|
return reject(buildHttpError(url, res, data));
|
||||||
|
}
|
||||||
|
resolve(data);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.on('error', reject)
|
||||||
|
.on('timeout', () => {
|
||||||
|
req.destroy();
|
||||||
|
reject(new Error('Request timed out'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { RegistryClient };
|
module.exports = { RegistryClient };
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,316 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
# /// script
|
||||||
|
# requires-python = ">=3.9"
|
||||||
|
# dependencies = []
|
||||||
|
# ///
|
||||||
|
"""Remove legacy module directories from _bmad/ after config migration.
|
||||||
|
|
||||||
|
After merge-config.py and merge-help-csv.py have migrated config data and
|
||||||
|
deleted individual legacy files, this script removes the now-redundant
|
||||||
|
directory trees. These directories contain skill files that are already
|
||||||
|
installed at .claude/skills/ (or equivalent) — only the config files at
|
||||||
|
_bmad/ root need to persist.
|
||||||
|
|
||||||
|
When --skills-dir is provided, the script verifies that every skill found
|
||||||
|
in the legacy directories exists at the installed location before removing
|
||||||
|
anything. Directories without skills (like _config/) are removed directly.
|
||||||
|
|
||||||
|
Exit codes: 0=success (including nothing to remove), 1=validation error, 2=runtime error
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Remove legacy module directories from _bmad/ after config migration."
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--bmad-dir",
|
||||||
|
required=True,
|
||||||
|
help="Path to the _bmad/ directory",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--module-code",
|
||||||
|
required=True,
|
||||||
|
help="Module code being cleaned up (e.g. 'bmb')",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--also-remove",
|
||||||
|
action="append",
|
||||||
|
default=[],
|
||||||
|
help="Additional directory names under _bmad/ to remove (repeatable)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--skills-dir",
|
||||||
|
help="Path to .claude/skills/ — enables safety verification that skills "
|
||||||
|
"are installed before removing legacy copies",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--verbose",
|
||||||
|
action="store_true",
|
||||||
|
help="Print detailed progress to stderr",
|
||||||
|
)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def find_skill_dirs(base_path: str) -> list:
|
||||||
|
"""Find installable skill directories under base_path.
|
||||||
|
|
||||||
|
Only considers SKILL.md files at recognized installable positions:
|
||||||
|
- Direct children: base_path/{name}/SKILL.md (legacy flat layout)
|
||||||
|
- Skills subfolder: base_path/skills/{name}/SKILL.md (current layout)
|
||||||
|
|
||||||
|
SKILL.md files nested deeper (e.g. in tasks/, assets/, or within a
|
||||||
|
skill's own subdirectories) are not installable skills and are skipped.
|
||||||
|
|
||||||
|
NOTE: These discovery rules are intentionally stricter than the installer's
|
||||||
|
recursive collectSkills() behavior. The installer is permissive — it walks
|
||||||
|
the entire tree to find all SKILL.md files for installation. Cleanup must
|
||||||
|
be conservative: we only match the two canonical installable layouts so we
|
||||||
|
never accidentally validate a SKILL.md buried in tasks/, assets/, or other
|
||||||
|
non-installable subdirectories as proof that a skill is present.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of skill directory names (e.g. ['bmad-agent-builder', 'bmad-builder-setup'])
|
||||||
|
"""
|
||||||
|
skills = []
|
||||||
|
root = Path(base_path)
|
||||||
|
if not root.exists():
|
||||||
|
return skills
|
||||||
|
|
||||||
|
# Direct child: {name}/SKILL.md
|
||||||
|
for skill_md in root.glob("*/SKILL.md"):
|
||||||
|
skills.append(skill_md.parent.name)
|
||||||
|
|
||||||
|
# Skills subfolder: skills/{name}/SKILL.md
|
||||||
|
skills_root = root / "skills"
|
||||||
|
if skills_root.exists():
|
||||||
|
for skill_md in skills_root.glob("*/SKILL.md"):
|
||||||
|
skills.append(skill_md.parent.name)
|
||||||
|
|
||||||
|
return sorted(set(skills))
|
||||||
|
|
||||||
|
|
||||||
|
def verify_skills_installed(
|
||||||
|
bmad_dir: str, dirs_to_check: list, skills_dir: str, verbose: bool = False
|
||||||
|
) -> list:
|
||||||
|
"""Verify that skills in legacy directories exist at the installed location.
|
||||||
|
|
||||||
|
Scans each directory in dirs_to_check for skill folders (containing SKILL.md),
|
||||||
|
then checks that a matching directory exists under skills_dir. Directories
|
||||||
|
that contain no skills (like _config/) are silently skipped.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of verified skill names.
|
||||||
|
|
||||||
|
Raises SystemExit(1) if any skills are missing from skills_dir.
|
||||||
|
"""
|
||||||
|
all_verified = []
|
||||||
|
missing = []
|
||||||
|
|
||||||
|
for dirname in dirs_to_check:
|
||||||
|
legacy_path = Path(bmad_dir) / dirname
|
||||||
|
if not legacy_path.exists():
|
||||||
|
continue
|
||||||
|
|
||||||
|
skill_names = find_skill_dirs(str(legacy_path))
|
||||||
|
if not skill_names:
|
||||||
|
if verbose:
|
||||||
|
print(
|
||||||
|
f"No skills found in {dirname}/ — skipping verification",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
for skill_name in skill_names:
|
||||||
|
installed_path = Path(skills_dir) / skill_name
|
||||||
|
if installed_path.is_dir():
|
||||||
|
all_verified.append(skill_name)
|
||||||
|
if verbose:
|
||||||
|
print(
|
||||||
|
f"Verified: {skill_name} exists at {installed_path}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
missing.append(skill_name)
|
||||||
|
if verbose:
|
||||||
|
print(
|
||||||
|
f"MISSING: {skill_name} not found at {installed_path}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
|
||||||
|
if missing:
|
||||||
|
error_result = {
|
||||||
|
"status": "error",
|
||||||
|
"error": "Skills not found at installed location",
|
||||||
|
"missing_skills": missing,
|
||||||
|
"skills_dir": str(Path(skills_dir).resolve()),
|
||||||
|
}
|
||||||
|
print(json.dumps(error_result, indent=2))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
return sorted(set(all_verified))
|
||||||
|
|
||||||
|
|
||||||
|
def count_files(path: Path) -> int:
|
||||||
|
"""Count all files recursively in a directory."""
|
||||||
|
count = 0
|
||||||
|
for item in path.rglob("*"):
|
||||||
|
if item.is_file():
|
||||||
|
count += 1
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_directories(
|
||||||
|
bmad_dir: str, dirs_to_remove: list, verbose: bool = False
|
||||||
|
) -> tuple:
|
||||||
|
"""Remove specified directories under bmad_dir.
|
||||||
|
|
||||||
|
Preserves config.yaml files if present (needed by bmad-init at runtime).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(removed, not_found, total_files_removed) tuple
|
||||||
|
"""
|
||||||
|
removed = []
|
||||||
|
not_found = []
|
||||||
|
total_files = 0
|
||||||
|
|
||||||
|
for dirname in dirs_to_remove:
|
||||||
|
target = Path(bmad_dir) / dirname
|
||||||
|
if not target.exists():
|
||||||
|
not_found.append(dirname)
|
||||||
|
if verbose:
|
||||||
|
print(f"Not found (skipping): {target}", file=sys.stderr)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not target.is_dir():
|
||||||
|
if verbose:
|
||||||
|
print(f"Not a directory (skipping): {target}", file=sys.stderr)
|
||||||
|
not_found.append(dirname)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Validate directory name to prevent path traversal
|
||||||
|
if ".." in dirname or "/" in dirname or "\\" in dirname:
|
||||||
|
error_result = {
|
||||||
|
"status": "error",
|
||||||
|
"error": f"Invalid directory name (path traversal rejected): {dirname}",
|
||||||
|
"directories_removed": removed,
|
||||||
|
"directories_failed": dirname,
|
||||||
|
}
|
||||||
|
print(json.dumps(error_result, indent=2))
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
# Preserve config.yaml if present (bmad-init needs per-module configs)
|
||||||
|
config_path = target / "config.yaml"
|
||||||
|
config_backup = None
|
||||||
|
if config_path.exists():
|
||||||
|
config_backup = config_path.read_bytes()
|
||||||
|
if verbose:
|
||||||
|
print(f"Preserving config.yaml in {dirname}/", file=sys.stderr)
|
||||||
|
|
||||||
|
file_count = count_files(target)
|
||||||
|
if config_backup is not None:
|
||||||
|
file_count -= 1 # Don't count the preserved file
|
||||||
|
if verbose:
|
||||||
|
print(
|
||||||
|
f"Removing {target} ({file_count} files)",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
shutil.rmtree(target)
|
||||||
|
|
||||||
|
# Restore preserved config.yaml
|
||||||
|
if config_backup is not None:
|
||||||
|
target.mkdir(parents=True, exist_ok=True)
|
||||||
|
config_path.write_bytes(config_backup)
|
||||||
|
if verbose:
|
||||||
|
print(
|
||||||
|
f"Restored config.yaml in {dirname}/",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
except OSError as e:
|
||||||
|
logger.error("Failed during cleanup of %s: %s", target, e)
|
||||||
|
error_result = {
|
||||||
|
"status": "error",
|
||||||
|
"error": f"Failed to remove {target}: {e}",
|
||||||
|
"directories_removed": removed,
|
||||||
|
"directories_failed": dirname,
|
||||||
|
}
|
||||||
|
print(json.dumps(error_result, indent=2))
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
removed.append(dirname)
|
||||||
|
total_files += file_count
|
||||||
|
|
||||||
|
return removed, not_found, total_files
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
args = parse_args()
|
||||||
|
|
||||||
|
bmad_dir = args.bmad_dir
|
||||||
|
module_code = args.module_code
|
||||||
|
|
||||||
|
# Build the list of directories to remove
|
||||||
|
dirs_to_remove = [module_code, "core"] + args.also_remove
|
||||||
|
# Deduplicate while preserving order
|
||||||
|
seen = set()
|
||||||
|
unique_dirs = []
|
||||||
|
for d in dirs_to_remove:
|
||||||
|
if d not in seen:
|
||||||
|
seen.add(d)
|
||||||
|
unique_dirs.append(d)
|
||||||
|
dirs_to_remove = unique_dirs
|
||||||
|
|
||||||
|
if args.verbose:
|
||||||
|
print(f"Directories to remove: {dirs_to_remove}", file=sys.stderr)
|
||||||
|
|
||||||
|
# Safety check: verify skills are installed before removing
|
||||||
|
verified_skills = None
|
||||||
|
if args.skills_dir:
|
||||||
|
if args.verbose:
|
||||||
|
print(
|
||||||
|
f"Verifying skills installed at {args.skills_dir}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
verified_skills = verify_skills_installed(
|
||||||
|
bmad_dir, dirs_to_remove, args.skills_dir, args.verbose
|
||||||
|
)
|
||||||
|
|
||||||
|
# Remove directories
|
||||||
|
removed, not_found, total_files = cleanup_directories(
|
||||||
|
bmad_dir, dirs_to_remove, args.verbose
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build result
|
||||||
|
result = {
|
||||||
|
"status": "success",
|
||||||
|
"bmad_dir": str(Path(bmad_dir).resolve()),
|
||||||
|
"directories_removed": removed,
|
||||||
|
"directories_not_found": not_found,
|
||||||
|
"files_removed_count": total_files,
|
||||||
|
}
|
||||||
|
|
||||||
|
if args.skills_dir:
|
||||||
|
result["safety_checks"] = {
|
||||||
|
"skills_verified": True,
|
||||||
|
"skills_dir": str(Path(args.skills_dir).resolve()),
|
||||||
|
"verified_skills": verified_skills,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
result["safety_checks"] = None
|
||||||
|
|
||||||
|
print(json.dumps(result, indent=2))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Loading…
Reference in New Issue