@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.claude-plugin/skill-manifest.json +1 -1
- package/.plugin/plugin.json +1 -1
- package/bin/commands/install.js +26 -5
- package/bin/commands/install.test.js +77 -0
- package/bin/lib/mcp-config.js +48 -3
- package/bin/lib/mcp-config.test.js +89 -0
- package/package.json +1 -1
- package/skills/pbi-connect/SKILL.md +1 -1
- package/skills/project-kickoff/SKILL.md +1 -1
|
@@ -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.
|
|
9
|
+
"version": "3.0.1",
|
|
10
10
|
"repository": "https://github.com/luquimbo/bi-superpowers"
|
|
11
11
|
},
|
|
12
12
|
"plugins": [
|
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.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"
|
package/bin/commands/install.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
414
|
-
|
|
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
|
+
});
|
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,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
|
|
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\\.(${
|
|
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,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.
|
|
4
|
+
version: "3.0.1"
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
<!-- Generated by BI Agent Superpowers. Edit src/content/skills/pbi-connect.md instead. -->
|