@luquimbo/bi-superpowers 2.0.1 → 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.
Files changed (76) hide show
  1. package/.claude-plugin/marketplace.json +2 -24
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.claude-plugin/skill-manifest.json +2 -178
  4. package/.mcp.json +0 -16
  5. package/.plugin/plugin.json +1 -1
  6. package/AGENTS.md +37 -55
  7. package/CHANGELOG.md +44 -0
  8. package/README.md +74 -191
  9. package/bin/cli.js +42 -43
  10. package/bin/commands/install.js +59 -8
  11. package/bin/commands/install.test.js +77 -0
  12. package/bin/lib/generators/claude-plugin.js +6 -31
  13. package/bin/lib/generators/claude-plugin.test.js +12 -11
  14. package/bin/lib/mcp-config.js +287 -0
  15. package/bin/lib/mcp-config.test.js +273 -0
  16. package/bin/lib/microsoft-mcp.js +6 -20
  17. package/bin/lib/microsoft-mcp.test.js +25 -21
  18. package/bin/postinstall.js +18 -23
  19. package/bin/utils/mcp-detect.js +4 -20
  20. package/bin/utils/mcp-detect.test.js +9 -33
  21. package/package.json +1 -1
  22. package/skills/pbi-connect/SKILL.md +1 -1
  23. package/skills/project-kickoff/SKILL.md +1 -1
  24. package/commands/contributions.md +0 -265
  25. package/commands/data-model-design.md +0 -468
  26. package/commands/dax-doctor.md +0 -248
  27. package/commands/fabric-scripts.md +0 -452
  28. package/commands/migration-assistant.md +0 -290
  29. package/commands/model-documenter.md +0 -242
  30. package/commands/report-layout.md +0 -296
  31. package/commands/rls-design.md +0 -533
  32. package/commands/theme-tweaker.md +0 -624
  33. package/skills/contributions/SKILL.md +0 -267
  34. package/skills/data-model-design/SKILL.md +0 -470
  35. package/skills/data-modeling/SKILL.md +0 -280
  36. package/skills/data-quality/SKILL.md +0 -664
  37. package/skills/dax/SKILL.md +0 -746
  38. package/skills/dax-doctor/SKILL.md +0 -250
  39. package/skills/dax-udf/SKILL.md +0 -489
  40. package/skills/deployment/SKILL.md +0 -320
  41. package/skills/excel-formulas/SKILL.md +0 -463
  42. package/skills/fabric-scripts/SKILL.md +0 -454
  43. package/skills/fast-standard/SKILL.md +0 -509
  44. package/skills/governance/SKILL.md +0 -258
  45. package/skills/migration-assistant/SKILL.md +0 -292
  46. package/skills/model-documenter/SKILL.md +0 -244
  47. package/skills/power-query/SKILL.md +0 -406
  48. package/skills/query-performance/SKILL.md +0 -480
  49. package/skills/report-design/SKILL.md +0 -207
  50. package/skills/report-layout/SKILL.md +0 -298
  51. package/skills/rls-design/SKILL.md +0 -535
  52. package/skills/semantic-model/SKILL.md +0 -237
  53. package/skills/testing-validation/SKILL.md +0 -643
  54. package/skills/theme-tweaker/SKILL.md +0 -626
  55. package/src/content/skills/contributions.md +0 -259
  56. package/src/content/skills/data-model-design.md +0 -462
  57. package/src/content/skills/data-modeling.md +0 -272
  58. package/src/content/skills/data-quality.md +0 -656
  59. package/src/content/skills/dax-doctor.md +0 -242
  60. package/src/content/skills/dax-udf.md +0 -481
  61. package/src/content/skills/dax.md +0 -738
  62. package/src/content/skills/deployment.md +0 -312
  63. package/src/content/skills/excel-formulas.md +0 -455
  64. package/src/content/skills/fabric-scripts.md +0 -446
  65. package/src/content/skills/fast-standard.md +0 -501
  66. package/src/content/skills/governance.md +0 -250
  67. package/src/content/skills/migration-assistant.md +0 -284
  68. package/src/content/skills/model-documenter.md +0 -236
  69. package/src/content/skills/power-query.md +0 -398
  70. package/src/content/skills/query-performance.md +0 -472
  71. package/src/content/skills/report-design.md +0 -199
  72. package/src/content/skills/report-layout.md +0 -290
  73. package/src/content/skills/rls-design.md +0 -527
  74. package/src/content/skills/semantic-model.md +0 -229
  75. package/src/content/skills/testing-validation.md +0 -635
  76. package/src/content/skills/theme-tweaker.md +0 -618
@@ -38,8 +38,6 @@ describe('claude-plugin generator', () => {
38
38
  const skills = [
39
39
  makeSkill('project-kickoff', 'Project Kickoff'),
40
40
  makeSkill('pbi-connect', 'PBI Connect'),
41
- makeSkill('dax', 'DAX'),
42
- makeSkill('excel-formulas', 'Excel Formulas'),
43
41
  ];
44
42
 
45
43
  await claudePlugin.generate(tempDir, skills, {
@@ -52,7 +50,7 @@ describe('claude-plugin generator', () => {
52
50
  const pluginManifestPath = path.join(tempDir, '.claude-plugin', 'plugin.json');
53
51
  const pluginMcpPath = path.join(tempDir, '.mcp.json');
54
52
  const commandPath = path.join(tempDir, 'commands', 'project-kickoff.md');
55
- const skillPath = path.join(tempDir, 'skills', 'dax', 'SKILL.md');
53
+ const skillPath = path.join(tempDir, 'skills', 'pbi-connect', 'SKILL.md');
56
54
 
57
55
  assert.ok(fs.existsSync(pluginManifestPath));
58
56
  assert.ok(fs.existsSync(pluginMcpPath));
@@ -67,21 +65,21 @@ describe('claude-plugin generator', () => {
67
65
  assert.strictEqual(pluginManifest.name, 'bi-superpowers');
68
66
  assert.strictEqual(pluginManifest.version, '9.9.9');
69
67
 
70
- assert.ok(pluginMcp['powerbi-remote']);
71
- assert.ok(pluginMcp['fabric-mcp-server']);
68
+ // Only 2 MCPs ship: local Power BI Modeling and Microsoft Learn
69
+ assert.strictEqual(Object.keys(pluginMcp).length, 2);
72
70
  assert.ok(pluginMcp['powerbi-modeling-mcp']);
73
- assert.strictEqual(pluginMcp['powerbi-remote'].type, 'http');
74
- assert.strictEqual(pluginMcp['fabric-mcp-server'].command, 'npx');
71
+ assert.ok(pluginMcp['microsoft-learn']);
75
72
  assert.strictEqual(pluginMcp['powerbi-modeling-mcp'].command, 'node');
76
73
  assert.ok(
77
74
  pluginMcp['powerbi-modeling-mcp'].args[0].includes(
78
75
  '${CLAUDE_PLUGIN_ROOT}/bin/mcp/powerbi-modeling-launcher.js'
79
76
  )
80
77
  );
78
+ assert.strictEqual(pluginMcp['microsoft-learn'].type, 'http');
79
+ assert.strictEqual(pluginMcp['microsoft-learn'].url, 'https://learn.microsoft.com/api/mcp');
81
80
 
82
- assert.ok(commandContent.includes('description: "Project analysis and planning"'));
83
81
  assert.ok(commandContent.includes('Generated by BI Agent Superpowers'));
84
- assert.ok(skillContent.includes('name: "dax"'));
82
+ assert.ok(skillContent.includes('name: "pbi-connect"'));
85
83
  assert.ok(skillContent.includes('version: "9.9.9"'));
86
84
  } finally {
87
85
  fs.rmSync(tempDir, { recursive: true, force: true });
@@ -92,7 +90,7 @@ describe('claude-plugin generator', () => {
92
90
  const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bi-superpowers-plugin-'));
93
91
 
94
92
  try {
95
- const skills = [makeSkill('dax', 'DAX')];
93
+ const skills = [makeSkill('pbi-connect', 'PBI Connect')];
96
94
 
97
95
  await claudePlugin.generate(tempDir, skills, {
98
96
  packageDir: '/tmp/bi-superpowers',
@@ -100,7 +98,10 @@ describe('claude-plugin generator', () => {
100
98
  libraryPrefix: '.bi-superpowers/library',
101
99
  });
102
100
 
103
- const skillContent = fs.readFileSync(path.join(tempDir, 'skills', 'dax', 'SKILL.md'), 'utf8');
101
+ const skillContent = fs.readFileSync(
102
+ path.join(tempDir, 'skills', 'pbi-connect', 'SKILL.md'),
103
+ 'utf8'
104
+ );
104
105
 
105
106
  assert.ok(skillContent.includes('.bi-superpowers/library/snippets/example.md'));
106
107
  } finally {
@@ -0,0 +1,287 @@
1
+ /**
2
+ * Multi-Agent MCP Configuration Writer
3
+ * =====================================
4
+ *
5
+ * Writes the bi-superpowers MCP config (2 servers: powerbi-modeling-mcp
6
+ * + microsoft-learn) into each supported agent's expected config location
7
+ * at the user level (home directory).
8
+ *
9
+ * Each agent has its own config format and path. This module normalizes
10
+ * the write operation across all 5 supported agents:
11
+ *
12
+ * | Agent | Path | Format |
13
+ * |-----------------|---------------------------------------|-------------------|
14
+ * | Claude Code | ~/.claude.json | JSON mcpServers |
15
+ * | GitHub Copilot | ~/.copilot/mcp-config.json | JSON servers |
16
+ * | Codex | ~/.codex/config.toml | TOML mcp_servers |
17
+ * | Gemini CLI | ~/.gemini/settings.json | JSON mcpServers |
18
+ * | Kilo Code | ~/.kilocode/mcp_settings.json | JSON mcpServers |
19
+ *
20
+ * The launcher path for the Power BI Modeling MCP is resolved to an
21
+ * absolute path at install time so it works in agents that don't
22
+ * support Claude Code's `${CLAUDE_PLUGIN_ROOT}` variable.
23
+ *
24
+ * @module lib/mcp-config
25
+ */
26
+
27
+ const fs = require('fs');
28
+ const path = require('path');
29
+ const os = require('os');
30
+
31
+ const MICROSOFT_LEARN_URL = 'https://learn.microsoft.com/api/mcp';
32
+ const MODELING_SERVER_NAME = 'powerbi-modeling';
33
+ const LEARN_SERVER_NAME = 'microsoft-learn';
34
+
35
+ /**
36
+ * Resolves the absolute path to the Power BI Modeling MCP launcher
37
+ * inside the installed npm package.
38
+ */
39
+ function getLauncherAbsolutePath(packageDir) {
40
+ return path.join(packageDir, 'bin', 'mcp', 'powerbi-modeling-launcher.js');
41
+ }
42
+
43
+ /**
44
+ * Safely read a JSON file. Returns null if missing or unparseable.
45
+ */
46
+ function readJsonSafe(filePath) {
47
+ if (!fs.existsSync(filePath)) return null;
48
+ try {
49
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
50
+ } catch (_) {
51
+ return null;
52
+ }
53
+ }
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
+
71
+ /**
72
+ * Write a JSON file, creating parent dirs if needed.
73
+ * Refuses to overwrite symbolic links for safety.
74
+ */
75
+ function writeJson(filePath, data) {
76
+ assertNotSymlink(filePath);
77
+ const dir = path.dirname(filePath);
78
+ if (!fs.existsSync(dir)) {
79
+ fs.mkdirSync(dir, { recursive: true });
80
+ }
81
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n');
82
+ }
83
+
84
+ /**
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.
91
+ */
92
+ function tomlEscape(str) {
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, '\\$&');
111
+ }
112
+
113
+ // ============================================
114
+ // CLAUDE CODE
115
+ // ============================================
116
+ // Writes ~/.claude.json, adding to `mcpServers`. Preserves other user-level
117
+ // config in that file (projects, settings, etc.).
118
+ function writeClaudeCodeConfig(packageDir) {
119
+ const configPath = path.join(os.homedir(), '.claude.json');
120
+ const existing = readJsonSafe(configPath) || {};
121
+ const mcpServers = { ...(existing.mcpServers || {}) };
122
+
123
+ mcpServers[MODELING_SERVER_NAME] = {
124
+ command: 'node',
125
+ args: [getLauncherAbsolutePath(packageDir)],
126
+ };
127
+ mcpServers[LEARN_SERVER_NAME] = {
128
+ type: 'http',
129
+ url: MICROSOFT_LEARN_URL,
130
+ };
131
+
132
+ writeJson(configPath, { ...existing, mcpServers });
133
+ return configPath;
134
+ }
135
+
136
+ // ============================================
137
+ // GITHUB COPILOT CLI
138
+ // ============================================
139
+ // Writes ~/.copilot/mcp-config.json. Copilot uses `servers` (NOT mcpServers)
140
+ // and each server has an explicit `type` field.
141
+ function writeCopilotConfig(packageDir) {
142
+ const configPath = path.join(os.homedir(), '.copilot', 'mcp-config.json');
143
+ const existing = readJsonSafe(configPath) || {};
144
+ const servers = { ...(existing.servers || {}) };
145
+
146
+ servers[MODELING_SERVER_NAME] = {
147
+ type: 'stdio',
148
+ command: 'node',
149
+ args: [getLauncherAbsolutePath(packageDir)],
150
+ };
151
+ servers[LEARN_SERVER_NAME] = {
152
+ type: 'http',
153
+ url: MICROSOFT_LEARN_URL,
154
+ };
155
+
156
+ writeJson(configPath, { ...existing, servers });
157
+ return configPath;
158
+ }
159
+
160
+ // ============================================
161
+ // CODEX (OpenAI) — TOML format
162
+ // ============================================
163
+ // Writes ~/.codex/config.toml, appending [mcp_servers.*] sections.
164
+ // Preserves existing content by removing only our own sections before
165
+ // appending the fresh ones.
166
+ function writeCodexConfig(packageDir) {
167
+ const configPath = path.join(os.homedir(), '.codex', 'config.toml');
168
+ assertNotSymlink(configPath);
169
+ const launcher = getLauncherAbsolutePath(packageDir);
170
+
171
+ let existing = '';
172
+ if (fs.existsSync(configPath)) {
173
+ existing = fs.readFileSync(configPath, 'utf8');
174
+ }
175
+
176
+ // Remove any previous bi-superpowers MCP sections so re-running install
177
+ // doesn't duplicate them. Strips each section from its header to the
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('|');
181
+ const stripPattern = new RegExp(
182
+ `\\n?\\[mcp_servers\\.(${namePattern})\\][\\s\\S]*?(?=\\n\\[|$)`,
183
+ 'g'
184
+ );
185
+ existing = existing.replace(stripPattern, '');
186
+
187
+ const newSections =
188
+ `\n\n[mcp_servers.${MODELING_SERVER_NAME}]\n` +
189
+ 'command = "node"\n' +
190
+ `args = ["${tomlEscape(launcher)}"]\n` +
191
+ `\n[mcp_servers.${LEARN_SERVER_NAME}]\n` +
192
+ `url = "${MICROSOFT_LEARN_URL}"\n`;
193
+
194
+ const content = existing.trimEnd() + newSections;
195
+
196
+ const dir = path.dirname(configPath);
197
+ if (!fs.existsSync(dir)) {
198
+ fs.mkdirSync(dir, { recursive: true });
199
+ }
200
+ fs.writeFileSync(configPath, content);
201
+ return configPath;
202
+ }
203
+
204
+ // ============================================
205
+ // GEMINI CLI
206
+ // ============================================
207
+ // Writes ~/.gemini/settings.json. Gemini uses `mcpServers` and `httpUrl`
208
+ // (not `url`) for HTTP transports.
209
+ function writeGeminiConfig(packageDir) {
210
+ const configPath = path.join(os.homedir(), '.gemini', 'settings.json');
211
+ const existing = readJsonSafe(configPath) || {};
212
+ const mcpServers = { ...(existing.mcpServers || {}) };
213
+
214
+ mcpServers[MODELING_SERVER_NAME] = {
215
+ command: 'node',
216
+ args: [getLauncherAbsolutePath(packageDir)],
217
+ };
218
+ mcpServers[LEARN_SERVER_NAME] = {
219
+ httpUrl: MICROSOFT_LEARN_URL,
220
+ };
221
+
222
+ writeJson(configPath, { ...existing, mcpServers });
223
+ return configPath;
224
+ }
225
+
226
+ // ============================================
227
+ // KILO CODE
228
+ // ============================================
229
+ // Writes ~/.kilocode/mcp_settings.json. Kilo uses `mcpServers` and `url`
230
+ // for HTTP transports.
231
+ function writeKiloConfig(packageDir) {
232
+ const configPath = path.join(os.homedir(), '.kilocode', 'mcp_settings.json');
233
+ const existing = readJsonSafe(configPath) || {};
234
+ const mcpServers = { ...(existing.mcpServers || {}) };
235
+
236
+ mcpServers[MODELING_SERVER_NAME] = {
237
+ command: 'node',
238
+ args: [getLauncherAbsolutePath(packageDir)],
239
+ };
240
+ mcpServers[LEARN_SERVER_NAME] = {
241
+ url: MICROSOFT_LEARN_URL,
242
+ };
243
+
244
+ writeJson(configPath, { ...existing, mcpServers });
245
+ return configPath;
246
+ }
247
+
248
+ /**
249
+ * Registry mapping agent IDs to their MCP config writers.
250
+ */
251
+ const MCP_WRITERS = {
252
+ 'claude-code': writeClaudeCodeConfig,
253
+ 'github-copilot': writeCopilotConfig,
254
+ codex: writeCodexConfig,
255
+ 'gemini-cli': writeGeminiConfig,
256
+ kilo: writeKiloConfig,
257
+ };
258
+
259
+ /**
260
+ * Write MCP config for a specific agent.
261
+ * @param {string} agentId - One of the supported agent IDs
262
+ * @param {string} packageDir - Absolute path to the installed package
263
+ * @returns {string|null} The config path written, or null if unknown agent
264
+ * @throws {Error} If the write fails
265
+ */
266
+ function writeMcpConfigForAgent(agentId, packageDir) {
267
+ const writer = MCP_WRITERS[agentId];
268
+ if (!writer) {
269
+ return null;
270
+ }
271
+ return writer(packageDir);
272
+ }
273
+
274
+ module.exports = {
275
+ writeMcpConfigForAgent,
276
+ MCP_WRITERS,
277
+ MODELING_SERVER_NAME,
278
+ LEARN_SERVER_NAME,
279
+ MICROSOFT_LEARN_URL,
280
+ // Exported for testing
281
+ readJsonSafe,
282
+ writeJson,
283
+ tomlEscape,
284
+ escapeRegex,
285
+ assertNotSymlink,
286
+ getLauncherAbsolutePath,
287
+ };
@@ -0,0 +1,273 @@
1
+ /**
2
+ * Tests for the multi-agent MCP config writer.
3
+ */
4
+
5
+ const { test, describe, beforeEach, afterEach } = require('node:test');
6
+ const assert = require('node:assert');
7
+ const fs = require('node:fs');
8
+ const os = require('node:os');
9
+ const path = require('node:path');
10
+
11
+ const {
12
+ MCP_WRITERS,
13
+ MODELING_SERVER_NAME,
14
+ LEARN_SERVER_NAME,
15
+ MICROSOFT_LEARN_URL,
16
+ tomlEscape,
17
+ escapeRegex,
18
+ assertNotSymlink,
19
+ getLauncherAbsolutePath,
20
+ } = require('./mcp-config');
21
+
22
+ describe('mcp-config helpers', () => {
23
+ test('getLauncherAbsolutePath returns absolute launcher path', () => {
24
+ const pkgDir = '/tmp/fake-pkg';
25
+ const launcher = getLauncherAbsolutePath(pkgDir);
26
+ assert.strictEqual(launcher, path.join(pkgDir, 'bin', 'mcp', 'powerbi-modeling-launcher.js'));
27
+ });
28
+
29
+ test('tomlEscape escapes backslashes and quotes', () => {
30
+ assert.strictEqual(tomlEscape('plain'), 'plain');
31
+ assert.strictEqual(tomlEscape('C:\\path\\to\\file'), 'C:\\\\path\\\\to\\\\file');
32
+ assert.strictEqual(tomlEscape('say "hello"'), 'say \\"hello\\"');
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
+ });
83
+ });
84
+
85
+ describe('MCP_WRITERS registry', () => {
86
+ test('covers all 5 supported agents', () => {
87
+ const ids = Object.keys(MCP_WRITERS).sort();
88
+ assert.deepStrictEqual(ids, ['claude-code', 'codex', 'gemini-cli', 'github-copilot', 'kilo']);
89
+ });
90
+
91
+ test('each writer is a function', () => {
92
+ for (const [id, writer] of Object.entries(MCP_WRITERS)) {
93
+ assert.strictEqual(typeof writer, 'function', `${id} writer should be a function`);
94
+ }
95
+ });
96
+ });
97
+
98
+ describe('MCP writers — JSON agents', () => {
99
+ let tempHome;
100
+ let origHome;
101
+ const fakePkgDir = '/tmp/fake-bi-superpowers';
102
+
103
+ beforeEach(() => {
104
+ tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'bi-mcp-home-'));
105
+ origHome = os.homedir;
106
+ os.homedir = () => tempHome;
107
+ });
108
+
109
+ afterEach(() => {
110
+ os.homedir = origHome;
111
+ fs.rmSync(tempHome, { recursive: true, force: true });
112
+ });
113
+
114
+ test('claude-code writer produces mcpServers JSON at ~/.claude.json', () => {
115
+ const configPath = MCP_WRITERS['claude-code'](fakePkgDir);
116
+
117
+ assert.strictEqual(configPath, path.join(tempHome, '.claude.json'));
118
+ assert.ok(fs.existsSync(configPath));
119
+
120
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
121
+ assert.ok(config.mcpServers);
122
+ assert.ok(config.mcpServers[MODELING_SERVER_NAME]);
123
+ assert.strictEqual(config.mcpServers[MODELING_SERVER_NAME].command, 'node');
124
+ assert.ok(
125
+ config.mcpServers[MODELING_SERVER_NAME].args[0].includes('powerbi-modeling-launcher.js')
126
+ );
127
+ assert.deepStrictEqual(config.mcpServers[LEARN_SERVER_NAME], {
128
+ type: 'http',
129
+ url: MICROSOFT_LEARN_URL,
130
+ });
131
+ });
132
+
133
+ test('github-copilot writer uses "servers" key (not mcpServers)', () => {
134
+ const configPath = MCP_WRITERS['github-copilot'](fakePkgDir);
135
+
136
+ assert.strictEqual(configPath, path.join(tempHome, '.copilot', 'mcp-config.json'));
137
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
138
+ assert.ok(config.servers);
139
+ assert.strictEqual(config.mcpServers, undefined);
140
+ assert.strictEqual(config.servers[MODELING_SERVER_NAME].type, 'stdio');
141
+ assert.strictEqual(config.servers[LEARN_SERVER_NAME].type, 'http');
142
+ });
143
+
144
+ test('gemini-cli writer uses httpUrl (not url)', () => {
145
+ const configPath = MCP_WRITERS['gemini-cli'](fakePkgDir);
146
+
147
+ assert.strictEqual(configPath, path.join(tempHome, '.gemini', 'settings.json'));
148
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
149
+ assert.ok(config.mcpServers);
150
+ assert.strictEqual(config.mcpServers[LEARN_SERVER_NAME].httpUrl, MICROSOFT_LEARN_URL);
151
+ assert.strictEqual(config.mcpServers[LEARN_SERVER_NAME].url, undefined);
152
+ });
153
+
154
+ test('kilo writer uses url (standard)', () => {
155
+ const configPath = MCP_WRITERS.kilo(fakePkgDir);
156
+
157
+ assert.strictEqual(configPath, path.join(tempHome, '.kilocode', 'mcp_settings.json'));
158
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
159
+ assert.ok(config.mcpServers);
160
+ assert.strictEqual(config.mcpServers[LEARN_SERVER_NAME].url, MICROSOFT_LEARN_URL);
161
+ });
162
+
163
+ test('claude-code writer preserves existing config', () => {
164
+ const configPath = path.join(tempHome, '.claude.json');
165
+ fs.writeFileSync(
166
+ configPath,
167
+ JSON.stringify({
168
+ otherSetting: 'value',
169
+ mcpServers: { 'existing-mcp': { command: 'echo' } },
170
+ })
171
+ );
172
+
173
+ MCP_WRITERS['claude-code'](fakePkgDir);
174
+
175
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
176
+ assert.strictEqual(config.otherSetting, 'value');
177
+ assert.ok(config.mcpServers['existing-mcp']);
178
+ assert.ok(config.mcpServers[MODELING_SERVER_NAME]);
179
+ assert.ok(config.mcpServers[LEARN_SERVER_NAME]);
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
+ });
219
+ });
220
+
221
+ describe('codex writer — TOML', () => {
222
+ let tempHome;
223
+ let origHome;
224
+
225
+ beforeEach(() => {
226
+ tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'bi-mcp-codex-'));
227
+ origHome = os.homedir;
228
+ os.homedir = () => tempHome;
229
+ });
230
+
231
+ afterEach(() => {
232
+ os.homedir = origHome;
233
+ fs.rmSync(tempHome, { recursive: true, force: true });
234
+ });
235
+
236
+ test('writes TOML with [mcp_servers.*] sections', () => {
237
+ const configPath = MCP_WRITERS.codex('/tmp/fake-pkg');
238
+
239
+ assert.strictEqual(configPath, path.join(tempHome, '.codex', 'config.toml'));
240
+ const content = fs.readFileSync(configPath, 'utf8');
241
+
242
+ assert.ok(content.includes('[mcp_servers.powerbi-modeling]'));
243
+ assert.ok(content.includes('command = "node"'));
244
+ assert.ok(content.includes('[mcp_servers.microsoft-learn]'));
245
+ assert.ok(content.includes(`url = "${MICROSOFT_LEARN_URL}"`));
246
+ });
247
+
248
+ test('preserves existing non-MCP TOML content', () => {
249
+ const configPath = path.join(tempHome, '.codex', 'config.toml');
250
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
251
+ fs.writeFileSync(configPath, '[profile]\nname = "default"\n');
252
+
253
+ MCP_WRITERS.codex('/tmp/fake-pkg');
254
+
255
+ const content = fs.readFileSync(configPath, 'utf8');
256
+ assert.ok(content.includes('[profile]'));
257
+ assert.ok(content.includes('name = "default"'));
258
+ assert.ok(content.includes('[mcp_servers.powerbi-modeling]'));
259
+ });
260
+
261
+ test('re-running install replaces MCP sections, does not duplicate', () => {
262
+ MCP_WRITERS.codex('/tmp/fake-pkg');
263
+ MCP_WRITERS.codex('/tmp/fake-pkg');
264
+
265
+ const content = fs.readFileSync(path.join(tempHome, '.codex', 'config.toml'), 'utf8');
266
+
267
+ // Each section name should appear exactly once
268
+ const modelingCount = (content.match(/\[mcp_servers\.powerbi-modeling\]/g) || []).length;
269
+ const learnCount = (content.match(/\[mcp_servers\.microsoft-learn\]/g) || []).length;
270
+ assert.strictEqual(modelingCount, 1);
271
+ assert.strictEqual(learnCount, 1);
272
+ });
273
+ });
@@ -2,21 +2,20 @@
2
2
  * Official Microsoft MCP Configuration Helpers
3
3
  * ============================================
4
4
  *
5
- * Single source of truth for the Claude Code plugin and legacy adapter
6
- * configurations that point at the official Microsoft Power BI / Fabric
7
- * MCP servers.
5
+ * Single source of truth for the MCP servers that bi-superpowers ships.
6
+ * Currently ships 2 servers, both from Microsoft:
7
+ *
8
+ * - powerbi-modeling-mcp: local stdio launcher that talks to Power BI Desktop
9
+ * via XMLA on localhost. Requires Power BI Desktop running with a model open.
10
+ * - microsoft-learn: HTTP MCP that pipes Microsoft Learn docs into the agent.
8
11
  *
9
12
  * @module lib/microsoft-mcp
10
13
  */
11
14
 
12
15
  const path = require('path');
13
16
 
14
- const REMOTE_POWERBI_URL = 'https://api.fabric.microsoft.com/v1/mcp/powerbi';
15
- const FABRIC_MCP_PACKAGE = '@microsoft/fabric-mcp@latest';
16
17
  const MICROSOFT_LEARN_URL = 'https://learn.microsoft.com/api/mcp';
17
18
  const MODELING_SERVER_NAME = 'powerbi-modeling-mcp';
18
- const REMOTE_SERVER_NAME = 'powerbi-remote';
19
- const FABRIC_SERVER_NAME = 'fabric-mcp-server';
20
19
  const LEARN_SERVER_NAME = 'microsoft-learn';
21
20
  const ABSOLUTE_LAUNCHER_MODE = 'absolute';
22
21
  const PLUGIN_ROOT_LAUNCHER_MODE = 'plugin-root';
@@ -51,15 +50,6 @@ function createPluginMcpConfig(options = {}) {
51
50
  const launcherPath = resolveModelingLauncherPath(options);
52
51
 
53
52
  return {
54
- [REMOTE_SERVER_NAME]: {
55
- type: 'http',
56
- url: REMOTE_POWERBI_URL,
57
- },
58
- [FABRIC_SERVER_NAME]: {
59
- type: 'stdio',
60
- command: 'npx',
61
- args: ['-y', FABRIC_MCP_PACKAGE, 'server', 'start', '--mode', 'all'],
62
- },
63
53
  [MODELING_SERVER_NAME]: {
64
54
  type: 'stdio',
65
55
  command: 'node',
@@ -170,12 +160,8 @@ function mergeMcpConfig(existingConfig, newConfig, format) {
170
160
  module.exports = {
171
161
  ABSOLUTE_LAUNCHER_MODE,
172
162
  PLUGIN_ROOT_LAUNCHER_MODE,
173
- REMOTE_POWERBI_URL,
174
- FABRIC_MCP_PACKAGE,
175
163
  MICROSOFT_LEARN_URL,
176
164
  MODELING_SERVER_NAME,
177
- REMOTE_SERVER_NAME,
178
- FABRIC_SERVER_NAME,
179
165
  LEARN_SERVER_NAME,
180
166
  resolveModelingLauncherPath,
181
167
  createPluginMcpConfig,