@luquimbo/bi-superpowers 3.0.0 → 3.0.1

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.
@@ -6,7 +6,7 @@
6
6
  },
7
7
  "metadata": {
8
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",
9
+ "version": "3.0.1",
10
10
  "repository": "https://github.com/luquimbo/bi-superpowers"
11
11
  },
12
12
  "plugins": [
@@ -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.0.1",
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.0.1",
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.0.1",
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"
@@ -400,7 +400,7 @@ async function installCommand(args, config) {
400
400
  mcpResults.push({ agent: agent.name, configPath, success: true });
401
401
  }
402
402
  } catch (err) {
403
- console.log(chalk.yellow(` ${agent.name}: ${err.message}`));
403
+ console.log(chalk.red(` ${agent.name}: ${err.message}`));
404
404
  mcpResults.push({ agent: agent.name, success: false, error: err.message });
405
405
  }
406
406
  }
@@ -408,11 +408,25 @@ async function installCommand(args, config) {
408
408
  // Resumen
409
409
  const totalAgents = agentResults.length + (universalAgents.length > 0 ? 1 : 0);
410
410
  const mcpSuccess = mcpResults.filter((r) => r.success).length;
411
+ const mcpFailures = mcpResults.filter((r) => !r.success);
412
+ const hasFailures = mcpFailures.length > 0;
413
+
414
+ const successMsg = `Instalados ${skillDirs.length} skills + 2 MCPs para ${totalAgents} agentes`;
415
+ const failureMsg = `Instalados ${skillDirs.length} skills. MCPs: ${mcpSuccess}/${mcpResults.length} agentes ✓, ${mcpFailures.length} con errores.`;
416
+ const headerLine = hasFailures ? chalk.yellow.bold(failureMsg) : chalk.green.bold(successMsg);
417
+
418
+ const failureDetail = hasFailures
419
+ ? '\n' +
420
+ chalk.red('Agentes con errores en MCP:') +
421
+ '\n' +
422
+ mcpFailures.map((r) => chalk.red(` ✗ ${r.agent}: ${r.error}`)).join('\n') +
423
+ '\n'
424
+ : '';
425
+
411
426
  console.log(
412
427
  boxen(
413
- chalk.green.bold(
414
- `Instalados ${skillDirs.length} skills + 2 MCPs para ${totalAgents} agentes`
415
- ) +
428
+ headerLine +
429
+ failureDetail +
416
430
  '\n\n' +
417
431
  chalk.gray(`MCPs configurados en ${mcpSuccess}/${mcpResults.length} agentes.`) +
418
432
  '\n' +
@@ -427,10 +441,17 @@ async function installCommand(args, config) {
427
441
  padding: 1,
428
442
  margin: { top: 1 },
429
443
  borderStyle: 'round',
430
- borderColor: 'green',
444
+ borderColor: hasFailures ? 'yellow' : 'green',
431
445
  }
432
446
  )
433
447
  );
448
+
449
+ if (hasFailures) {
450
+ // Non-zero exit so CI/scripts know something went wrong, but skills
451
+ // still got installed — we use exit code 2 to distinguish from total
452
+ // failure (exit 1).
453
+ process.exitCode = 2;
454
+ }
434
455
  }
435
456
 
436
457
  // 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
+ });
@@ -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,9 +83,31 @@ 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
+ );
104
+ }
105
+
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, '\\$&');
71
111
  }
72
112
 
73
113
  // ============================================
@@ -125,6 +165,7 @@ function writeCopilotConfig(packageDir) {
125
165
  // appending the fresh ones.
126
166
  function writeCodexConfig(packageDir) {
127
167
  const configPath = path.join(os.homedir(), '.codex', 'config.toml');
168
+ assertNotSymlink(configPath);
128
169
  const launcher = getLauncherAbsolutePath(packageDir);
129
170
 
130
171
  let existing = '';
@@ -134,9 +175,11 @@ function writeCodexConfig(packageDir) {
134
175
 
135
176
  // Remove any previous bi-superpowers MCP sections so re-running install
136
177
  // doesn't duplicate them. Strips each section from its header to the
137
- // next [ header or EOF.
178
+ // next [ header or EOF. Server names are escaped for regex safety even
179
+ // though current values are literal — defensive against future renames.
180
+ const namePattern = [MODELING_SERVER_NAME, LEARN_SERVER_NAME].map(escapeRegex).join('|');
138
181
  const stripPattern = new RegExp(
139
- `\\n?\\[mcp_servers\\.(${MODELING_SERVER_NAME}|${LEARN_SERVER_NAME})\\][\\s\\S]*?(?=\\n\\[|$)`,
182
+ `\\n?\\[mcp_servers\\.(${namePattern})\\][\\s\\S]*?(?=\\n\\[|$)`,
140
183
  'g'
141
184
  );
142
185
  existing = existing.replace(stripPattern, '');
@@ -238,5 +281,7 @@ module.exports = {
238
281
  readJsonSafe,
239
282
  writeJson,
240
283
  tomlEscape,
284
+ escapeRegex,
285
+ assertNotSymlink,
241
286
  getLauncherAbsolutePath,
242
287
  };
@@ -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,44 @@ 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
+ test('writeJson refuses to overwrite symlinked target', () => {
208
+ const realFile = path.join(tempHome, 'real-claude.json');
209
+ const linkFile = path.join(tempHome, '.claude.json');
210
+ fs.writeFileSync(realFile, '{"mcpServers":{}}');
211
+ fs.symlinkSync(realFile, linkFile);
212
+
213
+ assert.throws(() => MCP_WRITERS['claude-code'](fakePkgDir), /symbolic link/);
214
+
215
+ // The real file content must not have been touched.
216
+ const content = JSON.parse(fs.readFileSync(realFile, 'utf8'));
217
+ assert.deepStrictEqual(content, { mcpServers: {} });
218
+ });
130
219
  });
131
220
 
132
221
  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.0.1",
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.0.1"
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.0.1"
5
5
  ---
6
6
 
7
7
  <!-- Generated by BI Agent Superpowers. Edit src/content/skills/project-kickoff.md instead. -->