@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.
@@ -5,14 +5,14 @@
5
5
  "url": "https://github.com/luquimbo"
6
6
  },
7
7
  "metadata": {
8
- "description": "AI-powered skills for Power BI, Microsoft Fabric, and Excel development. 24 skills covering DAX, Power Query, data modeling, report design, governance, and more.",
9
- "version": "3.0.0",
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": "24 AI skills for Power BI, Fabric & Excel DAX, Power Query, data modeling, star schema design, report design, governance, deployment, and more.",
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": [
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "bi-superpowers",
3
3
  "description": "Claude Code plugin for Power BI, Microsoft Fabric, and semantic model workflows powered by the official Microsoft MCP servers.",
4
- "version": "3.0.0",
4
+ "version": "3.1.0",
5
5
  "author": {
6
6
  "name": "Lucas Sanchez"
7
7
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bi-superpowers",
3
- "version": "3.0.0",
3
+ "version": "3.1.0",
4
4
  "skillCount": 2,
5
5
  "skills": [
6
6
  {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "spec": "open-plugin-spec@1",
3
3
  "name": "bi-superpowers",
4
- "version": "3.0.0",
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**
@@ -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
- console.log(
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
- chalk.green.bold(
414
- `Instalados ${skillDirs.length} skills + 2 MCPs para ${totalAgents} agentes`
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(),
@@ -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 String(str).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
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
- // CLAUDE CODE
75
- // ============================================
76
- // Writes ~/.claude.json, adding to `mcpServers`. Preserves other user-level
77
- // config in that file (projects, settings, etc.).
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
- // GITHUB COPILOT CLI
114
+ // JSON AGENT CONFIGURATIONS
98
115
  // ============================================
99
- // Writes ~/.copilot/mcp-config.json. Copilot uses `servers` (NOT mcpServers)
100
- // and each server has an explicit `type` field.
101
- function writeCopilotConfig(packageDir) {
102
- const configPath = path.join(os.homedir(), '.copilot', 'mcp-config.json');
103
- const existing = readJsonSafe(configPath) || {};
104
- const servers = { ...(existing.servers || {}) };
105
-
106
- servers[MODELING_SERVER_NAME] = {
107
- type: 'stdio',
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
- servers[LEARN_SERVER_NAME] = {
112
- type: 'http',
113
- url: MICROSOFT_LEARN_URL,
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
- writeJson(configPath, { ...existing, servers });
117
- return configPath;
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\\.(${MODELING_SERVER_NAME}|${LEARN_SERVER_NAME})\\][\\s\\S]*?(?=\\n\\[|$)`,
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': writeClaudeCodeConfig,
210
- 'github-copilot': writeCopilotConfig,
273
+ 'claude-code': makeJsonWriter('claude-code'),
274
+ 'github-copilot': makeJsonWriter('github-copilot'),
211
275
  codex: writeCodexConfig,
212
- 'gemini-cli': writeGeminiConfig,
213
- kilo: writeKiloConfig,
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@luquimbo/bi-superpowers",
3
- "version": "3.0.0",
3
+ "version": "3.1.0",
4
4
  "description": "Plugin-first Claude Code toolkit for Power BI, Microsoft Fabric, and Excel workflows powered by official Microsoft MCP servers.",
5
5
  "main": "bin/cli.js",
6
6
  "bin": {
@@ -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.0.0"
4
+ version: "3.1.0"
5
5
  ---
6
6
 
7
7
  <!-- Generated by BI Agent Superpowers. Edit src/content/skills/pbi-connect.md instead. -->
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: "project-kickoff"
3
3
  description: "Project Kickoff Skill: Project analysis and planning."
4
- version: "3.0.0"
4
+ version: "3.1.0"
5
5
  ---
6
6
 
7
7
  <!-- Generated by BI Agent Superpowers. Edit src/content/skills/project-kickoff.md instead. -->