From d03ba50a6058dd624cfd91d99aefd6b6317944c0 Mon Sep 17 00:00:00 2001 From: Brian Madison Date: Thu, 9 Apr 2026 00:32:33 -0500 Subject: [PATCH] feat(installer): add plugin resolution strategies for custom URL installs When installing from a custom GitHub URL, the installer now analyzes marketplace.json plugin structures to determine how to locate module registration files (module.yaml, module-help.csv). Five strategies are tried in cascade: 1. Root module files at the common parent of listed skills 2. A -setup skill with registration files in its assets/ 3. Single standalone skill with registration files in assets/ 4. Multiple standalone skills, each with their own registration files 5. Fallback: synthesize registration from marketplace.json metadata and SKILL.md frontmatter Also changes the custom URL flow from confirm-all to multiselect, letting users pick which plugins to install. Already-installed modules are pre-checked for update; new modules are unchecked for opt-in. New file: tools/installer/modules/plugin-resolver.js Modified: custom-module-manager.js, official-modules.js, ui.js --- .../modules/custom-module-manager.js | 49 +++ tools/installer/modules/official-modules.js | 75 ++++ tools/installer/modules/plugin-resolver.js | 393 ++++++++++++++++++ tools/installer/ui.js | 124 ++++-- 4 files changed, 607 insertions(+), 34 deletions(-) create mode 100644 tools/installer/modules/plugin-resolver.js diff --git a/tools/installer/modules/custom-module-manager.js b/tools/installer/modules/custom-module-manager.js index 18a631a29..5908ecc92 100644 --- a/tools/installer/modules/custom-module-manager.js +++ b/tools/installer/modules/custom-module-manager.js @@ -10,6 +10,9 @@ const { RegistryClient } = require('./registry-client'); * Validates URLs, fetches .claude-plugin/marketplace.json, clones repos. */ class CustomModuleManager { + /** @type {Map} Shared across all instances: module code -> ResolvedModule */ + static _resolutionCache = new Map(); + constructor() { this._client = new RegistryClient(); } @@ -177,6 +180,37 @@ class CustomModuleManager { return repoCacheDir; } + // ─── Plugin Resolution ──────────────────────────────────────────────────── + + /** + * Resolve a plugin to determine installation strategy and module registration files. + * Results are cached in _resolutionCache keyed by module code. + * @param {string} repoPath - Absolute path to the cloned repository + * @param {Object} plugin - Raw plugin object from marketplace.json + * @returns {Promise>} Array of ResolvedModule objects + */ + async resolvePlugin(repoPath, plugin) { + const { PluginResolver } = require('./plugin-resolver'); + const resolver = new PluginResolver(); + const resolved = await resolver.resolve(repoPath, plugin); + + // Cache each resolved module by its code for lookup during install + for (const mod of resolved) { + CustomModuleManager._resolutionCache.set(mod.code, mod); + } + + return resolved; + } + + /** + * Get a cached resolution result by module code. + * @param {string} moduleCode - Module code to look up + * @returns {Object|null} ResolvedModule or null if not cached + */ + getResolution(moduleCode) { + return CustomModuleManager._resolutionCache.get(moduleCode) || null; + } + // ─── Source Finding ─────────────────────────────────────────────────────── /** @@ -236,6 +270,19 @@ class CustomModuleManager { * @returns {string|null} Path to the module source or null */ async findModuleSourceByCode(moduleCode, options = {}) { + // Check resolution cache first (populated by resolvePlugin) + const resolved = CustomModuleManager._resolutionCache.get(moduleCode); + if (resolved) { + // For strategies 1-2: the common parent or setup skill's parent has the module files + if (resolved.moduleYamlPath) { + return path.dirname(resolved.moduleYamlPath); + } + // For strategy 5 (synthesized): return the first skill's parent as a reference path + if (resolved.skillPaths && resolved.skillPaths.length > 0) { + return path.dirname(resolved.skillPaths[0]); + } + } + const cacheDir = this.getCacheDir(); if (!(await fs.pathExists(cacheDir))) return null; @@ -297,6 +344,8 @@ class CustomModuleManager { author: plugin.author || data.owner || '', url: repoUrl, source: plugin.source || null, + skills: plugin.skills || [], + rawPlugin: plugin, type: 'custom', trustTier: 'unverified', builtIn: false, diff --git a/tools/installer/modules/official-modules.js b/tools/installer/modules/official-modules.js index 6b9f76059..8da6e3d1c 100644 --- a/tools/installer/modules/official-modules.js +++ b/tools/installer/modules/official-modules.js @@ -135,6 +135,22 @@ class OfficialModules { const moduleConfigPath = path.join(modulePath, 'module.yaml'); if (!(await fs.pathExists(moduleConfigPath))) { + // Check resolution cache for strategy 5 modules (no module.yaml on disk) + const { CustomModuleManager } = require('./custom-module-manager'); + const customMgr = new CustomModuleManager(); + const resolved = customMgr.getResolution(defaultName); + if (resolved && resolved.synthesizedModuleYaml) { + return { + id: resolved.code, + path: modulePath, + name: resolved.name, + description: resolved.description, + version: resolved.version || '1.0.0', + source: sourceDescription, + dependencies: [], + defaultSelected: false, + }; + } return null; } @@ -232,6 +248,14 @@ class OfficialModules { * @param {Object} options.logger - Logger instance for output */ async install(moduleName, bmadDir, fileTrackingCallback = null, options = {}) { + // Check if this module has a plugin resolution (custom marketplace install) + const { CustomModuleManager } = require('./custom-module-manager'); + const customMgr = new CustomModuleManager(); + const resolved = customMgr.getResolution(moduleName); + if (resolved) { + return this.installFromResolution(resolved, bmadDir, fileTrackingCallback, options); + } + const sourcePath = await this.findModuleSource(moduleName, { silent: options.silent }); const targetPath = path.join(bmadDir, moduleName); @@ -265,6 +289,57 @@ class OfficialModules { return { success: true, module: moduleName, path: targetPath, versionInfo }; } + /** + * Install a module from a PluginResolver resolution result. + * Copies specific skill directories and places module-help.csv at the target root. + * @param {Object} resolved - ResolvedModule from PluginResolver + * @param {string} bmadDir - Target bmad directory + * @param {Function} fileTrackingCallback - Optional callback to track installed files + * @param {Object} options - Installation options + */ + async installFromResolution(resolved, bmadDir, fileTrackingCallback = null, options = {}) { + const targetPath = path.join(bmadDir, resolved.code); + + if (await fs.pathExists(targetPath)) { + await fs.remove(targetPath); + } + + await fs.ensureDir(targetPath); + + // Copy each skill directory, flattened by leaf name + for (const skillPath of resolved.skillPaths) { + const skillDirName = path.basename(skillPath); + const skillTarget = path.join(targetPath, skillDirName); + await this.copyModuleWithFiltering(skillPath, skillTarget, fileTrackingCallback, options.moduleConfig); + } + + // Place module-help.csv at the module root + if (resolved.moduleHelpCsvPath) { + // Strategies 1-4: copy the existing file + const helpTarget = path.join(targetPath, 'module-help.csv'); + await fs.copy(resolved.moduleHelpCsvPath, helpTarget, { overwrite: true }); + if (fileTrackingCallback) fileTrackingCallback(helpTarget); + } else if (resolved.synthesizedHelpCsv) { + // Strategy 5: write synthesized content + const helpTarget = path.join(targetPath, 'module-help.csv'); + await fs.writeFile(helpTarget, resolved.synthesizedHelpCsv, 'utf8'); + if (fileTrackingCallback) fileTrackingCallback(helpTarget); + } + + // Update manifest + const { Manifest } = require('../core/manifest'); + const manifestObj = new Manifest(); + + await manifestObj.addModule(bmadDir, resolved.code, { + version: resolved.version || '', + source: `custom:${resolved.pluginName}`, + npmPackage: '', + repoUrl: '', + }); + + return { success: true, module: resolved.code, path: targetPath, versionInfo: { version: resolved.version || '' } }; + } + /** * Update an existing module * @param {string} moduleName - Name of the module to update diff --git a/tools/installer/modules/plugin-resolver.js b/tools/installer/modules/plugin-resolver.js new file mode 100644 index 000000000..8606da21a --- /dev/null +++ b/tools/installer/modules/plugin-resolver.js @@ -0,0 +1,393 @@ +const fs = require('fs-extra'); +const path = require('node:path'); +const yaml = require('yaml'); + +/** + * Resolves how to install a plugin from marketplace.json by analyzing + * where module.yaml and module-help.csv live relative to the listed skills. + * + * Five strategies, tried in order: + * 1. Root module files at the common parent of all skills + * 2. A -setup skill with assets/module.yaml + assets/module-help.csv + * 3. Single standalone skill with both files in its assets/ + * 4. Multiple standalone skills, each with both files in assets/ + * 5. Fallback: synthesize from marketplace.json + SKILL.md frontmatter + */ +class PluginResolver { + /** + * Resolve a plugin to one or more installable module definitions. + * @param {string} repoPath - Absolute path to the cloned repository root + * @param {Object} plugin - Plugin object from marketplace.json + * @param {string} plugin.name - Plugin identifier + * @param {string} [plugin.source] - Relative path from repo root + * @param {string} [plugin.version] - Semantic version + * @param {string} [plugin.description] - Plugin description + * @param {string[]} [plugin.skills] - Relative paths to skill directories + * @returns {Promise} Array of resolved module definitions + */ + async resolve(repoPath, plugin) { + const skillRelPaths = plugin.skills || []; + + // No skills array: legacy behavior - caller should use existing findModuleSource + if (skillRelPaths.length === 0) { + return []; + } + + // Resolve skill paths to absolute and filter out non-existent + const skillPaths = []; + for (const rel of skillRelPaths) { + const normalized = rel.replace(/^\.\//, ''); + const abs = path.join(repoPath, normalized); + if (await fs.pathExists(abs)) { + skillPaths.push(abs); + } + } + + if (skillPaths.length === 0) { + return []; + } + + // Try each strategy in order + const result = + (await this._tryRootModuleFiles(repoPath, plugin, skillPaths)) || + (await this._trySetupSkill(repoPath, plugin, skillPaths)) || + (await this._trySingleStandalone(repoPath, plugin, skillPaths)) || + (await this._tryMultipleStandalone(repoPath, plugin, skillPaths)) || + (await this._synthesizeFallback(repoPath, plugin, skillPaths)); + + return result; + } + + // ─── Strategy 1: Root Module Files ────────────────────────────────────────── + + /** + * Check if module.yaml + module-help.csv exist at the common parent of all skills. + */ + async _tryRootModuleFiles(repoPath, plugin, skillPaths) { + const commonParent = this._computeCommonParent(skillPaths); + const moduleYamlPath = path.join(commonParent, 'module.yaml'); + const moduleHelpPath = path.join(commonParent, 'module-help.csv'); + + if (!(await fs.pathExists(moduleYamlPath)) || !(await fs.pathExists(moduleHelpPath))) { + return null; + } + + const moduleData = await this._readModuleYaml(moduleYamlPath); + if (!moduleData) return null; + + return [ + { + code: moduleData.code || plugin.name, + name: moduleData.name || plugin.name, + version: plugin.version || moduleData.module_version || null, + description: moduleData.description || plugin.description || '', + strategy: 1, + pluginName: plugin.name, + moduleYamlPath, + moduleHelpCsvPath: moduleHelpPath, + skillPaths, + synthesizedModuleYaml: null, + synthesizedHelpCsv: null, + }, + ]; + } + + // ─── Strategy 2: Setup Skill ──────────────────────────────────────────────── + + /** + * Search for a skill ending in -setup with assets/module.yaml + assets/module-help.csv. + */ + async _trySetupSkill(repoPath, plugin, skillPaths) { + for (const skillPath of skillPaths) { + const dirName = path.basename(skillPath); + if (!dirName.endsWith('-setup')) continue; + + const moduleYamlPath = path.join(skillPath, 'assets', 'module.yaml'); + const moduleHelpPath = path.join(skillPath, 'assets', 'module-help.csv'); + + if (!(await fs.pathExists(moduleYamlPath)) || !(await fs.pathExists(moduleHelpPath))) { + continue; + } + + const moduleData = await this._readModuleYaml(moduleYamlPath); + if (!moduleData) continue; + + return [ + { + code: moduleData.code || plugin.name, + name: moduleData.name || plugin.name, + version: plugin.version || moduleData.module_version || null, + description: moduleData.description || plugin.description || '', + strategy: 2, + pluginName: plugin.name, + moduleYamlPath, + moduleHelpCsvPath: moduleHelpPath, + skillPaths, + synthesizedModuleYaml: null, + synthesizedHelpCsv: null, + }, + ]; + } + + return null; + } + + // ─── Strategy 3: Single Standalone Skill ──────────────────────────────────── + + /** + * One skill listed, with assets/module.yaml + assets/module-help.csv. + */ + async _trySingleStandalone(repoPath, plugin, skillPaths) { + if (skillPaths.length !== 1) return null; + + const skillPath = skillPaths[0]; + const moduleYamlPath = path.join(skillPath, 'assets', 'module.yaml'); + const moduleHelpPath = path.join(skillPath, 'assets', 'module-help.csv'); + + if (!(await fs.pathExists(moduleYamlPath)) || !(await fs.pathExists(moduleHelpPath))) { + return null; + } + + const moduleData = await this._readModuleYaml(moduleYamlPath); + if (!moduleData) return null; + + return [ + { + code: moduleData.code || plugin.name, + name: moduleData.name || plugin.name, + version: plugin.version || moduleData.module_version || null, + description: moduleData.description || plugin.description || '', + strategy: 3, + pluginName: plugin.name, + moduleYamlPath, + moduleHelpCsvPath: moduleHelpPath, + skillPaths, + synthesizedModuleYaml: null, + synthesizedHelpCsv: null, + }, + ]; + } + + // ─── Strategy 4: Multiple Standalone Skills ───────────────────────────────── + + /** + * Multiple skills, each with assets/module.yaml + assets/module-help.csv. + * Each becomes its own installable module. + */ + async _tryMultipleStandalone(repoPath, plugin, skillPaths) { + if (skillPaths.length < 2) return null; + + const resolved = []; + + for (const skillPath of skillPaths) { + const moduleYamlPath = path.join(skillPath, 'assets', 'module.yaml'); + const moduleHelpPath = path.join(skillPath, 'assets', 'module-help.csv'); + + if (!(await fs.pathExists(moduleYamlPath)) || !(await fs.pathExists(moduleHelpPath))) { + continue; + } + + const moduleData = await this._readModuleYaml(moduleYamlPath); + if (!moduleData) continue; + + resolved.push({ + code: moduleData.code || path.basename(skillPath), + name: moduleData.name || path.basename(skillPath), + version: plugin.version || moduleData.module_version || null, + description: moduleData.description || '', + strategy: 4, + pluginName: plugin.name, + moduleYamlPath, + moduleHelpCsvPath: moduleHelpPath, + skillPaths: [skillPath], + synthesizedModuleYaml: null, + synthesizedHelpCsv: null, + }); + } + + // Only use strategy 4 if ALL skills have module files + if (resolved.length === skillPaths.length) { + return resolved; + } + + // Partial match: fall through to strategy 5 + return null; + } + + // ─── Strategy 5: Fallback (Synthesized) ───────────────────────────────────── + + /** + * No module files found anywhere. Synthesize from marketplace.json metadata + * and SKILL.md frontmatter. + */ + async _synthesizeFallback(repoPath, plugin, skillPaths) { + const skillInfos = []; + + for (const skillPath of skillPaths) { + const frontmatter = await this._parseSkillFrontmatter(skillPath); + skillInfos.push({ + dirName: path.basename(skillPath), + name: frontmatter.name || path.basename(skillPath), + description: frontmatter.description || '', + }); + } + + const moduleName = this._formatDisplayName(plugin.name); + const code = plugin.name; + + const synthesizedYaml = { + code, + name: moduleName, + description: plugin.description || '', + module_version: plugin.version || '1.0.0', + default_selected: false, + }; + + const synthesizedCsv = this._buildSynthesizedHelpCsv(moduleName, skillInfos); + + return [ + { + code, + name: moduleName, + version: plugin.version || null, + description: plugin.description || '', + strategy: 5, + pluginName: plugin.name, + moduleYamlPath: null, + moduleHelpCsvPath: null, + skillPaths, + synthesizedModuleYaml: synthesizedYaml, + synthesizedHelpCsv: synthesizedCsv, + }, + ]; + } + + // ─── Helpers ──────────────────────────────────────────────────────────────── + + /** + * Compute the deepest common ancestor directory of an array of absolute paths. + * @param {string[]} absPaths - Absolute directory paths + * @returns {string} Common parent directory + */ + _computeCommonParent(absPaths) { + if (absPaths.length === 0) return '/'; + if (absPaths.length === 1) return path.dirname(absPaths[0]); + + const segments = absPaths.map((p) => p.split(path.sep)); + const minLen = Math.min(...segments.map((s) => s.length)); + const common = []; + + for (let i = 0; i < minLen; i++) { + const segment = segments[0][i]; + if (segments.every((s) => s[i] === segment)) { + common.push(segment); + } else { + break; + } + } + + return common.join(path.sep) || '/'; + } + + /** + * Read and parse a module.yaml file. + * @param {string} yamlPath - Absolute path to module.yaml + * @returns {Object|null} Parsed content or null on failure + */ + async _readModuleYaml(yamlPath) { + try { + const content = await fs.readFile(yamlPath, 'utf8'); + return yaml.parse(content); + } catch { + return null; + } + } + + /** + * Extract name and description from a SKILL.md YAML frontmatter block. + * @param {string} skillDirPath - Absolute path to the skill directory + * @returns {Object} { name, description } or empty strings + */ + async _parseSkillFrontmatter(skillDirPath) { + const skillMdPath = path.join(skillDirPath, 'SKILL.md'); + try { + const content = await fs.readFile(skillMdPath, 'utf8'); + const match = content.match(/^---\s*\n([\s\S]*?)\n---/); + if (!match) return { name: '', description: '' }; + + const parsed = yaml.parse(match[1]); + return { + name: parsed.name || '', + description: parsed.description || '', + }; + } catch { + return { name: '', description: '' }; + } + } + + /** + * Build a synthesized module-help.csv from plugin metadata and skill frontmatter. + * Uses the standard 13-column format. + * @param {string} moduleName - Display name for the module column + * @param {Array<{dirName: string, name: string, description: string}>} skillInfos + * @returns {string} CSV content + */ + _buildSynthesizedHelpCsv(moduleName, skillInfos) { + const header = 'module,skill,display-name,menu-code,description,action,args,phase,after,before,required,output-location,outputs'; + const rows = [header]; + + for (const info of skillInfos) { + const displayName = this._formatDisplayName(info.name || info.dirName); + const menuCode = this._generateMenuCode(info.name || info.dirName); + const description = this._escapeCSVField(info.description); + + rows.push(`${moduleName},${info.dirName},${displayName},${menuCode},${description},activate,,anytime,,,false,,`); + } + + return rows.join('\n') + '\n'; + } + + /** + * Format a kebab-case or snake_case name into a display name. + * Strips common prefixes like "bmad-" or "bmad-agent-". + * @param {string} name - Raw name + * @returns {string} Formatted display name + */ + _formatDisplayName(name) { + let cleaned = name.replace(/^bmad-agent-/, '').replace(/^bmad-/, ''); + return cleaned + .split(/[-_]/) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); + } + + /** + * Generate a short menu code from a skill name. + * Takes first letter of each significant word, uppercased, max 3 chars. + * @param {string} name - Skill name (kebab-case) + * @returns {string} Menu code (e.g., "CC" for "code-coach") + */ + _generateMenuCode(name) { + const cleaned = name.replace(/^bmad-agent-/, '').replace(/^bmad-/, ''); + const words = cleaned.split(/[-_]/).filter((w) => w.length > 0); + return words + .map((w) => w.charAt(0).toUpperCase()) + .join('') + .slice(0, 3); + } + + /** + * Escape a value for CSV output (wrap in quotes if it contains commas, quotes, or newlines). + * @param {string} value + * @returns {string} + */ + _escapeCSVField(value) { + if (!value) return ''; + if (value.includes(',') || value.includes('"') || value.includes('\n')) { + return `"${value.replace(/"/g, '""')}"`; + } + return value; + } +} + +module.exports = { PluginResolver }; diff --git a/tools/installer/ui.js b/tools/installer/ui.js index de8783666..b93cd02ce 100644 --- a/tools/installer/ui.js +++ b/tools/installer/ui.js @@ -848,48 +848,104 @@ class UI { const s = await prompts.spinner(); s.start('Fetching module info...'); + let plugins; try { - const plugins = await customMgr.discoverModules(url.trim()); + plugins = await customMgr.discoverModules(url.trim()); s.stop('Module info loaded'); - - await prompts.log.warn( - 'UNVERIFIED MODULE: This module has not been reviewed by the BMad team.\n' + ' Only install modules from sources you trust.', - ); - - for (const plugin of plugins) { - const versionStr = plugin.version ? ` v${plugin.version}` : ''; - await prompts.log.info(` ${plugin.name}${versionStr}\n ${plugin.description}\n Author: ${plugin.author}`); - } - - const confirmInstall = await prompts.confirm({ - message: `Install ${plugins.length} plugin${plugins.length === 1 ? '' : 's'} from ${url.trim()}?`, - default: false, - }); - - if (confirmInstall) { - // Pre-clone the repo so it's cached for the install pipeline - s.start('Cloning repository...'); - try { - await customMgr.cloneRepo(url.trim()); - s.stop('Repository cloned'); - } catch (cloneError) { - s.error('Failed to clone repository'); - await prompts.log.error(` ${cloneError.message}`); - addMore = await prompts.confirm({ message: 'Try another URL?', default: false }); - continue; - } - - for (const plugin of plugins) { - selectedModules.push(plugin.code); - } - } } catch (error) { s.error('Failed to load module info'); await prompts.log.error(` ${error.message}`); + addMore = await prompts.confirm({ message: 'Try another URL?', default: false }); + continue; + } + + await prompts.log.warn( + 'UNVERIFIED MODULE: This module has not been reviewed by the BMad team.\n' + ' Only install modules from sources you trust.', + ); + + // Clone the repo so we can resolve plugin structures + s.start('Cloning repository...'); + let repoPath; + try { + repoPath = await customMgr.cloneRepo(url.trim()); + s.stop('Repository cloned'); + } catch (cloneError) { + s.error('Failed to clone repository'); + await prompts.log.error(` ${cloneError.message}`); + addMore = await prompts.confirm({ message: 'Try another URL?', default: false }); + continue; + } + + // Resolve each plugin to determine installable modules + s.start('Analyzing plugin structure...'); + const allResolved = []; + for (const plugin of plugins) { + try { + const resolved = await customMgr.resolvePlugin(repoPath, plugin.rawPlugin); + if (resolved.length > 0) { + allResolved.push(...resolved); + } else { + // No skills array or empty - use plugin metadata as-is (legacy) + allResolved.push({ + code: plugin.code, + name: plugin.displayName || plugin.name, + version: plugin.version, + description: plugin.description, + strategy: 0, + pluginName: plugin.name, + skillPaths: [], + }); + } + } catch (resolveError) { + await prompts.log.warn(` Could not resolve ${plugin.name}: ${resolveError.message}`); + } + } + s.stop(`Found ${allResolved.length} installable module${allResolved.length === 1 ? '' : 's'}`); + + if (allResolved.length === 0) { + await prompts.log.warn('No installable modules found in this repository.'); + addMore = await prompts.confirm({ message: 'Try another URL?', default: false }); + continue; + } + + // Build multiselect choices + // Already-installed modules are pre-checked (update). New modules are unchecked (opt-in). + // Unchecking an installed module means "skip update" - removal is handled elsewhere. + const choices = allResolved.map((mod) => { + const versionStr = mod.version ? ` v${mod.version}` : ''; + const skillCount = mod.skillPaths ? mod.skillPaths.length : 0; + const skillStr = skillCount > 0 ? ` (${skillCount} skill${skillCount === 1 ? '' : 's'})` : ''; + const alreadyInstalled = installedModuleIds.has(mod.code); + const hint = alreadyInstalled ? 'update' : undefined; + + return { + name: `${mod.name}${versionStr}${skillStr}`, + value: mod.code, + hint, + checked: alreadyInstalled, + }; + }); + + // Show descriptions before the multiselect + for (const mod of allResolved) { + const versionStr = mod.version ? ` v${mod.version}` : ''; + await prompts.log.info(` ${mod.name}${versionStr}\n ${mod.description}`); + } + + const selected = await prompts.multiselect({ + message: 'Select modules to install:', + choices, + required: false, + }); + + if (selected && selected.length > 0) { + for (const code of selected) { + selectedModules.push(code); + } } addMore = await prompts.confirm({ - message: 'Add another custom module?', + message: 'Add another custom module URL?', default: false, }); }