@luquimbo/bi-superpowers 3.2.0 → 4.1.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 +5 -3
- package/.claude-plugin/plugin.json +28 -2
- package/.claude-plugin/skill-manifest.json +22 -6
- package/.plugin/plugin.json +1 -1
- package/AGENTS.md +53 -36
- package/CHANGELOG.md +310 -0
- package/README.md +77 -26
- package/bin/build-plugin.js +11 -4
- package/bin/cli.js +113 -16
- package/bin/commands/build-desktop.js +35 -16
- package/bin/commands/diff.js +31 -13
- package/bin/commands/install.js +7 -3
- package/bin/commands/lint.js +40 -26
- package/bin/commands/mcp-setup.js +3 -10
- package/bin/commands/update-check.js +403 -0
- package/bin/lib/generators/claude-plugin.js +162 -6
- package/bin/lib/generators/shared.js +29 -33
- package/bin/lib/mcp-config.js +168 -12
- package/bin/lib/skills.js +115 -27
- package/bin/postinstall.js +4 -2
- package/bin/utils/mcp-detect.js +2 -2
- package/commands/bi-start.md +197 -0
- package/commands/pbi-connect.md +43 -65
- package/commands/project-kickoff.md +393 -673
- package/commands/report-design.md +403 -0
- package/desktop-extension/manifest.json +3 -3
- package/package.json +7 -5
- package/skills/bi-start/SKILL.md +199 -0
- package/skills/bi-start/scripts/update-check.js +403 -0
- package/skills/pbi-connect/SKILL.md +45 -67
- package/skills/pbi-connect/scripts/update-check.js +403 -0
- package/skills/project-kickoff/SKILL.md +395 -675
- package/skills/project-kickoff/scripts/update-check.js +403 -0
- package/skills/report-design/SKILL.md +405 -0
- package/skills/report-design/references/cli-commands.md +184 -0
- package/skills/report-design/references/cli-setup.md +101 -0
- package/skills/report-design/references/close-write-open-pattern.md +80 -0
- package/skills/report-design/references/layouts/finance.md +65 -0
- package/skills/report-design/references/layouts/generic.md +46 -0
- package/skills/report-design/references/layouts/hr.md +48 -0
- package/skills/report-design/references/layouts/marketing.md +45 -0
- package/skills/report-design/references/layouts/operations.md +44 -0
- package/skills/report-design/references/layouts/sales.md +50 -0
- package/skills/report-design/references/native-visuals.md +341 -0
- package/skills/report-design/references/pbi-desktop-installation.md +87 -0
- package/skills/report-design/references/pbir-preview-activation.md +40 -0
- package/skills/report-design/references/slicer.md +89 -0
- package/skills/report-design/references/textbox.md +101 -0
- package/skills/report-design/references/themes/BISuperpowers.json +915 -0
- package/skills/report-design/references/troubleshooting.md +135 -0
- package/skills/report-design/references/visual-types.md +78 -0
- package/skills/report-design/scripts/apply-theme.js +243 -0
- package/skills/report-design/scripts/create-visual.js +878 -0
- package/skills/report-design/scripts/ensure-pbi-cli.sh +41 -0
- package/skills/report-design/scripts/update-check.js +403 -0
- package/skills/report-design/scripts/validate-pbir.js +322 -0
- package/src/content/base.md +12 -68
- package/src/content/mcp-requirements.json +0 -25
- package/src/content/routing.md +19 -74
- package/src/content/skills/bi-start.md +191 -0
- package/src/content/skills/pbi-connect.md +22 -65
- package/src/content/skills/project-kickoff.md +372 -673
- package/src/content/skills/report-design/SKILL.md +376 -0
- package/src/content/skills/report-design/references/cli-commands.md +184 -0
- package/src/content/skills/report-design/references/cli-setup.md +101 -0
- package/src/content/skills/report-design/references/close-write-open-pattern.md +80 -0
- package/src/content/skills/report-design/references/layouts/finance.md +65 -0
- package/src/content/skills/report-design/references/layouts/generic.md +46 -0
- package/src/content/skills/report-design/references/layouts/hr.md +48 -0
- package/src/content/skills/report-design/references/layouts/marketing.md +45 -0
- package/src/content/skills/report-design/references/layouts/operations.md +44 -0
- package/src/content/skills/report-design/references/layouts/sales.md +50 -0
- package/src/content/skills/report-design/references/native-visuals.md +341 -0
- package/src/content/skills/report-design/references/pbi-desktop-installation.md +87 -0
- package/src/content/skills/report-design/references/pbir-preview-activation.md +40 -0
- package/src/content/skills/report-design/references/slicer.md +89 -0
- package/src/content/skills/report-design/references/textbox.md +101 -0
- package/src/content/skills/report-design/references/themes/BISuperpowers.json +915 -0
- package/src/content/skills/report-design/references/troubleshooting.md +135 -0
- package/src/content/skills/report-design/references/visual-types.md +78 -0
- package/src/content/skills/report-design/scripts/apply-theme.js +243 -0
- package/src/content/skills/report-design/scripts/create-visual.js +878 -0
- package/src/content/skills/report-design/scripts/ensure-pbi-cli.sh +41 -0
- package/src/content/skills/report-design/scripts/validate-pbir.js +322 -0
- package/bin/commands/install.test.js +0 -289
- package/bin/commands/lint.test.js +0 -103
- package/bin/lib/generators/claude-plugin.test.js +0 -111
- package/bin/lib/mcp-config.test.js +0 -310
- package/bin/lib/microsoft-mcp.test.js +0 -115
- package/bin/utils/mcp-detect.test.js +0 -81
- package/bin/utils/tui.test.js +0 -127
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# ensure-pbi-cli.sh — idempotent installer for pbi-cli-tool + pywin32
|
|
3
|
+
# Run from any directory. Requires Python 3.10+ and pip on PATH.
|
|
4
|
+
# Exit code 0 = ready, 1 = install failed
|
|
5
|
+
|
|
6
|
+
set -e
|
|
7
|
+
|
|
8
|
+
check_python() {
|
|
9
|
+
python --version 2>/dev/null | grep -qE "Python 3\.(1[0-9]|[2-9][0-9])" && return 0
|
|
10
|
+
python3 --version 2>/dev/null | grep -qE "Python 3\.(1[0-9]|[2-9][0-9])" && return 0
|
|
11
|
+
echo "❌ Python 3.10+ required. Install from Microsoft Store or python.org."
|
|
12
|
+
exit 1
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
check_pipx() {
|
|
16
|
+
pipx --version 2>/dev/null && return 0
|
|
17
|
+
python -m pipx --version 2>/dev/null && return 0
|
|
18
|
+
echo "📦 Installing pipx..."
|
|
19
|
+
pip install --user pipx 2>/dev/null || python -m pip install --user pipx
|
|
20
|
+
python -m pipx ensurepath
|
|
21
|
+
echo "⚠ Close and reopen your terminal, then re-run this script."
|
|
22
|
+
exit 1
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
check_pbi() {
|
|
26
|
+
pbi --version 2>/dev/null && return 0
|
|
27
|
+
echo "📦 Installing pbi-cli-tool..."
|
|
28
|
+
python -m pipx install pbi-cli-tool
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
inject_pywin32() {
|
|
32
|
+
python -m pipx inject pbi-cli-tool pywin32 2>/dev/null || true
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
echo "Checking pbi-cli-tool environment..."
|
|
36
|
+
check_python
|
|
37
|
+
check_pipx
|
|
38
|
+
check_pbi
|
|
39
|
+
inject_pywin32
|
|
40
|
+
echo "✅ pbi-cli-tool is ready."
|
|
41
|
+
pbi --version
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* validate-pbir.js — allowlist-driven PBIR validator.
|
|
4
|
+
*
|
|
5
|
+
* Complements `pbi report validate` (which does schema sanity) by enforcing
|
|
6
|
+
* the native-visuals allowlist maintained in create-visual.js. Catches:
|
|
7
|
+
*
|
|
8
|
+
* - visualType that's not a native PBIR type (e.g. `stackedBarChart`,
|
|
9
|
+
* typos) — Desktop renders these as "objeto visual personalizado" but
|
|
10
|
+
* the CLI validate passes them. This is the exact bug F1 that motivated
|
|
11
|
+
* the migration away from the CLI.
|
|
12
|
+
*
|
|
13
|
+
* - bindings on roles that the type doesn't declare (e.g. `--bind size`
|
|
14
|
+
* on a funnelChart) — CLI validate doesn't catch these either.
|
|
15
|
+
*
|
|
16
|
+
* - missing required roles (e.g. a barChart with no Category).
|
|
17
|
+
*
|
|
18
|
+
* - visualContainer schema drift (wrong $schema URL or missing `name`).
|
|
19
|
+
*
|
|
20
|
+
* Usage:
|
|
21
|
+
* node validate-pbir.js <reportPath>
|
|
22
|
+
* node validate-pbir.js --json <reportPath> # machine-readable output
|
|
23
|
+
*
|
|
24
|
+
* Exit codes:
|
|
25
|
+
* 0 valid
|
|
26
|
+
* 1 validation errors found
|
|
27
|
+
* 2 I/O error (bad path, corrupt JSON)
|
|
28
|
+
*
|
|
29
|
+
* NOT a replacement for `pbi report validate` — run both. This validator
|
|
30
|
+
* trusts that the .Report folder is structurally correct (has definition/,
|
|
31
|
+
* pages/, etc.) and focuses on visual-level correctness that the CLI misses.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
'use strict';
|
|
35
|
+
|
|
36
|
+
const fs = require('fs');
|
|
37
|
+
const path = require('path');
|
|
38
|
+
|
|
39
|
+
const { NATIVE_VISUAL_TYPES, KNOWN_NON_NATIVE_TYPES } = require('./create-visual.js');
|
|
40
|
+
|
|
41
|
+
const EXPECTED_VISUAL_SCHEMA =
|
|
42
|
+
'https://developer.microsoft.com/json-schemas/fabric/item/report/definition/visualContainer/2.7.0/schema.json';
|
|
43
|
+
|
|
44
|
+
function fail(msg, code = 2) {
|
|
45
|
+
process.stderr.write(`validate-pbir: ${msg}\n`);
|
|
46
|
+
process.exit(code);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function parseArgs(argv) {
|
|
50
|
+
const out = {};
|
|
51
|
+
const positional = [];
|
|
52
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
53
|
+
const a = argv[i];
|
|
54
|
+
if (a === '--json') out.json = true;
|
|
55
|
+
else if (a === '-h' || a === '--help') out.help = true;
|
|
56
|
+
else if (a.startsWith('-')) fail(`unknown flag: ${a}`, 2);
|
|
57
|
+
else positional.push(a);
|
|
58
|
+
}
|
|
59
|
+
out.reportPath = positional[0];
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function printHelp() {
|
|
64
|
+
process.stdout.write(
|
|
65
|
+
[
|
|
66
|
+
'Usage: validate-pbir.js [--json] <reportPath>',
|
|
67
|
+
'',
|
|
68
|
+
'Arguments:',
|
|
69
|
+
' reportPath Path to a .Report folder',
|
|
70
|
+
'',
|
|
71
|
+
'Options:',
|
|
72
|
+
' --json Emit JSON instead of human-readable output',
|
|
73
|
+
' -h, --help Show this help',
|
|
74
|
+
'',
|
|
75
|
+
'Exit codes:',
|
|
76
|
+
' 0 valid',
|
|
77
|
+
' 1 validation errors found',
|
|
78
|
+
' 2 I/O error',
|
|
79
|
+
'',
|
|
80
|
+
].join('\n')
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function listPages(reportPath) {
|
|
85
|
+
const pagesDir = path.join(reportPath, 'definition', 'pages');
|
|
86
|
+
if (!fs.existsSync(pagesDir)) {
|
|
87
|
+
fail(`pages directory not found: ${pagesDir}`, 2);
|
|
88
|
+
}
|
|
89
|
+
const out = [];
|
|
90
|
+
for (const entry of fs.readdirSync(pagesDir, { withFileTypes: true })) {
|
|
91
|
+
if (!entry.isDirectory()) continue;
|
|
92
|
+
const pageJsonPath = path.join(pagesDir, entry.name, 'page.json');
|
|
93
|
+
if (!fs.existsSync(pageJsonPath)) continue;
|
|
94
|
+
out.push({ name: entry.name, dir: path.join(pagesDir, entry.name) });
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function listVisuals(pageDir) {
|
|
100
|
+
const visualsDir = path.join(pageDir, 'visuals');
|
|
101
|
+
if (!fs.existsSync(visualsDir)) return [];
|
|
102
|
+
const out = [];
|
|
103
|
+
for (const entry of fs.readdirSync(visualsDir, { withFileTypes: true })) {
|
|
104
|
+
if (!entry.isDirectory()) continue;
|
|
105
|
+
const visualJsonPath = path.join(visualsDir, entry.name, 'visual.json');
|
|
106
|
+
if (!fs.existsSync(visualJsonPath)) continue;
|
|
107
|
+
out.push({ name: entry.name, path: visualJsonPath });
|
|
108
|
+
}
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function readJsonSafe(filePath) {
|
|
113
|
+
try {
|
|
114
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
115
|
+
return { data: JSON.parse(raw), err: null };
|
|
116
|
+
} catch (err) {
|
|
117
|
+
return { data: null, err };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Extracts the set of roles that have at least one projection bound.
|
|
122
|
+
function boundRoles(visualData) {
|
|
123
|
+
const qs = visualData && visualData.visual && visualData.visual.query && visualData.visual.query.queryState;
|
|
124
|
+
if (!qs || typeof qs !== 'object') return new Set();
|
|
125
|
+
const bound = new Set();
|
|
126
|
+
for (const [role, spec] of Object.entries(qs)) {
|
|
127
|
+
const projections = spec && spec.projections;
|
|
128
|
+
if (Array.isArray(projections) && projections.length > 0) bound.add(role);
|
|
129
|
+
}
|
|
130
|
+
return bound;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function validateVisual(visual) {
|
|
134
|
+
const errors = [];
|
|
135
|
+
const { data, path: filePath } = visual;
|
|
136
|
+
|
|
137
|
+
// Schema URL check.
|
|
138
|
+
if (data.$schema !== EXPECTED_VISUAL_SCHEMA) {
|
|
139
|
+
errors.push({
|
|
140
|
+
severity: 'warn',
|
|
141
|
+
rule: 'schema-url',
|
|
142
|
+
message: `unexpected $schema (got ${data.$schema || 'none'})`,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// `name` must exist and match the folder.
|
|
147
|
+
if (typeof data.name !== 'string' || !data.name) {
|
|
148
|
+
errors.push({ severity: 'error', rule: 'name', message: 'missing "name"' });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const vt = data.visual && data.visual.visualType;
|
|
152
|
+
if (!vt) {
|
|
153
|
+
errors.push({ severity: 'error', rule: 'visual-type', message: 'missing visualType' });
|
|
154
|
+
return errors;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Allowlist: known non-native (like stackedBarChart).
|
|
158
|
+
if (KNOWN_NON_NATIVE_TYPES[vt]) {
|
|
159
|
+
errors.push({
|
|
160
|
+
severity: 'error',
|
|
161
|
+
rule: 'non-native-type',
|
|
162
|
+
visualType: vt,
|
|
163
|
+
message: KNOWN_NON_NATIVE_TYPES[vt],
|
|
164
|
+
});
|
|
165
|
+
return errors;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const def = NATIVE_VISUAL_TYPES[vt];
|
|
169
|
+
if (!def) {
|
|
170
|
+
errors.push({
|
|
171
|
+
severity: 'error',
|
|
172
|
+
rule: 'unknown-type',
|
|
173
|
+
visualType: vt,
|
|
174
|
+
message: `visualType "${vt}" is not in the native allowlist. Run create-visual.js --list-types for the canonical list.`,
|
|
175
|
+
});
|
|
176
|
+
return errors;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Check required roles are bound (skip for textbox — no roles).
|
|
180
|
+
if (vt !== 'textbox') {
|
|
181
|
+
const bound = boundRoles(data);
|
|
182
|
+
for (const [role, spec] of Object.entries(def.roles)) {
|
|
183
|
+
if (spec.required && !bound.has(role)) {
|
|
184
|
+
errors.push({
|
|
185
|
+
severity: 'error',
|
|
186
|
+
rule: 'missing-required-role',
|
|
187
|
+
visualType: vt,
|
|
188
|
+
role,
|
|
189
|
+
message: `required role "${role}" has no projections (visualType: ${vt})`,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Check that every bound role is valid for this type.
|
|
195
|
+
for (const role of bound) {
|
|
196
|
+
if (!(role in def.roles)) {
|
|
197
|
+
errors.push({
|
|
198
|
+
severity: 'error',
|
|
199
|
+
rule: 'invalid-role',
|
|
200
|
+
visualType: vt,
|
|
201
|
+
role,
|
|
202
|
+
message: `role "${role}" is not valid for visualType ${vt}. Valid: ${Object.keys(def.roles).join(', ') || '(none)'}`,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return errors;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function main() {
|
|
212
|
+
const args = parseArgs(process.argv.slice(2));
|
|
213
|
+
if (args.help) {
|
|
214
|
+
printHelp();
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
if (!args.reportPath) fail('reportPath is required. Run --help for usage.', 2);
|
|
218
|
+
|
|
219
|
+
const reportPath = path.resolve(args.reportPath);
|
|
220
|
+
if (!fs.existsSync(reportPath)) fail(`not found: ${reportPath}`, 2);
|
|
221
|
+
|
|
222
|
+
const pages = listPages(reportPath);
|
|
223
|
+
const results = {
|
|
224
|
+
reportPath,
|
|
225
|
+
pagesChecked: pages.length,
|
|
226
|
+
visualsChecked: 0,
|
|
227
|
+
errors: [],
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
for (const page of pages) {
|
|
231
|
+
const visuals = listVisuals(page.dir);
|
|
232
|
+
// Track visual.name per page so we can flag duplicates. PBI Desktop
|
|
233
|
+
// does not reject a duplicate-name pair on load, but downstream tooling
|
|
234
|
+
// that references visuals by name (filters, bookmarks, selections) gets
|
|
235
|
+
// ambiguous — validate-pbir treats it as a hard error.
|
|
236
|
+
const namesSeenOnPage = new Map();
|
|
237
|
+
for (const v of visuals) {
|
|
238
|
+
results.visualsChecked += 1;
|
|
239
|
+
const { data, err } = readJsonSafe(v.path);
|
|
240
|
+
if (err) {
|
|
241
|
+
results.errors.push({
|
|
242
|
+
page: page.name,
|
|
243
|
+
visual: v.name,
|
|
244
|
+
severity: 'error',
|
|
245
|
+
rule: 'bad-json',
|
|
246
|
+
message: `could not parse visual.json: ${err.message}`,
|
|
247
|
+
});
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Duplicate-name check (page-scoped).
|
|
252
|
+
const declaredName = data && typeof data.name === 'string' ? data.name : null;
|
|
253
|
+
if (declaredName) {
|
|
254
|
+
if (namesSeenOnPage.has(declaredName)) {
|
|
255
|
+
results.errors.push({
|
|
256
|
+
page: page.name,
|
|
257
|
+
visual: v.name,
|
|
258
|
+
severity: 'error',
|
|
259
|
+
rule: 'duplicate-name',
|
|
260
|
+
message: `visual name "${declaredName}" is also used by "${namesSeenOnPage.get(declaredName)}" on this page`,
|
|
261
|
+
});
|
|
262
|
+
} else {
|
|
263
|
+
namesSeenOnPage.set(declaredName, v.name);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const visualErrors = validateVisual({ data, path: v.path });
|
|
268
|
+
for (const e of visualErrors) {
|
|
269
|
+
results.errors.push({
|
|
270
|
+
page: page.name,
|
|
271
|
+
visual: v.name,
|
|
272
|
+
...e,
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const errorCount = results.errors.filter((e) => e.severity === 'error').length;
|
|
279
|
+
const warnCount = results.errors.filter((e) => e.severity === 'warn').length;
|
|
280
|
+
const valid = errorCount === 0;
|
|
281
|
+
|
|
282
|
+
if (args.json) {
|
|
283
|
+
process.stdout.write(JSON.stringify({ ...results, valid, errorCount, warnCount }, null, 2) + '\n');
|
|
284
|
+
} else {
|
|
285
|
+
const W = 77; // inner width between the | borders
|
|
286
|
+
const padRow = (label, value) => {
|
|
287
|
+
const text = `${label}: ${value}`;
|
|
288
|
+
return `| ${text}${' '.repeat(Math.max(0, W - text.length - 1))}|`;
|
|
289
|
+
};
|
|
290
|
+
const border = `+${'-'.repeat(W)}+`;
|
|
291
|
+
process.stdout.write(border + '\n');
|
|
292
|
+
process.stdout.write(padRow('valid', valid ? 'True' : 'False') + '\n');
|
|
293
|
+
process.stdout.write(padRow('pages_checked', String(results.pagesChecked)) + '\n');
|
|
294
|
+
process.stdout.write(padRow('visuals_checked', String(results.visualsChecked)) + '\n');
|
|
295
|
+
process.stdout.write(padRow('errors', String(errorCount)) + '\n');
|
|
296
|
+
process.stdout.write(padRow('warnings', String(warnCount)) + '\n');
|
|
297
|
+
process.stdout.write(border + '\n');
|
|
298
|
+
if (results.errors.length) {
|
|
299
|
+
process.stdout.write('\nIssues:\n');
|
|
300
|
+
for (const e of results.errors) {
|
|
301
|
+
const icon = e.severity === 'error' ? '✗' : '⚠';
|
|
302
|
+
const loc = `${e.page}/${e.visual}`;
|
|
303
|
+
process.stdout.write(` ${icon} [${e.rule}] ${loc}: ${e.message}\n`);
|
|
304
|
+
}
|
|
305
|
+
process.stdout.write('\n');
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
process.exit(valid ? 0 : 1);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
module.exports = {
|
|
313
|
+
validateVisual,
|
|
314
|
+
boundRoles,
|
|
315
|
+
listPages,
|
|
316
|
+
listVisuals,
|
|
317
|
+
parseArgs,
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
if (require.main === module) {
|
|
321
|
+
main();
|
|
322
|
+
}
|
|
@@ -1,289 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Unit tests for the install command
|
|
3
|
-
*
|
|
4
|
-
* Run with: npm test
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
const { test, describe, beforeEach, afterEach } = require('node:test');
|
|
8
|
-
const assert = require('node:assert');
|
|
9
|
-
const fs = require('fs');
|
|
10
|
-
const path = require('path');
|
|
11
|
-
const os = require('os');
|
|
12
|
-
|
|
13
|
-
const installCommand = require('./install');
|
|
14
|
-
const { parseArgs, detectAgents, copySkillDir, formatFsError, AGENTS, UNIVERSAL_DIR } =
|
|
15
|
-
installCommand;
|
|
16
|
-
|
|
17
|
-
describe('install command - parseArgs', () => {
|
|
18
|
-
test('parses --yes/-y flag', () => {
|
|
19
|
-
assert.strictEqual(parseArgs(['--yes']).isYes, true);
|
|
20
|
-
assert.strictEqual(parseArgs(['-y']).isYes, true);
|
|
21
|
-
assert.strictEqual(parseArgs([]).isYes, false);
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
test('parses --all flag', () => {
|
|
25
|
-
assert.strictEqual(parseArgs(['--all']).isAll, true);
|
|
26
|
-
assert.strictEqual(parseArgs([]).isAll, false);
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
test('parses multiple --agent flags', () => {
|
|
30
|
-
const opts = parseArgs(['--agent', 'claude-code', '--agent', 'codex']);
|
|
31
|
-
assert.deepStrictEqual(opts.agentFlags, ['claude-code', 'codex']);
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
test('parses short -a flag', () => {
|
|
35
|
-
const opts = parseArgs(['-a', 'claude-code', '-a', 'kilo']);
|
|
36
|
-
assert.deepStrictEqual(opts.agentFlags, ['claude-code', 'kilo']);
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
test('ignores --agent without value (end of args)', () => {
|
|
40
|
-
const opts = parseArgs(['--agent']);
|
|
41
|
-
assert.deepStrictEqual(opts.agentFlags, []);
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
test('ignores --agent when next arg is another flag', () => {
|
|
45
|
-
const opts = parseArgs(['--agent', '--yes']);
|
|
46
|
-
assert.deepStrictEqual(opts.agentFlags, []);
|
|
47
|
-
assert.strictEqual(opts.isYes, true);
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
test('combines all flags correctly', () => {
|
|
51
|
-
const opts = parseArgs(['--all', '--yes', '-a', 'claude-code']);
|
|
52
|
-
assert.strictEqual(opts.isAll, true);
|
|
53
|
-
assert.strictEqual(opts.isYes, true);
|
|
54
|
-
assert.deepStrictEqual(opts.agentFlags, ['claude-code']);
|
|
55
|
-
});
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
describe('install command - AGENTS registry', () => {
|
|
59
|
-
test('has exactly 5 supported agents', () => {
|
|
60
|
-
assert.strictEqual(Object.keys(AGENTS).length, 5);
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
test('includes the 5 officially supported agents', () => {
|
|
64
|
-
const ids = Object.keys(AGENTS);
|
|
65
|
-
assert.ok(ids.includes('github-copilot'));
|
|
66
|
-
assert.ok(ids.includes('claude-code'));
|
|
67
|
-
assert.ok(ids.includes('codex'));
|
|
68
|
-
assert.ok(ids.includes('gemini-cli'));
|
|
69
|
-
assert.ok(ids.includes('kilo'));
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
test('every agent has name and dir fields', () => {
|
|
73
|
-
for (const [id, agent] of Object.entries(AGENTS)) {
|
|
74
|
-
assert.ok(agent.name, `${id} missing name`);
|
|
75
|
-
assert.ok(agent.dir, `${id} missing dir`);
|
|
76
|
-
assert.ok(agent.dir.startsWith('.'), `${id} dir should start with .`);
|
|
77
|
-
}
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
test('UNIVERSAL_DIR is .agents/skills', () => {
|
|
81
|
-
assert.strictEqual(UNIVERSAL_DIR, '.agents/skills');
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
test('codex uses UNIVERSAL_DIR', () => {
|
|
85
|
-
assert.strictEqual(AGENTS.codex.dir, UNIVERSAL_DIR);
|
|
86
|
-
});
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
describe('install command - detectAgents', () => {
|
|
90
|
-
let tempDir;
|
|
91
|
-
|
|
92
|
-
beforeEach(() => {
|
|
93
|
-
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bi-install-test-'));
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
afterEach(() => {
|
|
97
|
-
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
test('returns empty array when no agents are installed', () => {
|
|
101
|
-
const detected = detectAgents(tempDir);
|
|
102
|
-
assert.deepStrictEqual(detected, []);
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
test('detects claude-code when .claude exists', () => {
|
|
106
|
-
fs.mkdirSync(path.join(tempDir, '.claude'), { recursive: true });
|
|
107
|
-
const detected = detectAgents(tempDir);
|
|
108
|
-
assert.ok(detected.includes('claude-code'));
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
test('detects multiple agents', () => {
|
|
112
|
-
fs.mkdirSync(path.join(tempDir, '.claude'), { recursive: true });
|
|
113
|
-
fs.mkdirSync(path.join(tempDir, '.copilot'), { recursive: true });
|
|
114
|
-
fs.mkdirSync(path.join(tempDir, '.gemini'), { recursive: true });
|
|
115
|
-
const detected = detectAgents(tempDir);
|
|
116
|
-
assert.ok(detected.includes('claude-code'));
|
|
117
|
-
assert.ok(detected.includes('github-copilot'));
|
|
118
|
-
assert.ok(detected.includes('gemini-cli'));
|
|
119
|
-
});
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
describe('install command - copySkillDir', () => {
|
|
123
|
-
let srcDir;
|
|
124
|
-
let destDir;
|
|
125
|
-
|
|
126
|
-
beforeEach(() => {
|
|
127
|
-
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bi-install-copy-'));
|
|
128
|
-
srcDir = path.join(tempDir, 'src');
|
|
129
|
-
destDir = path.join(tempDir, 'dest');
|
|
130
|
-
fs.mkdirSync(srcDir, { recursive: true });
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
afterEach(() => {
|
|
134
|
-
fs.rmSync(path.dirname(srcDir), { recursive: true, force: true });
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
test('copies a single file', () => {
|
|
138
|
-
fs.writeFileSync(path.join(srcDir, 'SKILL.md'), '# Test');
|
|
139
|
-
copySkillDir(srcDir, destDir);
|
|
140
|
-
assert.strictEqual(fs.readFileSync(path.join(destDir, 'SKILL.md'), 'utf8'), '# Test');
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
test('creates destination directory if missing', () => {
|
|
144
|
-
fs.writeFileSync(path.join(srcDir, 'SKILL.md'), 'content');
|
|
145
|
-
copySkillDir(srcDir, destDir);
|
|
146
|
-
assert.ok(fs.existsSync(destDir));
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
test('copies nested directories recursively', () => {
|
|
150
|
-
fs.writeFileSync(path.join(srcDir, 'SKILL.md'), 'top');
|
|
151
|
-
fs.mkdirSync(path.join(srcDir, 'references'));
|
|
152
|
-
fs.writeFileSync(path.join(srcDir, 'references', 'notes.md'), 'nested');
|
|
153
|
-
fs.mkdirSync(path.join(srcDir, 'scripts'));
|
|
154
|
-
fs.writeFileSync(path.join(srcDir, 'scripts', 'run.sh'), '#!/bin/sh');
|
|
155
|
-
|
|
156
|
-
copySkillDir(srcDir, destDir);
|
|
157
|
-
|
|
158
|
-
assert.strictEqual(fs.readFileSync(path.join(destDir, 'SKILL.md'), 'utf8'), 'top');
|
|
159
|
-
assert.strictEqual(
|
|
160
|
-
fs.readFileSync(path.join(destDir, 'references', 'notes.md'), 'utf8'),
|
|
161
|
-
'nested'
|
|
162
|
-
);
|
|
163
|
-
assert.strictEqual(
|
|
164
|
-
fs.readFileSync(path.join(destDir, 'scripts', 'run.sh'), 'utf8'),
|
|
165
|
-
'#!/bin/sh'
|
|
166
|
-
);
|
|
167
|
-
});
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
describe('install command - formatFsError', () => {
|
|
171
|
-
test('includes the context and error message', () => {
|
|
172
|
-
const err = new Error('oops');
|
|
173
|
-
err.code = 'UNKNOWN';
|
|
174
|
-
const msg = formatFsError(err, 'Failed');
|
|
175
|
-
assert.ok(msg.includes('Failed'));
|
|
176
|
-
assert.ok(msg.includes('oops'));
|
|
177
|
-
});
|
|
178
|
-
|
|
179
|
-
test('adds hint for EACCES', () => {
|
|
180
|
-
const err = new Error('permission denied');
|
|
181
|
-
err.code = 'EACCES';
|
|
182
|
-
const msg = formatFsError(err, 'Failed');
|
|
183
|
-
assert.ok(msg.toLowerCase().includes('permiso'));
|
|
184
|
-
});
|
|
185
|
-
|
|
186
|
-
test('adds hint for EPERM (Windows admin)', () => {
|
|
187
|
-
const err = new Error('not permitted');
|
|
188
|
-
err.code = 'EPERM';
|
|
189
|
-
const msg = formatFsError(err, 'Failed');
|
|
190
|
-
assert.ok(msg.toLowerCase().includes('admin'));
|
|
191
|
-
});
|
|
192
|
-
|
|
193
|
-
test('adds hint for ENOSPC', () => {
|
|
194
|
-
const err = new Error('no space');
|
|
195
|
-
err.code = 'ENOSPC';
|
|
196
|
-
const msg = formatFsError(err, 'Failed');
|
|
197
|
-
assert.ok(msg.toLowerCase().includes('espacio'));
|
|
198
|
-
});
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
describe('install command - module exports', () => {
|
|
202
|
-
test('module default export is a function', () => {
|
|
203
|
-
assert.strictEqual(typeof installCommand, 'function');
|
|
204
|
-
});
|
|
205
|
-
|
|
206
|
-
test('exposes internal helpers for testing', () => {
|
|
207
|
-
assert.strictEqual(typeof installCommand.parseArgs, 'function');
|
|
208
|
-
assert.strictEqual(typeof installCommand.detectAgents, 'function');
|
|
209
|
-
assert.strictEqual(typeof installCommand.copySkillDir, 'function');
|
|
210
|
-
assert.strictEqual(typeof installCommand.formatFsError, 'function');
|
|
211
|
-
});
|
|
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, '.kilo', '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
|
-
});
|