@luquimbo/bi-superpowers 3.2.0 → 4.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.
Files changed (91) hide show
  1. package/.claude-plugin/marketplace.json +5 -3
  2. package/.claude-plugin/plugin.json +28 -2
  3. package/.claude-plugin/skill-manifest.json +22 -6
  4. package/.plugin/plugin.json +1 -1
  5. package/AGENTS.md +52 -36
  6. package/CHANGELOG.md +295 -0
  7. package/README.md +75 -26
  8. package/bin/build-plugin.js +11 -4
  9. package/bin/cli.js +113 -16
  10. package/bin/commands/build-desktop.js +35 -16
  11. package/bin/commands/diff.js +31 -13
  12. package/bin/commands/install.js +7 -3
  13. package/bin/commands/lint.js +40 -26
  14. package/bin/commands/mcp-setup.js +3 -10
  15. package/bin/commands/update-check.js +389 -0
  16. package/bin/lib/generators/claude-plugin.js +144 -6
  17. package/bin/lib/generators/shared.js +29 -33
  18. package/bin/lib/mcp-config.js +168 -12
  19. package/bin/lib/skills.js +115 -27
  20. package/bin/postinstall.js +4 -2
  21. package/bin/utils/mcp-detect.js +2 -2
  22. package/commands/bi-start.md +218 -0
  23. package/commands/pbi-connect.md +43 -65
  24. package/commands/project-kickoff.md +393 -673
  25. package/commands/report-design.md +403 -0
  26. package/desktop-extension/manifest.json +3 -3
  27. package/package.json +7 -5
  28. package/skills/bi-start/SKILL.md +220 -0
  29. package/skills/bi-start/scripts/update-check.js +389 -0
  30. package/skills/pbi-connect/SKILL.md +45 -67
  31. package/skills/pbi-connect/scripts/update-check.js +389 -0
  32. package/skills/project-kickoff/SKILL.md +395 -675
  33. package/skills/project-kickoff/scripts/update-check.js +389 -0
  34. package/skills/report-design/SKILL.md +405 -0
  35. package/skills/report-design/references/cli-commands.md +184 -0
  36. package/skills/report-design/references/cli-setup.md +101 -0
  37. package/skills/report-design/references/close-write-open-pattern.md +80 -0
  38. package/skills/report-design/references/layouts/finance.md +65 -0
  39. package/skills/report-design/references/layouts/generic.md +46 -0
  40. package/skills/report-design/references/layouts/hr.md +48 -0
  41. package/skills/report-design/references/layouts/marketing.md +45 -0
  42. package/skills/report-design/references/layouts/operations.md +44 -0
  43. package/skills/report-design/references/layouts/sales.md +50 -0
  44. package/skills/report-design/references/native-visuals.md +341 -0
  45. package/skills/report-design/references/pbi-desktop-installation.md +87 -0
  46. package/skills/report-design/references/pbir-preview-activation.md +40 -0
  47. package/skills/report-design/references/slicer.md +89 -0
  48. package/skills/report-design/references/textbox.md +101 -0
  49. package/skills/report-design/references/themes/BISuperpowers.json +915 -0
  50. package/skills/report-design/references/troubleshooting.md +135 -0
  51. package/skills/report-design/references/visual-types.md +78 -0
  52. package/skills/report-design/scripts/apply-theme.js +243 -0
  53. package/skills/report-design/scripts/create-visual.js +878 -0
  54. package/skills/report-design/scripts/ensure-pbi-cli.sh +41 -0
  55. package/skills/report-design/scripts/update-check.js +389 -0
  56. package/skills/report-design/scripts/validate-pbir.js +322 -0
  57. package/src/content/base.md +12 -68
  58. package/src/content/mcp-requirements.json +0 -25
  59. package/src/content/routing.md +19 -74
  60. package/src/content/skills/bi-start.md +191 -0
  61. package/src/content/skills/pbi-connect.md +22 -65
  62. package/src/content/skills/project-kickoff.md +372 -673
  63. package/src/content/skills/report-design/SKILL.md +376 -0
  64. package/src/content/skills/report-design/references/cli-commands.md +184 -0
  65. package/src/content/skills/report-design/references/cli-setup.md +101 -0
  66. package/src/content/skills/report-design/references/close-write-open-pattern.md +80 -0
  67. package/src/content/skills/report-design/references/layouts/finance.md +65 -0
  68. package/src/content/skills/report-design/references/layouts/generic.md +46 -0
  69. package/src/content/skills/report-design/references/layouts/hr.md +48 -0
  70. package/src/content/skills/report-design/references/layouts/marketing.md +45 -0
  71. package/src/content/skills/report-design/references/layouts/operations.md +44 -0
  72. package/src/content/skills/report-design/references/layouts/sales.md +50 -0
  73. package/src/content/skills/report-design/references/native-visuals.md +341 -0
  74. package/src/content/skills/report-design/references/pbi-desktop-installation.md +87 -0
  75. package/src/content/skills/report-design/references/pbir-preview-activation.md +40 -0
  76. package/src/content/skills/report-design/references/slicer.md +89 -0
  77. package/src/content/skills/report-design/references/textbox.md +101 -0
  78. package/src/content/skills/report-design/references/themes/BISuperpowers.json +915 -0
  79. package/src/content/skills/report-design/references/troubleshooting.md +135 -0
  80. package/src/content/skills/report-design/references/visual-types.md +78 -0
  81. package/src/content/skills/report-design/scripts/apply-theme.js +243 -0
  82. package/src/content/skills/report-design/scripts/create-visual.js +878 -0
  83. package/src/content/skills/report-design/scripts/ensure-pbi-cli.sh +41 -0
  84. package/src/content/skills/report-design/scripts/validate-pbir.js +322 -0
  85. package/bin/commands/install.test.js +0 -289
  86. package/bin/commands/lint.test.js +0 -103
  87. package/bin/lib/generators/claude-plugin.test.js +0 -111
  88. package/bin/lib/mcp-config.test.js +0 -310
  89. package/bin/lib/microsoft-mcp.test.js +0 -115
  90. package/bin/utils/mcp-detect.test.js +0 -81
  91. package/bin/utils/tui.test.js +0 -127
@@ -20,10 +20,12 @@ const {
20
20
  } = require('../microsoft-mcp');
21
21
  const { parseSkillMetadata, getSkillPurpose } = require('./shared');
22
22
 
23
- // Currently the plugin ships 2 skills, both interactive wizards.
23
+ // Currently the plugin ships 4 skills (bi-start, project-kickoff,
24
+ // pbi-connect, report-design). bi-start is the session-opener that
25
+ // routes to the other three; the rest are specialists.
24
26
  // The old reference-skills split is kept as an empty set so that any
25
27
  // downstream code that checks `REFERENCE_SKILLS.has(x)` still works.
26
- const COMMAND_SKILLS = new Set(['project-kickoff', 'pbi-connect']);
28
+ const COMMAND_SKILLS = new Set(['bi-start', 'project-kickoff', 'pbi-connect', 'report-design']);
27
29
 
28
30
  const REFERENCE_SKILLS = new Set();
29
31
 
@@ -38,6 +40,36 @@ function ensureDirectory(directory) {
38
40
  }
39
41
  }
40
42
 
43
+ /**
44
+ * Recursively copy a directory tree from `source` to `target`.
45
+ * Follows the Node built-in cp() semantics: overwrites existing files,
46
+ * preserves directory structure. No-op if source does not exist.
47
+ *
48
+ * Used to propagate `references/` and `scripts/` subfolders from
49
+ * folder-based source skills into the generated plugin so the agent can
50
+ * load on-demand material and run bundled helper scripts.
51
+ *
52
+ * @param {string} source - Source directory (must exist to do anything)
53
+ * @param {string} target - Target directory (created if missing)
54
+ */
55
+ function copyDirectory(source, target) {
56
+ if (!fs.existsSync(source)) {
57
+ return;
58
+ }
59
+ ensureDirectory(target);
60
+ for (const entry of fs.readdirSync(source, { withFileTypes: true })) {
61
+ const sourcePath = path.join(source, entry.name);
62
+ const targetPath = path.join(target, entry.name);
63
+ if (entry.isDirectory()) {
64
+ copyDirectory(sourcePath, targetPath);
65
+ } else if (entry.isFile()) {
66
+ fs.copyFileSync(sourcePath, targetPath);
67
+ }
68
+ // Ignore symlinks / other entry types to match the conservative
69
+ // behaviour of readSkillDirectory().
70
+ }
71
+ }
72
+
41
73
  /**
42
74
  * Rewrite library references for generated plugin targets.
43
75
  *
@@ -92,16 +124,57 @@ function toFrontmatterValue(value) {
92
124
  return JSON.stringify(value);
93
125
  }
94
126
 
127
+ // ---------------------------------------------------------------------------
128
+ // Update-check preamble
129
+ // ---------------------------------------------------------------------------
130
+ // Cross-agent "auto-update" mechanism (see ROADMAP item 4 / gstack pattern).
131
+ // The preamble is prepended to every generated SKILL.md and command Markdown
132
+ // so the agent reading the skill content runs the version check at the top
133
+ // of each conversation, surfaces an update notice if available, and then
134
+ // proceeds with the skill. The check caches for 24h on disk and degrades to
135
+ // no-output on network failure — it must never block the skill invocation.
136
+ //
137
+ // The update-check.js script is shipped alongside each skill's `scripts/`
138
+ // folder at generation time (see copyUpdateCheckScript) so `{skillBundleDir}`
139
+ // resolves correctly regardless of the install path (npm-based `super
140
+ // install` OR Claude Code plugin marketplace).
141
+
142
+ const UPDATE_CHECK_PREAMBLE = `<!-- bi-superpowers update-check preamble (auto-generated, do not edit) -->
143
+ ## ⬆ Update check
144
+
145
+ Before handling the user's request below, run this command **once per conversation** and interpret the output:
146
+
147
+ \`\`\`bash
148
+ node "{skillBundleDir}/scripts/update-check.js" --silent-if-uptodate --silent-if-snoozed
149
+ \`\`\`
150
+
151
+ - Empty output or \`UPTODATE\` — proceed with the skill silently. No message.
152
+ - \`UPDATE_AVAILABLE <installed> <latest>\` — tell the user exactly once this conversation, before diving into the skill:
153
+ > "Hay **bi-superpowers v{latest}** disponible (estás en v{installed}). Actualizá con \`super upgrade\` (o \`/plugin update bi-superpowers\` en Claude Code) cuando te venga bien."
154
+
155
+ Then continue with the skill below.
156
+ - \`SNOOZED <iso>\` — proceed silently.
157
+
158
+ If the command fails (missing binary, permissions, offline), ignore the error and proceed with the skill. The update check must never block the user's request.
159
+
160
+ ---
161
+ <!-- /bi-superpowers update-check preamble -->
162
+
163
+ `;
164
+
95
165
  /**
96
166
  * Build command markdown with Claude Code frontmatter.
97
167
  *
98
168
  * @param {Object} skill - Skill definition object
99
169
  * @param {string} libraryPrefix - Library prefix used in generated target
170
+ * @param {Object} [options] - Options
171
+ * @param {boolean} [options.skipPreamble] - Omit the update-check preamble (tests)
100
172
  * @returns {string} Command markdown
101
173
  */
102
- function buildCommandMarkdown(skill, libraryPrefix) {
174
+ function buildCommandMarkdown(skill, libraryPrefix, options = {}) {
103
175
  const description = getSkillPurpose(skill.name);
104
176
  const content = rewriteLibraryReferences(skill.content, libraryPrefix);
177
+ const preamble = options.skipPreamble ? '' : UPDATE_CHECK_PREAMBLE;
105
178
 
106
179
  return `---
107
180
  description: ${toFrontmatterValue(description)}
@@ -109,7 +182,7 @@ description: ${toFrontmatterValue(description)}
109
182
 
110
183
  <!-- Generated by BI Agent Superpowers. Edit src/content/skills/${skill.name}.md instead. -->
111
184
 
112
- ${content}`;
185
+ ${preamble}${content}`;
113
186
  }
114
187
 
115
188
  /**
@@ -118,10 +191,13 @@ ${content}`;
118
191
  * @param {Object} skill - Skill definition object
119
192
  * @param {string} version - Package version
120
193
  * @param {string} libraryPrefix - Library prefix used in generated target
194
+ * @param {Object} [options] - Options
195
+ * @param {boolean} [options.skipPreamble] - Omit the update-check preamble (tests)
121
196
  * @returns {string} Skill markdown
122
197
  */
123
- function buildSkillMarkdown(skill, version, libraryPrefix) {
198
+ function buildSkillMarkdown(skill, version, libraryPrefix, options = {}) {
124
199
  const content = rewriteLibraryReferences(skill.content, libraryPrefix);
200
+ const preamble = options.skipPreamble ? '' : UPDATE_CHECK_PREAMBLE;
125
201
 
126
202
  return `---
127
203
  name: ${toFrontmatterValue(skill.name)}
@@ -131,7 +207,29 @@ version: ${toFrontmatterValue(version)}
131
207
 
132
208
  <!-- Generated by BI Agent Superpowers. Edit src/content/skills/${skill.name}.md instead. -->
133
209
 
134
- ${content}`;
210
+ ${preamble}${content}`;
211
+ }
212
+
213
+ /**
214
+ * Copy bin/commands/update-check.js into each generated skill's scripts/
215
+ * directory so the preamble's `{skillBundleDir}/scripts/update-check.js`
216
+ * invocation works under both install paths (npm-based `super install` and
217
+ * Claude Code plugin marketplace). The file is bundled with the npm tarball
218
+ * via bin/, so this is purely a generation-time copy into skills/.
219
+ *
220
+ * @param {string} packageDir - npm package root (source of the canonical script)
221
+ * @param {string} targetDir - plugin target root (contains skills/<name>/)
222
+ * @param {Object[]} skills - Skills to wire the script into
223
+ */
224
+ function copyUpdateCheckScript(packageDir, targetDir, skills) {
225
+ if (!packageDir) return;
226
+ const src = path.join(packageDir, 'bin', 'commands', 'update-check.js');
227
+ if (!fs.existsSync(src)) return;
228
+ for (const skill of skills) {
229
+ const scriptsDir = path.join(targetDir, 'skills', skill.name, 'scripts');
230
+ ensureDirectory(scriptsDir);
231
+ fs.copyFileSync(src, path.join(scriptsDir, 'update-check.js'));
232
+ }
135
233
  }
136
234
 
137
235
  /**
@@ -178,6 +276,25 @@ async function generate(targetDir, skills, options = {}) {
178
276
  ensureDirectory(commandsDir);
179
277
  ensureDirectory(skillsDir);
180
278
 
279
+ // Pull discoverable metadata from the installed package.json when available
280
+ // so the generated plugin.json has the same repo/license/keywords/homepage
281
+ // fields as the published npm package. Falls back to bare identity fields
282
+ // if options.packageDir is missing (unit tests, detached generation).
283
+ let pkgMetadata = {};
284
+ if (options.packageDir) {
285
+ try {
286
+ const pkg = require(path.join(options.packageDir, 'package.json'));
287
+ pkgMetadata = {
288
+ repository: pkg.repository,
289
+ homepage: pkg.homepage,
290
+ license: pkg.license,
291
+ keywords: Array.isArray(pkg.keywords) ? pkg.keywords : undefined,
292
+ };
293
+ } catch (_) {
294
+ // Package.json unreadable — generate a minimal manifest and move on.
295
+ }
296
+ }
297
+
181
298
  const pluginManifest = {
182
299
  name: 'bi-superpowers',
183
300
  description:
@@ -186,6 +303,10 @@ async function generate(targetDir, skills, options = {}) {
186
303
  author: {
187
304
  name: 'Lucas Sanchez',
188
305
  },
306
+ ...(pkgMetadata.repository !== undefined ? { repository: pkgMetadata.repository } : {}),
307
+ ...(pkgMetadata.homepage !== undefined ? { homepage: pkgMetadata.homepage } : {}),
308
+ ...(pkgMetadata.license !== undefined ? { license: pkgMetadata.license } : {}),
309
+ ...(pkgMetadata.keywords !== undefined ? { keywords: pkgMetadata.keywords } : {}),
189
310
  };
190
311
 
191
312
  fs.writeFileSync(
@@ -312,6 +433,9 @@ async function generate(targetDir, skills, options = {}) {
312
433
  }
313
434
 
314
435
  // ALL skills → skills/*/SKILL.md (universal, discoverable by all Claude tools)
436
+ // Folder-based skills additionally get their `references/` and `scripts/`
437
+ // subfolders copied verbatim so the agent can load on-demand material and
438
+ // invoke helper scripts bundled with the skill.
315
439
  for (const skill of skills) {
316
440
  const skillDir = path.join(skillsDir, skill.name);
317
441
  ensureDirectory(skillDir);
@@ -319,8 +443,18 @@ async function generate(targetDir, skills, options = {}) {
319
443
  path.join(skillDir, 'SKILL.md'),
320
444
  buildSkillMarkdown(skill, version, libraryPrefix)
321
445
  );
446
+
447
+ if (skill.bundleDir) {
448
+ copyDirectory(path.join(skill.bundleDir, 'references'), path.join(skillDir, 'references'));
449
+ copyDirectory(path.join(skill.bundleDir, 'scripts'), path.join(skillDir, 'scripts'));
450
+ }
322
451
  }
323
452
 
453
+ // Ship the update-check helper alongside every skill so the SKILL.md
454
+ // preamble can invoke it via `{skillBundleDir}/scripts/update-check.js`
455
+ // regardless of install path.
456
+ copyUpdateCheckScript(options.packageDir, targetDir, skills);
457
+
324
458
  console.log(' ✓ Created Claude Code plugin manifest');
325
459
  console.log(' ✓ Created .mcp.json with official Microsoft MCP servers');
326
460
  console.log(` ✓ Created ${commandSkills.length} plugin commands`);
@@ -331,6 +465,10 @@ module.exports = {
331
465
  name: 'Claude Code Plugin',
332
466
  description: 'Native Claude Code plugin (recommended)',
333
467
  generate,
468
+ buildCommandMarkdown,
469
+ buildSkillMarkdown,
470
+ copyUpdateCheckScript,
471
+ UPDATE_CHECK_PREAMBLE,
334
472
  COMMAND_SKILLS,
335
473
  REFERENCE_SKILLS,
336
474
  };
@@ -29,12 +29,19 @@ function parseSkillMetadata(content) {
29
29
  const titleMatch = content.match(/^#\s+(.+)/m);
30
30
  if (titleMatch) metadata.title = titleMatch[1];
31
31
 
32
- // Get triggers
32
+ // Get triggers — collect every double-quoted fragment in the Trigger
33
+ // section. This handles all three shapes our skills use:
34
+ // - Bare-quote bullets: - "connect Power BI", "PBI connection"
35
+ // - Prose bullets: - User mentions: "analizar proyecto", "new project"
36
+ // - Asterisk bullets: * "pattern"
37
+ // The regex intentionally doesn't anchor to bullet syntax — that's
38
+ // what broke extraction for project-kickoff (prose) and under-counted
39
+ // pbi-connect (first quote per bullet only) before this change.
33
40
  const triggerSection = content.match(/##\s+Trigger[\s\S]*?(?=##|$)/i);
34
41
  if (triggerSection) {
35
- const triggers = triggerSection[0].match(/[-*]\s+"([^"]+)"/g);
36
- if (triggers) {
37
- metadata.triggers = triggers.map((t) => t.replace(/[-*]\s+"/, '').replace('"', ''));
42
+ const quotedFragments = triggerSection[0].match(/"([^"]+)"/g);
43
+ if (quotedFragments) {
44
+ metadata.triggers = quotedFragments.map((t) => t.slice(1, -1));
38
45
  }
39
46
  }
40
47
 
@@ -47,41 +54,29 @@ function parseSkillMetadata(content) {
47
54
  return metadata;
48
55
  }
49
56
 
57
+ /**
58
+ * Map of skill name → one-line purpose description used in generated
59
+ * commands/*.md frontmatter and the CLI's `super powers` output.
60
+ *
61
+ * Keep this map in lock-step with `src/content/skills/`. Adding a skill
62
+ * without an entry here makes the generated description fall back to the
63
+ * generic `'Specialized BI assistance'` string and breaks the parity
64
+ * test in shared.test.js.
65
+ */
66
+ const SKILL_PURPOSES = {
67
+ 'bi-start': 'Session opener — environment snapshot, update check, and routing to other skills',
68
+ 'project-kickoff': 'Project analysis and planning',
69
+ 'pbi-connect': 'Power BI Desktop connection',
70
+ 'report-design': '3-page PBIR report generation for Power BI Desktop (Windows)',
71
+ };
72
+
50
73
  /**
51
74
  * Get skill purpose description for table
52
75
  * @param {string} skillName - Name of the skill
53
76
  * @returns {string} Purpose description
54
77
  */
55
78
  function getSkillPurpose(skillName) {
56
- const purposes = {
57
- dax: 'DAX writing and optimization',
58
- 'power-query': 'Power Query / M language patterns',
59
- 'data-modeling': 'Star schema design',
60
- 'data-model-design': 'Interactive model builder',
61
- 'excel-formulas': 'Modern Excel 365 formulas',
62
- 'project-kickoff': 'Project analysis and planning',
63
- 'theme-tweaker': 'Power BI theme customization',
64
- 'rls-design': 'Row-level security design',
65
- 'query-performance': 'Performance optimization',
66
- 'data-quality': 'Data validation and profiling',
67
- 'fabric-scripts': 'Fabric automation scripts',
68
- 'fast-standard': 'FAST spreadsheet standard',
69
- 'testing-validation': 'Testing and validation patterns',
70
- 'pbi-connect': 'Power BI Desktop connection',
71
- contributions: 'Contribution validation',
72
- // New command skills
73
- 'dax-doctor': 'DAX debugging and optimization wizard',
74
- 'model-documenter': 'Semantic model documentation generator',
75
- 'migration-assistant': 'Migration and upgrade assistant',
76
- 'report-layout': 'Report page layout planner',
77
- // New reference skills
78
- governance: 'Naming conventions, standards, and governance',
79
- 'semantic-model': 'Semantic model best practices and patterns',
80
- 'report-design': 'Report design and visualization principles',
81
- deployment: 'CI/CD and deployment patterns for BI',
82
- 'dax-udf': 'DAX user-defined functions (UDFs)',
83
- };
84
- return purposes[skillName] || 'Specialized BI assistance';
79
+ return SKILL_PURPOSES[skillName] || 'Specialized BI assistance';
85
80
  }
86
81
 
87
82
  /**
@@ -243,6 +238,7 @@ ${footer}`;
243
238
  module.exports = {
244
239
  parseSkillMetadata,
245
240
  getSkillPurpose,
241
+ SKILL_PURPOSES,
246
242
  generateSkillsSection,
247
243
  getCodeStandards,
248
244
  getFormatHeader,
@@ -29,7 +29,7 @@ const path = require('path');
29
29
  const os = require('os');
30
30
 
31
31
  const MICROSOFT_LEARN_URL = 'https://learn.microsoft.com/api/mcp';
32
- const MODELING_SERVER_NAME = 'powerbi-modeling';
32
+ const MODELING_SERVER_NAME = 'powerbi-modeling-mcp';
33
33
  const LEARN_SERVER_NAME = 'microsoft-learn';
34
34
 
35
35
  /**
@@ -42,6 +42,11 @@ function getLauncherAbsolutePath(packageDir) {
42
42
 
43
43
  /**
44
44
  * Safely read a JSON file. Returns null if missing or unparseable.
45
+ *
46
+ * NOTE: this swallows parse errors. Do NOT use it before a write — a
47
+ * silent null on a corrupt user config means a spread merge ends up
48
+ * replacing the whole config with our 2 servers (data loss). Use
49
+ * `readJsonStrict` for write paths instead.
45
50
  */
46
51
  function readJsonSafe(filePath) {
47
52
  if (!fs.existsSync(filePath)) return null;
@@ -52,6 +57,36 @@ function readJsonSafe(filePath) {
52
57
  }
53
58
  }
54
59
 
60
+ /**
61
+ * Read a JSON file for write paths. Returns null only if the file is
62
+ * absent or empty. Throws an actionable error if the file exists with
63
+ * non-empty content but is not parseable — silently overwriting a
64
+ * user's corrupt config (especially `~/.claude.json`) destroys data.
65
+ *
66
+ * @throws {Error} if the file exists but is unreadable or unparseable
67
+ */
68
+ function readJsonStrict(filePath) {
69
+ if (!fs.existsSync(filePath)) return null;
70
+ let raw;
71
+ try {
72
+ raw = fs.readFileSync(filePath, 'utf8');
73
+ } catch (err) {
74
+ throw new Error(
75
+ `Could not read ${filePath}: ${err.message}. ` + 'Check file permissions and retry.'
76
+ );
77
+ }
78
+ if (raw.trim() === '') return null;
79
+ try {
80
+ return JSON.parse(raw);
81
+ } catch (err) {
82
+ throw new Error(
83
+ `Refusing to overwrite ${filePath}: file exists but is not valid JSON ` +
84
+ `(${err.message}). Inspect or move it manually before retrying. ` +
85
+ `If a previous-install backup exists at ${filePath}.bak, restore from there.`
86
+ );
87
+ }
88
+ }
89
+
55
90
  /**
56
91
  * Reject symlink attacks before writing to a file. We use lstatSync so
57
92
  * we detect the link itself (not what it points to). If a user home has
@@ -69,16 +104,132 @@ function assertNotSymlink(filePath) {
69
104
  }
70
105
 
71
106
  /**
72
- * Write a JSON file, creating parent dirs if needed.
73
- * Refuses to overwrite symbolic links for safety.
107
+ * Walk the ancestors of `filePath` up to (but not including) the user's
108
+ * home directory and reject if any ancestor is a symbolic link. Needed
109
+ * because `assertNotSymlink` only checks the leaf — a symlink at a
110
+ * parent (e.g. `~/.copilot` → `/tmp/attacker/`) would still redirect
111
+ * every `.tmp` / `.bak` / final rename through the link, bypassing the
112
+ * leaf-only hardening.
113
+ *
114
+ * Trust boundaries:
115
+ * - We stop walking at `os.homedir()` itself. If the user's home is a
116
+ * symlink, that's their machine setup, not something we can police.
117
+ * - We also stop if, after `realpathSync`, the ancestor resolves
118
+ * outside the home subtree — defensive, covers the case where an
119
+ * earlier ancestor already redirected us elsewhere.
120
+ * - We stop at filesystem root (`path.dirname(x) === x`).
121
+ *
122
+ * Known Windows gap: this uses `lstatSync().isSymbolicLink()`. NTFS
123
+ * junctions are NOT classified as symlinks by Node, so a junction at
124
+ * `~/.copilot` would pass this check. Documented as a residual risk
125
+ * in `coordination/TO_CODEX_REVIEW.md`; addressing it needs
126
+ * `fs.readlinkSync` with reparse-point inspection and is out of scope
127
+ * for this commit.
128
+ *
129
+ * @throws {Error} if any ancestor of filePath (up to home) is a symlink
74
130
  */
75
- function writeJson(filePath, data) {
131
+ function assertNoSymlinkedAncestor(filePath) {
132
+ const home = os.homedir();
133
+ const homeResolved = fs.existsSync(home) ? fs.realpathSync(home) : home;
134
+
135
+ let current = path.dirname(filePath);
136
+ const visited = new Set();
137
+
138
+ while (current && !visited.has(current)) {
139
+ visited.add(current);
140
+
141
+ // Stop at the user's home — trust boundary.
142
+ if (current === home || current === homeResolved) return;
143
+
144
+ if (fs.existsSync(current)) {
145
+ if (fs.lstatSync(current).isSymbolicLink()) {
146
+ throw new Error(
147
+ `Refusing to write MCP config: parent directory ${current} is a symbolic link. ` +
148
+ 'Remove the symlink (or redirect super install at a different path) and retry.'
149
+ );
150
+ }
151
+
152
+ // Defensive: if realpath has already pulled us outside the home
153
+ // subtree, stop walking — we're in territory we can't reason about.
154
+ const currentResolved = fs.realpathSync(current);
155
+ if (!currentResolved.startsWith(homeResolved)) return;
156
+ }
157
+
158
+ const parent = path.dirname(current);
159
+ if (parent === current) return; // filesystem root
160
+ current = parent;
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Full pre-write safety check: leaf must not be a symlink, and no
166
+ * ancestor up to `os.homedir()` may be a symlink either. Use this
167
+ * instead of `assertNotSymlink` on every write path inside the user
168
+ * home directory.
169
+ */
170
+ function assertPathSafeForWrite(filePath) {
76
171
  assertNotSymlink(filePath);
172
+ assertNoSymlinkedAncestor(filePath);
173
+ }
174
+
175
+ /**
176
+ * Write any text content to a file using a safe pattern:
177
+ *
178
+ * 1. Refuse if the target is a symbolic link (assertNotSymlink).
179
+ * 2. If the target exists, copy it to `<target>.bak` first. This
180
+ * gives the user a one-slot recovery file overwritten on each
181
+ * install — enough to recover from a bad merge without
182
+ * accumulating stale backups across many install runs.
183
+ * 3. Write to `<target>.tmp` then atomically rename to the final
184
+ * path. `fs.renameSync` is atomic on POSIX and functionally
185
+ * atomic for same-volume renames on NTFS, which is the only
186
+ * shape we hit when writing into the user home (~/...).
187
+ * 4. On any error mid-process, best-effort cleanup of the .tmp
188
+ * file so we never leave half-written turds behind.
189
+ *
190
+ * The atomic + backup pattern matters most for `~/.claude.json` —
191
+ * Claude Code's primary user config holds project pointers, OAuth
192
+ * state, conversation history. A crash mid-write or a bad merge
193
+ * would brick the user's Claude install otherwise.
194
+ */
195
+ function writeFileAtomic(filePath, content) {
196
+ assertPathSafeForWrite(filePath);
77
197
  const dir = path.dirname(filePath);
78
198
  if (!fs.existsSync(dir)) {
79
199
  fs.mkdirSync(dir, { recursive: true });
80
200
  }
81
- fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n');
201
+
202
+ if (fs.existsSync(filePath)) {
203
+ try {
204
+ fs.copyFileSync(filePath, `${filePath}.bak`);
205
+ } catch (err) {
206
+ throw new Error(
207
+ `Could not back up ${filePath} before writing: ${err.message}. ` +
208
+ 'Aborting to avoid losing the existing file.'
209
+ );
210
+ }
211
+ }
212
+
213
+ const tmpPath = `${filePath}.tmp`;
214
+ try {
215
+ fs.writeFileSync(tmpPath, content);
216
+ fs.renameSync(tmpPath, filePath);
217
+ } catch (err) {
218
+ try {
219
+ fs.unlinkSync(tmpPath);
220
+ } catch (_) {
221
+ // Best-effort cleanup; ignore if there is nothing to remove.
222
+ }
223
+ throw err;
224
+ }
225
+ }
226
+
227
+ /**
228
+ * Write a JSON file using the atomic + backup pattern.
229
+ * Refuses to overwrite symbolic links for safety.
230
+ */
231
+ function writeJson(filePath, data) {
232
+ writeFileAtomic(filePath, JSON.stringify(data, null, 2) + '\n');
82
233
  }
83
234
 
84
235
  /**
@@ -226,7 +377,10 @@ function makeJsonWriter(agentId) {
226
377
  const agentConfig = JSON_AGENT_CONFIGS[agentId];
227
378
  return function jsonWriter(packageDir) {
228
379
  const configPath = agentConfig.configPath();
229
- const existing = readJsonSafe(configPath) || {};
380
+ // Use strict reader on the write path: a silent parse failure here
381
+ // would discard the user's other config keys (history, OAuth, etc.)
382
+ // when the empty {} fallback is spread into the new write.
383
+ const existing = readJsonStrict(configPath) || {};
230
384
  const wrapperKey = agentConfig.wrapperKey;
231
385
 
232
386
  const newServers = buildJsonServers(agentConfig, packageDir);
@@ -250,7 +404,7 @@ function makeJsonWriter(agentId) {
250
404
  // appending the fresh ones.
251
405
  function writeCodexConfig(packageDir) {
252
406
  const configPath = path.join(os.homedir(), '.codex', 'config.toml');
253
- assertNotSymlink(configPath);
407
+ assertPathSafeForWrite(configPath);
254
408
  const launcher = getLauncherAbsolutePath(packageDir);
255
409
 
256
410
  let existing = '';
@@ -278,11 +432,9 @@ function writeCodexConfig(packageDir) {
278
432
 
279
433
  const content = existing.trimEnd() + newSections;
280
434
 
281
- const dir = path.dirname(configPath);
282
- if (!fs.existsSync(dir)) {
283
- fs.mkdirSync(dir, { recursive: true });
284
- }
285
- fs.writeFileSync(configPath, content);
435
+ // Use the same atomic + backup pattern as the JSON writers — a crash
436
+ // mid-write to ~/.codex/config.toml would brick the user's Codex install.
437
+ writeFileAtomic(configPath, content);
286
438
  return configPath;
287
439
  }
288
440
 
@@ -322,9 +474,13 @@ module.exports = {
322
474
  MICROSOFT_LEARN_URL,
323
475
  // Exported for testing
324
476
  readJsonSafe,
477
+ readJsonStrict,
325
478
  writeJson,
479
+ writeFileAtomic,
326
480
  tomlEscape,
327
481
  escapeRegex,
328
482
  assertNotSymlink,
483
+ assertNoSymlinkedAncestor,
484
+ assertPathSafeForWrite,
329
485
  getLauncherAbsolutePath,
330
486
  };