@luquimbo/bi-superpowers 3.0.0 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +1 -1
- package/.claude-plugin/skill-manifest.json +1 -1
- package/.plugin/plugin.json +1 -1
- package/README.md +19 -0
- package/bin/commands/install.js +64 -22
- package/bin/commands/install.test.js +77 -0
- package/bin/lib/generators/claude-plugin.js +10 -5
- package/bin/lib/mcp-config.js +153 -87
- package/bin/lib/mcp-config.test.js +126 -0
- package/package.json +1 -1
- package/skills/pbi-connect/SKILL.md +1 -1
- package/skills/project-kickoff/SKILL.md +1 -1
|
@@ -5,14 +5,14 @@
|
|
|
5
5
|
"url": "https://github.com/luquimbo"
|
|
6
6
|
},
|
|
7
7
|
"metadata": {
|
|
8
|
-
"description": "AI-powered skills for Power BI
|
|
9
|
-
"version": "3.
|
|
8
|
+
"description": "AI-powered skills for Power BI Desktop development. Works with Claude Code, GitHub Copilot, Codex, Gemini CLI, and Kilo Code.",
|
|
9
|
+
"version": "3.1.0",
|
|
10
10
|
"repository": "https://github.com/luquimbo/bi-superpowers"
|
|
11
11
|
},
|
|
12
12
|
"plugins": [
|
|
13
13
|
{
|
|
14
14
|
"name": "bi-superpowers",
|
|
15
|
-
"description": "
|
|
15
|
+
"description": "2 AI skills + Power BI Modeling and Microsoft Learn MCP servers for local Power BI Desktop workflows across 5 AI agents.",
|
|
16
16
|
"source": "./",
|
|
17
17
|
"strict": false,
|
|
18
18
|
"skills": [
|
package/.plugin/plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"spec": "open-plugin-spec@1",
|
|
3
3
|
"name": "bi-superpowers",
|
|
4
|
-
"version": "3.
|
|
4
|
+
"version": "3.1.0",
|
|
5
5
|
"description": "Claude Code plugin for Power BI, Microsoft Fabric, and semantic model workflows powered by the official Microsoft MCP servers.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Lucas Sanchez"
|
package/README.md
CHANGED
|
@@ -89,6 +89,25 @@ super install --all --yes
|
|
|
89
89
|
|
|
90
90
|
---
|
|
91
91
|
|
|
92
|
+
## Alternativas para instalar los MCPs
|
|
93
|
+
|
|
94
|
+
`super install` escribe automáticamente los 2 MCPs en el config file de cada agente. Pero hay otras formas de instalar los mismos MCPs — cualquiera de estas funciona igual de bien:
|
|
95
|
+
|
|
96
|
+
### Para Power BI Modeling MCP (local)
|
|
97
|
+
|
|
98
|
+
- **Extensión de VS Code** — Microsoft publica "Power BI Modeling MCP" en el VS Code Marketplace. Si la instalás, VS Code + Copilot lo descubren automáticamente sin tocar archivos de config.
|
|
99
|
+
- **Extensión de Cursor** — misma extensión, Cursor la detecta.
|
|
100
|
+
- **Manual** — descargá el binario `.exe` y configurá `BI_SUPERPOWERS_POWERBI_MODELING_MCP_PATH` apuntando al ejecutable.
|
|
101
|
+
|
|
102
|
+
### Para Microsoft Learn MCP (HTTP)
|
|
103
|
+
|
|
104
|
+
- **MCP marketplace oficial de Anthropic** — en Claude Code se puede hacer `/plugin marketplace add anthropic/mcp-registry` y después `/plugin install microsoft-learn` (según disponibilidad).
|
|
105
|
+
- **Agregarlo a mano** en el config file del agente con la URL `https://learn.microsoft.com/api/mcp`.
|
|
106
|
+
|
|
107
|
+
Si ya instalaste los MCPs por alguno de estos caminos, no necesitás correr `super install` — o, si lo corrés, los configs se mergean sin conflicto (cada agente tiene merge por clave, no por overwrite).
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
92
111
|
## Requisitos
|
|
93
112
|
|
|
94
113
|
- **Node.js ≥ 18**
|
package/bin/commands/install.js
CHANGED
|
@@ -276,6 +276,44 @@ function performInstall(skillsSourceDir, skillDirs, selectedAgents, baseDir) {
|
|
|
276
276
|
return { agentResults, copyFallbacks };
|
|
277
277
|
}
|
|
278
278
|
|
|
279
|
+
/**
|
|
280
|
+
* Configura los 2 MCP servers (powerbi-modeling + microsoft-learn)
|
|
281
|
+
* para cada agente seleccionado escribiendo el config file en el path
|
|
282
|
+
* y formato que cada agente espera.
|
|
283
|
+
*
|
|
284
|
+
* Los errores por agente no interrumpen el flujo: se recolectan en los
|
|
285
|
+
* resultados para mostrarlos al final y que el caller decida qué hacer
|
|
286
|
+
* con el exit code.
|
|
287
|
+
*
|
|
288
|
+
* @param {string[]} selectedAgents - IDs de agentes seleccionados
|
|
289
|
+
* @param {string} packageDir - Absolute path al paquete instalado
|
|
290
|
+
* @param {string} baseDir - Home directory del usuario (para display)
|
|
291
|
+
* @param {Object} chalk - chalk instance para colorear output
|
|
292
|
+
* @returns {Array<{agent: string, success: boolean, configPath?: string, error?: string}>}
|
|
293
|
+
*/
|
|
294
|
+
function configureMcpsForAgents(selectedAgents, packageDir, baseDir, chalk) {
|
|
295
|
+
console.log(
|
|
296
|
+
chalk.cyan('\n Configurando MCP servers (Power BI Modeling + Microsoft Learn)...\n')
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
const results = [];
|
|
300
|
+
for (const agentId of selectedAgents) {
|
|
301
|
+
const agent = AGENTS[agentId];
|
|
302
|
+
try {
|
|
303
|
+
const configPath = writeMcpConfigForAgent(agentId, packageDir);
|
|
304
|
+
if (configPath) {
|
|
305
|
+
const relPath = configPath.replace(baseDir, '~');
|
|
306
|
+
console.log(chalk.green(` ✓ ${relPath} — ${agent.name}`));
|
|
307
|
+
results.push({ agent: agent.name, configPath, success: true });
|
|
308
|
+
}
|
|
309
|
+
} catch (err) {
|
|
310
|
+
console.log(chalk.red(` ✗ ${agent.name}: ${err.message}`));
|
|
311
|
+
results.push({ agent: agent.name, success: false, error: err.message });
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return results;
|
|
315
|
+
}
|
|
316
|
+
|
|
279
317
|
/**
|
|
280
318
|
* Handler principal del comando install.
|
|
281
319
|
* @param {string[]} args - Argumentos CLI
|
|
@@ -386,33 +424,30 @@ async function installCommand(args, config) {
|
|
|
386
424
|
}
|
|
387
425
|
|
|
388
426
|
// Configurar MCPs para cada agente seleccionado
|
|
389
|
-
|
|
390
|
-
chalk.cyan('\n Configurando MCP servers (Power BI Modeling + Microsoft Learn)...\n')
|
|
391
|
-
);
|
|
392
|
-
const mcpResults = [];
|
|
393
|
-
for (const agentId of selectedAgents) {
|
|
394
|
-
const agent = AGENTS[agentId];
|
|
395
|
-
try {
|
|
396
|
-
const configPath = writeMcpConfigForAgent(agentId, packageDir);
|
|
397
|
-
if (configPath) {
|
|
398
|
-
const relPath = configPath.replace(baseDir, '~');
|
|
399
|
-
console.log(chalk.green(` ✓ ${relPath} — ${agent.name}`));
|
|
400
|
-
mcpResults.push({ agent: agent.name, configPath, success: true });
|
|
401
|
-
}
|
|
402
|
-
} catch (err) {
|
|
403
|
-
console.log(chalk.yellow(` ⚠ ${agent.name}: ${err.message}`));
|
|
404
|
-
mcpResults.push({ agent: agent.name, success: false, error: err.message });
|
|
405
|
-
}
|
|
406
|
-
}
|
|
427
|
+
const mcpResults = configureMcpsForAgents(selectedAgents, packageDir, baseDir, chalk);
|
|
407
428
|
|
|
408
429
|
// Resumen
|
|
409
430
|
const totalAgents = agentResults.length + (universalAgents.length > 0 ? 1 : 0);
|
|
410
431
|
const mcpSuccess = mcpResults.filter((r) => r.success).length;
|
|
432
|
+
const mcpFailures = mcpResults.filter((r) => !r.success);
|
|
433
|
+
const hasFailures = mcpFailures.length > 0;
|
|
434
|
+
|
|
435
|
+
const successMsg = `Instalados ${skillDirs.length} skills + 2 MCPs para ${totalAgents} agentes`;
|
|
436
|
+
const failureMsg = `Instalados ${skillDirs.length} skills. MCPs: ${mcpSuccess}/${mcpResults.length} agentes ✓, ${mcpFailures.length} con errores.`;
|
|
437
|
+
const headerLine = hasFailures ? chalk.yellow.bold(failureMsg) : chalk.green.bold(successMsg);
|
|
438
|
+
|
|
439
|
+
const failureDetail = hasFailures
|
|
440
|
+
? '\n' +
|
|
441
|
+
chalk.red('Agentes con errores en MCP:') +
|
|
442
|
+
'\n' +
|
|
443
|
+
mcpFailures.map((r) => chalk.red(` ✗ ${r.agent}: ${r.error}`)).join('\n') +
|
|
444
|
+
'\n'
|
|
445
|
+
: '';
|
|
446
|
+
|
|
411
447
|
console.log(
|
|
412
448
|
boxen(
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
) +
|
|
449
|
+
headerLine +
|
|
450
|
+
failureDetail +
|
|
416
451
|
'\n\n' +
|
|
417
452
|
chalk.gray(`MCPs configurados en ${mcpSuccess}/${mcpResults.length} agentes.`) +
|
|
418
453
|
'\n' +
|
|
@@ -427,10 +462,17 @@ async function installCommand(args, config) {
|
|
|
427
462
|
padding: 1,
|
|
428
463
|
margin: { top: 1 },
|
|
429
464
|
borderStyle: 'round',
|
|
430
|
-
borderColor: 'green',
|
|
465
|
+
borderColor: hasFailures ? 'yellow' : 'green',
|
|
431
466
|
}
|
|
432
467
|
)
|
|
433
468
|
);
|
|
469
|
+
|
|
470
|
+
if (hasFailures) {
|
|
471
|
+
// Non-zero exit so CI/scripts know something went wrong, but skills
|
|
472
|
+
// still got installed — we use exit code 2 to distinguish from total
|
|
473
|
+
// failure (exit 1).
|
|
474
|
+
process.exitCode = 2;
|
|
475
|
+
}
|
|
434
476
|
}
|
|
435
477
|
|
|
436
478
|
// Exports internos para testing
|
|
@@ -210,3 +210,80 @@ describe('install command - module exports', () => {
|
|
|
210
210
|
assert.strictEqual(typeof installCommand.formatFsError, 'function');
|
|
211
211
|
});
|
|
212
212
|
});
|
|
213
|
+
|
|
214
|
+
describe('install command - integration: --all --yes', () => {
|
|
215
|
+
let tempHome;
|
|
216
|
+
let tempPkg;
|
|
217
|
+
let origHome;
|
|
218
|
+
|
|
219
|
+
beforeEach(() => {
|
|
220
|
+
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'bi-install-int-home-'));
|
|
221
|
+
tempPkg = fs.mkdtempSync(path.join(os.tmpdir(), 'bi-install-int-pkg-'));
|
|
222
|
+
|
|
223
|
+
// Minimal fake package layout: skills/<name>/SKILL.md + launcher file
|
|
224
|
+
const skillsDir = path.join(tempPkg, 'skills');
|
|
225
|
+
for (const skillName of ['project-kickoff', 'pbi-connect']) {
|
|
226
|
+
const dir = path.join(skillsDir, skillName);
|
|
227
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
228
|
+
fs.writeFileSync(
|
|
229
|
+
path.join(dir, 'SKILL.md'),
|
|
230
|
+
`---\nname: ${skillName}\ndescription: fake skill for test\n---\n# ${skillName}\n`
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
fs.mkdirSync(path.join(tempPkg, 'bin', 'mcp'), { recursive: true });
|
|
234
|
+
fs.writeFileSync(
|
|
235
|
+
path.join(tempPkg, 'bin', 'mcp', 'powerbi-modeling-launcher.js'),
|
|
236
|
+
'// fake launcher\n'
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
origHome = os.homedir;
|
|
240
|
+
os.homedir = () => tempHome;
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
afterEach(() => {
|
|
244
|
+
os.homedir = origHome;
|
|
245
|
+
fs.rmSync(tempHome, { recursive: true, force: true });
|
|
246
|
+
fs.rmSync(tempPkg, { recursive: true, force: true });
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test('installs skills and writes MCP config for all 5 agents', async () => {
|
|
250
|
+
const origExitCode = process.exitCode;
|
|
251
|
+
try {
|
|
252
|
+
await installCommand(['--all', '--yes'], {
|
|
253
|
+
packageDir: tempPkg,
|
|
254
|
+
version: '9.9.9-test',
|
|
255
|
+
});
|
|
256
|
+
} finally {
|
|
257
|
+
// Reset exit code so subsequent tests aren't affected
|
|
258
|
+
process.exitCode = origExitCode;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Skills installed at universal path
|
|
262
|
+
assert.ok(
|
|
263
|
+
fs.existsSync(path.join(tempHome, '.agents', 'skills', 'project-kickoff', 'SKILL.md'))
|
|
264
|
+
);
|
|
265
|
+
assert.ok(fs.existsSync(path.join(tempHome, '.agents', 'skills', 'pbi-connect', 'SKILL.md')));
|
|
266
|
+
|
|
267
|
+
// MCP configs written for all 5 agents
|
|
268
|
+
const expectedMcpFiles = [
|
|
269
|
+
path.join(tempHome, '.claude.json'),
|
|
270
|
+
path.join(tempHome, '.copilot', 'mcp-config.json'),
|
|
271
|
+
path.join(tempHome, '.codex', 'config.toml'),
|
|
272
|
+
path.join(tempHome, '.gemini', 'settings.json'),
|
|
273
|
+
path.join(tempHome, '.kilocode', 'mcp_settings.json'),
|
|
274
|
+
];
|
|
275
|
+
for (const filePath of expectedMcpFiles) {
|
|
276
|
+
assert.ok(fs.existsSync(filePath), `expected MCP config at ${filePath}`);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Claude Code config has both servers under mcpServers
|
|
280
|
+
const claudeConfig = JSON.parse(fs.readFileSync(path.join(tempHome, '.claude.json'), 'utf8'));
|
|
281
|
+
assert.ok(claudeConfig.mcpServers['powerbi-modeling']);
|
|
282
|
+
assert.ok(claudeConfig.mcpServers['microsoft-learn']);
|
|
283
|
+
|
|
284
|
+
// Codex TOML has both sections
|
|
285
|
+
const codexToml = fs.readFileSync(path.join(tempHome, '.codex', 'config.toml'), 'utf8');
|
|
286
|
+
assert.ok(codexToml.includes('[mcp_servers.powerbi-modeling]'));
|
|
287
|
+
assert.ok(codexToml.includes('[mcp_servers.microsoft-learn]'));
|
|
288
|
+
});
|
|
289
|
+
});
|
|
@@ -193,7 +193,14 @@ async function generate(targetDir, skills, options = {}) {
|
|
|
193
193
|
JSON.stringify(pluginManifest, null, 2) + '\n'
|
|
194
194
|
);
|
|
195
195
|
|
|
196
|
-
// Generate marketplace.json with all skills listed and synced version
|
|
196
|
+
// Generate marketplace.json with all skills listed and synced version.
|
|
197
|
+
// The descriptions are derived from the actual skill count so they never
|
|
198
|
+
// drift out of sync with the real plugin contents.
|
|
199
|
+
const skillCount = skills.length;
|
|
200
|
+
const skillsPlural = skillCount === 1 ? 'skill' : 'skills';
|
|
201
|
+
const metadataDescription = `AI-powered ${skillsPlural} for Power BI Desktop development. Works with Claude Code, GitHub Copilot, Codex, Gemini CLI, and Kilo Code.`;
|
|
202
|
+
const pluginDescription = `${skillCount} AI ${skillsPlural} + Power BI Modeling and Microsoft Learn MCP servers for local Power BI Desktop workflows across 5 AI agents.`;
|
|
203
|
+
|
|
197
204
|
const marketplaceManifest = {
|
|
198
205
|
name: 'bi-superpowers',
|
|
199
206
|
owner: {
|
|
@@ -201,16 +208,14 @@ async function generate(targetDir, skills, options = {}) {
|
|
|
201
208
|
url: 'https://github.com/luquimbo',
|
|
202
209
|
},
|
|
203
210
|
metadata: {
|
|
204
|
-
description:
|
|
205
|
-
'AI-powered skills for Power BI, Microsoft Fabric, and Excel development. 24 skills covering DAX, Power Query, data modeling, report design, governance, and more.',
|
|
211
|
+
description: metadataDescription,
|
|
206
212
|
version,
|
|
207
213
|
repository: 'https://github.com/luquimbo/bi-superpowers',
|
|
208
214
|
},
|
|
209
215
|
plugins: [
|
|
210
216
|
{
|
|
211
217
|
name: 'bi-superpowers',
|
|
212
|
-
description:
|
|
213
|
-
'24 AI skills for Power BI, Fabric & Excel — DAX, Power Query, data modeling, star schema design, report design, governance, deployment, and more.',
|
|
218
|
+
description: pluginDescription,
|
|
214
219
|
source: './',
|
|
215
220
|
strict: false,
|
|
216
221
|
skills: skills.map((skill) => `./skills/${skill.name}`).sort(),
|
package/bin/lib/mcp-config.js
CHANGED
|
@@ -52,10 +52,28 @@ function readJsonSafe(filePath) {
|
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Reject symlink attacks before writing to a file. We use lstatSync so
|
|
57
|
+
* we detect the link itself (not what it points to). If a user home has
|
|
58
|
+
* `~/.claude.json` pointing to `/etc/passwd`, we refuse to write.
|
|
59
|
+
* @throws {Error} if filePath exists and is a symbolic link
|
|
60
|
+
*/
|
|
61
|
+
function assertNotSymlink(filePath) {
|
|
62
|
+
if (!fs.existsSync(filePath)) return;
|
|
63
|
+
if (fs.lstatSync(filePath).isSymbolicLink()) {
|
|
64
|
+
throw new Error(
|
|
65
|
+
`Refusing to write MCP config: ${filePath} is a symbolic link. ` +
|
|
66
|
+
'Remove the symlink and re-run the install.'
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
55
71
|
/**
|
|
56
72
|
* Write a JSON file, creating parent dirs if needed.
|
|
73
|
+
* Refuses to overwrite symbolic links for safety.
|
|
57
74
|
*/
|
|
58
75
|
function writeJson(filePath, data) {
|
|
76
|
+
assertNotSymlink(filePath);
|
|
59
77
|
const dir = path.dirname(filePath);
|
|
60
78
|
if (!fs.existsSync(dir)) {
|
|
61
79
|
fs.mkdirSync(dir, { recursive: true });
|
|
@@ -65,56 +83,141 @@ function writeJson(filePath, data) {
|
|
|
65
83
|
|
|
66
84
|
/**
|
|
67
85
|
* Escape a string for use inside TOML double-quoted strings.
|
|
86
|
+
* Covers backslash, quote, and the control characters that TOML spec
|
|
87
|
+
* requires escaping in basic strings: \b \t \n \f \r
|
|
88
|
+
*
|
|
89
|
+
* Note: `\b` in a JS regex is word-boundary, NOT backspace (0x08).
|
|
90
|
+
* We use \x08 explicitly to match the backspace character itself.
|
|
68
91
|
*/
|
|
69
92
|
function tomlEscape(str) {
|
|
70
|
-
return
|
|
93
|
+
return (
|
|
94
|
+
String(str)
|
|
95
|
+
.replace(/\\/g, '\\\\')
|
|
96
|
+
.replace(/"/g, '\\"')
|
|
97
|
+
// eslint-disable-next-line no-control-regex
|
|
98
|
+
.replace(/\x08/g, '\\b')
|
|
99
|
+
.replace(/\t/g, '\\t')
|
|
100
|
+
.replace(/\n/g, '\\n')
|
|
101
|
+
.replace(/\f/g, '\\f')
|
|
102
|
+
.replace(/\r/g, '\\r')
|
|
103
|
+
);
|
|
71
104
|
}
|
|
72
105
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
function writeClaudeCodeConfig(packageDir) {
|
|
79
|
-
const configPath = path.join(os.homedir(), '.claude.json');
|
|
80
|
-
const existing = readJsonSafe(configPath) || {};
|
|
81
|
-
const mcpServers = { ...(existing.mcpServers || {}) };
|
|
82
|
-
|
|
83
|
-
mcpServers[MODELING_SERVER_NAME] = {
|
|
84
|
-
command: 'node',
|
|
85
|
-
args: [getLauncherAbsolutePath(packageDir)],
|
|
86
|
-
};
|
|
87
|
-
mcpServers[LEARN_SERVER_NAME] = {
|
|
88
|
-
type: 'http',
|
|
89
|
-
url: MICROSOFT_LEARN_URL,
|
|
90
|
-
};
|
|
91
|
-
|
|
92
|
-
writeJson(configPath, { ...existing, mcpServers });
|
|
93
|
-
return configPath;
|
|
106
|
+
/**
|
|
107
|
+
* Escape a string for safe use inside a regex pattern (as a literal).
|
|
108
|
+
*/
|
|
109
|
+
function escapeRegex(str) {
|
|
110
|
+
return String(str).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
94
111
|
}
|
|
95
112
|
|
|
96
113
|
// ============================================
|
|
97
|
-
//
|
|
114
|
+
// JSON AGENT CONFIGURATIONS
|
|
98
115
|
// ============================================
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
116
|
+
// The 4 JSON-based agents (Claude Code, GitHub Copilot, Gemini CLI,
|
|
117
|
+
// Kilo Code) all follow the same pattern: read existing JSON, merge
|
|
118
|
+
// our 2 servers into a wrapper key, write back. Each agent differs
|
|
119
|
+
// only in:
|
|
120
|
+
// - The config file path
|
|
121
|
+
// - The wrapper key (`mcpServers` vs `servers`)
|
|
122
|
+
// - Whether the stdio entry needs an explicit `type` field
|
|
123
|
+
// - The HTTP URL field name (`url` vs `httpUrl`)
|
|
124
|
+
//
|
|
125
|
+
// We describe each agent in a single table and build the writer functions
|
|
126
|
+
// from it so adding a new JSON agent is a one-line change.
|
|
127
|
+
|
|
128
|
+
const JSON_AGENT_CONFIGS = {
|
|
129
|
+
'claude-code': {
|
|
130
|
+
// Claude Code user-scope MCP config lives in ~/.claude.json under
|
|
131
|
+
// mcpServers. stdio entries omit `type`, HTTP entries include it.
|
|
132
|
+
configPath: () => path.join(os.homedir(), '.claude.json'),
|
|
133
|
+
wrapperKey: 'mcpServers',
|
|
134
|
+
stdioIncludesType: false,
|
|
135
|
+
httpIncludesType: true,
|
|
136
|
+
httpField: 'url',
|
|
137
|
+
},
|
|
138
|
+
'github-copilot': {
|
|
139
|
+
// Copilot CLI uses `servers` (NOT mcpServers) and every server needs
|
|
140
|
+
// an explicit `type` field.
|
|
141
|
+
configPath: () => path.join(os.homedir(), '.copilot', 'mcp-config.json'),
|
|
142
|
+
wrapperKey: 'servers',
|
|
143
|
+
stdioIncludesType: true,
|
|
144
|
+
httpIncludesType: true,
|
|
145
|
+
httpField: 'url',
|
|
146
|
+
},
|
|
147
|
+
'gemini-cli': {
|
|
148
|
+
// Gemini uses `httpUrl` (NOT `url`) for HTTP transports and omits
|
|
149
|
+
// `type` — it's inferred from which key is present.
|
|
150
|
+
configPath: () => path.join(os.homedir(), '.gemini', 'settings.json'),
|
|
151
|
+
wrapperKey: 'mcpServers',
|
|
152
|
+
stdioIncludesType: false,
|
|
153
|
+
httpIncludesType: false,
|
|
154
|
+
httpField: 'httpUrl',
|
|
155
|
+
},
|
|
156
|
+
kilo: {
|
|
157
|
+
// Kilo uses the canonical `mcpServers` with `url` for HTTP.
|
|
158
|
+
configPath: () => path.join(os.homedir(), '.kilocode', 'mcp_settings.json'),
|
|
159
|
+
wrapperKey: 'mcpServers',
|
|
160
|
+
stdioIncludesType: false,
|
|
161
|
+
httpIncludesType: false,
|
|
162
|
+
httpField: 'url',
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Build the two-server JSON payload for a JSON-format agent.
|
|
168
|
+
* Each agent differs in whether it wants `type` fields and which key
|
|
169
|
+
* holds the HTTP URL (`url` vs `httpUrl`).
|
|
170
|
+
*
|
|
171
|
+
* @param {Object} agentConfig - Descriptor from JSON_AGENT_CONFIGS
|
|
172
|
+
* @param {string} packageDir - Absolute path to the installed package
|
|
173
|
+
* @returns {Object} Map of server name → server config
|
|
174
|
+
*/
|
|
175
|
+
function buildJsonServers(agentConfig, packageDir) {
|
|
176
|
+
// Modeling (stdio local launcher)
|
|
177
|
+
const modelingEntry = {
|
|
108
178
|
command: 'node',
|
|
109
179
|
args: [getLauncherAbsolutePath(packageDir)],
|
|
110
180
|
};
|
|
111
|
-
|
|
112
|
-
type
|
|
113
|
-
|
|
181
|
+
if (agentConfig.stdioIncludesType) {
|
|
182
|
+
// Put type first for readability
|
|
183
|
+
const { command, args } = modelingEntry;
|
|
184
|
+
Object.keys(modelingEntry).forEach((k) => delete modelingEntry[k]);
|
|
185
|
+
modelingEntry.type = 'stdio';
|
|
186
|
+
modelingEntry.command = command;
|
|
187
|
+
modelingEntry.args = args;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Microsoft Learn (HTTP)
|
|
191
|
+
const learnEntry = {};
|
|
192
|
+
if (agentConfig.httpIncludesType) {
|
|
193
|
+
learnEntry.type = 'http';
|
|
194
|
+
}
|
|
195
|
+
learnEntry[agentConfig.httpField] = MICROSOFT_LEARN_URL;
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
[MODELING_SERVER_NAME]: modelingEntry,
|
|
199
|
+
[LEARN_SERVER_NAME]: learnEntry,
|
|
114
200
|
};
|
|
201
|
+
}
|
|
115
202
|
|
|
116
|
-
|
|
117
|
-
|
|
203
|
+
/**
|
|
204
|
+
* Create a writer function for a JSON-format agent.
|
|
205
|
+
* The returned function reads the existing config (if any), merges in
|
|
206
|
+
* our 2 servers, and writes back, preserving all other fields.
|
|
207
|
+
*/
|
|
208
|
+
function makeJsonWriter(agentId) {
|
|
209
|
+
const agentConfig = JSON_AGENT_CONFIGS[agentId];
|
|
210
|
+
return function jsonWriter(packageDir) {
|
|
211
|
+
const configPath = agentConfig.configPath();
|
|
212
|
+
const existing = readJsonSafe(configPath) || {};
|
|
213
|
+
const wrapperKey = agentConfig.wrapperKey;
|
|
214
|
+
|
|
215
|
+
const newServers = buildJsonServers(agentConfig, packageDir);
|
|
216
|
+
const mergedServers = { ...(existing[wrapperKey] || {}), ...newServers };
|
|
217
|
+
|
|
218
|
+
writeJson(configPath, { ...existing, [wrapperKey]: mergedServers });
|
|
219
|
+
return configPath;
|
|
220
|
+
};
|
|
118
221
|
}
|
|
119
222
|
|
|
120
223
|
// ============================================
|
|
@@ -125,6 +228,7 @@ function writeCopilotConfig(packageDir) {
|
|
|
125
228
|
// appending the fresh ones.
|
|
126
229
|
function writeCodexConfig(packageDir) {
|
|
127
230
|
const configPath = path.join(os.homedir(), '.codex', 'config.toml');
|
|
231
|
+
assertNotSymlink(configPath);
|
|
128
232
|
const launcher = getLauncherAbsolutePath(packageDir);
|
|
129
233
|
|
|
130
234
|
let existing = '';
|
|
@@ -134,9 +238,11 @@ function writeCodexConfig(packageDir) {
|
|
|
134
238
|
|
|
135
239
|
// Remove any previous bi-superpowers MCP sections so re-running install
|
|
136
240
|
// doesn't duplicate them. Strips each section from its header to the
|
|
137
|
-
// next [ header or EOF.
|
|
241
|
+
// next [ header or EOF. Server names are escaped for regex safety even
|
|
242
|
+
// though current values are literal — defensive against future renames.
|
|
243
|
+
const namePattern = [MODELING_SERVER_NAME, LEARN_SERVER_NAME].map(escapeRegex).join('|');
|
|
138
244
|
const stripPattern = new RegExp(
|
|
139
|
-
`\\n?\\[mcp_servers\\.(${
|
|
245
|
+
`\\n?\\[mcp_servers\\.(${namePattern})\\][\\s\\S]*?(?=\\n\\[|$)`,
|
|
140
246
|
'g'
|
|
141
247
|
);
|
|
142
248
|
existing = existing.replace(stripPattern, '');
|
|
@@ -158,59 +264,17 @@ function writeCodexConfig(packageDir) {
|
|
|
158
264
|
return configPath;
|
|
159
265
|
}
|
|
160
266
|
|
|
161
|
-
// ============================================
|
|
162
|
-
// GEMINI CLI
|
|
163
|
-
// ============================================
|
|
164
|
-
// Writes ~/.gemini/settings.json. Gemini uses `mcpServers` and `httpUrl`
|
|
165
|
-
// (not `url`) for HTTP transports.
|
|
166
|
-
function writeGeminiConfig(packageDir) {
|
|
167
|
-
const configPath = path.join(os.homedir(), '.gemini', 'settings.json');
|
|
168
|
-
const existing = readJsonSafe(configPath) || {};
|
|
169
|
-
const mcpServers = { ...(existing.mcpServers || {}) };
|
|
170
|
-
|
|
171
|
-
mcpServers[MODELING_SERVER_NAME] = {
|
|
172
|
-
command: 'node',
|
|
173
|
-
args: [getLauncherAbsolutePath(packageDir)],
|
|
174
|
-
};
|
|
175
|
-
mcpServers[LEARN_SERVER_NAME] = {
|
|
176
|
-
httpUrl: MICROSOFT_LEARN_URL,
|
|
177
|
-
};
|
|
178
|
-
|
|
179
|
-
writeJson(configPath, { ...existing, mcpServers });
|
|
180
|
-
return configPath;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
// ============================================
|
|
184
|
-
// KILO CODE
|
|
185
|
-
// ============================================
|
|
186
|
-
// Writes ~/.kilocode/mcp_settings.json. Kilo uses `mcpServers` and `url`
|
|
187
|
-
// for HTTP transports.
|
|
188
|
-
function writeKiloConfig(packageDir) {
|
|
189
|
-
const configPath = path.join(os.homedir(), '.kilocode', 'mcp_settings.json');
|
|
190
|
-
const existing = readJsonSafe(configPath) || {};
|
|
191
|
-
const mcpServers = { ...(existing.mcpServers || {}) };
|
|
192
|
-
|
|
193
|
-
mcpServers[MODELING_SERVER_NAME] = {
|
|
194
|
-
command: 'node',
|
|
195
|
-
args: [getLauncherAbsolutePath(packageDir)],
|
|
196
|
-
};
|
|
197
|
-
mcpServers[LEARN_SERVER_NAME] = {
|
|
198
|
-
url: MICROSOFT_LEARN_URL,
|
|
199
|
-
};
|
|
200
|
-
|
|
201
|
-
writeJson(configPath, { ...existing, mcpServers });
|
|
202
|
-
return configPath;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
267
|
/**
|
|
206
268
|
* Registry mapping agent IDs to their MCP config writers.
|
|
269
|
+
* JSON agents are generated from JSON_AGENT_CONFIGS via makeJsonWriter;
|
|
270
|
+
* Codex is the only TOML-based agent and has its own writer.
|
|
207
271
|
*/
|
|
208
272
|
const MCP_WRITERS = {
|
|
209
|
-
'claude-code':
|
|
210
|
-
'github-copilot':
|
|
273
|
+
'claude-code': makeJsonWriter('claude-code'),
|
|
274
|
+
'github-copilot': makeJsonWriter('github-copilot'),
|
|
211
275
|
codex: writeCodexConfig,
|
|
212
|
-
'gemini-cli':
|
|
213
|
-
kilo:
|
|
276
|
+
'gemini-cli': makeJsonWriter('gemini-cli'),
|
|
277
|
+
kilo: makeJsonWriter('kilo'),
|
|
214
278
|
};
|
|
215
279
|
|
|
216
280
|
/**
|
|
@@ -238,5 +302,7 @@ module.exports = {
|
|
|
238
302
|
readJsonSafe,
|
|
239
303
|
writeJson,
|
|
240
304
|
tomlEscape,
|
|
305
|
+
escapeRegex,
|
|
306
|
+
assertNotSymlink,
|
|
241
307
|
getLauncherAbsolutePath,
|
|
242
308
|
};
|
|
@@ -14,6 +14,8 @@ const {
|
|
|
14
14
|
LEARN_SERVER_NAME,
|
|
15
15
|
MICROSOFT_LEARN_URL,
|
|
16
16
|
tomlEscape,
|
|
17
|
+
escapeRegex,
|
|
18
|
+
assertNotSymlink,
|
|
17
19
|
getLauncherAbsolutePath,
|
|
18
20
|
} = require('./mcp-config');
|
|
19
21
|
|
|
@@ -29,6 +31,55 @@ describe('mcp-config helpers', () => {
|
|
|
29
31
|
assert.strictEqual(tomlEscape('C:\\path\\to\\file'), 'C:\\\\path\\\\to\\\\file');
|
|
30
32
|
assert.strictEqual(tomlEscape('say "hello"'), 'say \\"hello\\"');
|
|
31
33
|
});
|
|
34
|
+
|
|
35
|
+
test('tomlEscape escapes TOML control characters', () => {
|
|
36
|
+
assert.strictEqual(tomlEscape('line1\nline2'), 'line1\\nline2');
|
|
37
|
+
assert.strictEqual(tomlEscape('tab\there'), 'tab\\there');
|
|
38
|
+
assert.strictEqual(tomlEscape('cr\rhere'), 'cr\\rhere');
|
|
39
|
+
// \x08 = backspace, \f = form feed
|
|
40
|
+
assert.strictEqual(tomlEscape('\x08\f'), '\\b\\f');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('escapeRegex escapes all regex metacharacters', () => {
|
|
44
|
+
assert.strictEqual(escapeRegex('plain'), 'plain');
|
|
45
|
+
assert.strictEqual(escapeRegex('a.b'), 'a\\.b');
|
|
46
|
+
assert.strictEqual(escapeRegex('a[b]c'), 'a\\[b\\]c');
|
|
47
|
+
assert.strictEqual(escapeRegex('a+b*c?'), 'a\\+b\\*c\\?');
|
|
48
|
+
assert.strictEqual(escapeRegex('(hello)'), '\\(hello\\)');
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('assertNotSymlink', () => {
|
|
53
|
+
let tempDir;
|
|
54
|
+
|
|
55
|
+
beforeEach(() => {
|
|
56
|
+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bi-mcp-symlink-'));
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
afterEach(() => {
|
|
60
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('allows non-existent paths', () => {
|
|
64
|
+
assert.doesNotThrow(() => {
|
|
65
|
+
assertNotSymlink(path.join(tempDir, 'nope.json'));
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('allows regular files', () => {
|
|
70
|
+
const file = path.join(tempDir, 'regular.json');
|
|
71
|
+
fs.writeFileSync(file, '{}');
|
|
72
|
+
assert.doesNotThrow(() => assertNotSymlink(file));
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('throws on symbolic links', () => {
|
|
76
|
+
const target = path.join(tempDir, 'target.json');
|
|
77
|
+
const link = path.join(tempDir, 'link.json');
|
|
78
|
+
fs.writeFileSync(target, '{}');
|
|
79
|
+
fs.symlinkSync(target, link);
|
|
80
|
+
|
|
81
|
+
assert.throws(() => assertNotSymlink(link), /is a symbolic link/);
|
|
82
|
+
});
|
|
32
83
|
});
|
|
33
84
|
|
|
34
85
|
describe('MCP_WRITERS registry', () => {
|
|
@@ -127,6 +178,81 @@ describe('MCP writers — JSON agents', () => {
|
|
|
127
178
|
assert.ok(config.mcpServers[MODELING_SERVER_NAME]);
|
|
128
179
|
assert.ok(config.mcpServers[LEARN_SERVER_NAME]);
|
|
129
180
|
});
|
|
181
|
+
|
|
182
|
+
// Re-install de-duplication for JSON writers — each should replace its
|
|
183
|
+
// own entries cleanly without duplicating them on repeated runs.
|
|
184
|
+
for (const agentId of ['claude-code', 'github-copilot', 'gemini-cli', 'kilo']) {
|
|
185
|
+
test(`${agentId} re-install replaces entries without duplicating`, () => {
|
|
186
|
+
// First install
|
|
187
|
+
MCP_WRITERS[agentId](fakePkgDir);
|
|
188
|
+
// Second install (same args)
|
|
189
|
+
const configPath = MCP_WRITERS[agentId](fakePkgDir);
|
|
190
|
+
|
|
191
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
192
|
+
const serversKey = agentId === 'github-copilot' ? 'servers' : 'mcpServers';
|
|
193
|
+
const servers = config[serversKey];
|
|
194
|
+
|
|
195
|
+
// Our 2 servers should each appear exactly once (via object keys)
|
|
196
|
+
assert.ok(servers[MODELING_SERVER_NAME], 'modeling server missing after re-install');
|
|
197
|
+
assert.ok(servers[LEARN_SERVER_NAME], 'learn server missing after re-install');
|
|
198
|
+
|
|
199
|
+
// And no stray duplicates of our keys
|
|
200
|
+
const ourKeys = Object.keys(servers).filter(
|
|
201
|
+
(k) => k === MODELING_SERVER_NAME || k === LEARN_SERVER_NAME
|
|
202
|
+
);
|
|
203
|
+
assert.strictEqual(ourKeys.length, 2, `expected 2 of our keys, found ${ourKeys.length}`);
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Cross-agent sanity check: verifies each JSON agent writes the HTTP
|
|
208
|
+
// URL under the correct key per its spec. Catches copy-paste errors
|
|
209
|
+
// where someone might accidentally use `url` for Gemini (which expects
|
|
210
|
+
// `httpUrl`) or vice versa.
|
|
211
|
+
test('each JSON agent uses the correct HTTP field name', () => {
|
|
212
|
+
const expectations = {
|
|
213
|
+
'claude-code': { key: 'url', wrapperKey: 'mcpServers' },
|
|
214
|
+
'github-copilot': { key: 'url', wrapperKey: 'servers' },
|
|
215
|
+
'gemini-cli': { key: 'httpUrl', wrapperKey: 'mcpServers' },
|
|
216
|
+
kilo: { key: 'url', wrapperKey: 'mcpServers' },
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
for (const [agentId, expected] of Object.entries(expectations)) {
|
|
220
|
+
// Reset home for each agent so configs don't leak between writes.
|
|
221
|
+
fs.rmSync(tempHome, { recursive: true, force: true });
|
|
222
|
+
fs.mkdirSync(tempHome, { recursive: true });
|
|
223
|
+
|
|
224
|
+
const configPath = MCP_WRITERS[agentId](fakePkgDir);
|
|
225
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
226
|
+
const learnEntry = config[expected.wrapperKey][LEARN_SERVER_NAME];
|
|
227
|
+
|
|
228
|
+
assert.strictEqual(
|
|
229
|
+
learnEntry[expected.key],
|
|
230
|
+
MICROSOFT_LEARN_URL,
|
|
231
|
+
`${agentId} should use "${expected.key}" for HTTP URL`
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
// And it should NOT use the wrong key (defensive check)
|
|
235
|
+
const wrongKey = expected.key === 'url' ? 'httpUrl' : 'url';
|
|
236
|
+
assert.strictEqual(
|
|
237
|
+
learnEntry[wrongKey],
|
|
238
|
+
undefined,
|
|
239
|
+
`${agentId} should NOT have "${wrongKey}"`
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test('writeJson refuses to overwrite symlinked target', () => {
|
|
245
|
+
const realFile = path.join(tempHome, 'real-claude.json');
|
|
246
|
+
const linkFile = path.join(tempHome, '.claude.json');
|
|
247
|
+
fs.writeFileSync(realFile, '{"mcpServers":{}}');
|
|
248
|
+
fs.symlinkSync(realFile, linkFile);
|
|
249
|
+
|
|
250
|
+
assert.throws(() => MCP_WRITERS['claude-code'](fakePkgDir), /symbolic link/);
|
|
251
|
+
|
|
252
|
+
// The real file content must not have been touched.
|
|
253
|
+
const content = JSON.parse(fs.readFileSync(realFile, 'utf8'));
|
|
254
|
+
assert.deepStrictEqual(content, { mcpServers: {} });
|
|
255
|
+
});
|
|
130
256
|
});
|
|
131
257
|
|
|
132
258
|
describe('codex writer — TOML', () => {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: "pbi-connect"
|
|
3
3
|
description: "Use when the user asks about Power BI MCP Connection Skill, especially phrases like \"connect Power BI\", \"modeling mcp\", \"Power BI Desktop\", \"conectar Power BI\", \"can't connect to Power BI\"."
|
|
4
|
-
version: "3.
|
|
4
|
+
version: "3.1.0"
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
<!-- Generated by BI Agent Superpowers. Edit src/content/skills/pbi-connect.md instead. -->
|