@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.
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 +53 -36
  6. package/CHANGELOG.md +310 -0
  7. package/README.md +77 -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 +403 -0
  16. package/bin/lib/generators/claude-plugin.js +162 -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 +197 -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 +199 -0
  29. package/skills/bi-start/scripts/update-check.js +403 -0
  30. package/skills/pbi-connect/SKILL.md +45 -67
  31. package/skills/pbi-connect/scripts/update-check.js +403 -0
  32. package/skills/project-kickoff/SKILL.md +395 -675
  33. package/skills/project-kickoff/scripts/update-check.js +403 -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 +403 -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
@@ -12,6 +12,7 @@
12
12
  const fs = require('fs');
13
13
  const path = require('path');
14
14
  const tui = require('../utils/tui');
15
+ const { readSkillDirectory, normalizeSkillName } = require('../lib/skills');
15
16
 
16
17
  // Lint rules configuration
17
18
  const RULES = {
@@ -57,12 +58,13 @@ const RULES = {
57
58
  maxBytes: 50000, // 50KB
58
59
  },
59
60
 
60
- // Naming convention
61
+ // Naming convention. Folder-based skills follow Anthropic's convention
62
+ // of `<folder>/SKILL.md` (uppercase), so allow that literal too.
61
63
  naming: {
62
64
  enabled: true,
63
65
  severity: 'error',
64
- pattern: /^[a-z0-9-]+\.md$/,
65
- description: 'Skill files should be lowercase-kebab-case.md',
66
+ pattern: /^([a-z0-9-]+\.md|SKILL\.md)$/,
67
+ description: 'Skill files should be lowercase-kebab-case.md, or SKILL.md inside a skill folder',
66
68
  },
67
69
  };
68
70
 
@@ -231,9 +233,16 @@ function addIssue(result, rule, severity, message, line) {
231
233
  * @returns {string|null} Section content or null
232
234
  */
233
235
  function extractSection(content, sectionName) {
234
- const pattern = new RegExp(`^##\\s+${sectionName}[\\s\\S]*?(?=^##|------|$)`, 'mi');
235
- const match = content.match(pattern);
236
- return match ? match[0] : null;
236
+ // Locate the section heading first, then slice from there to the next
237
+ // H2 heading or horizontal rule. The earlier regex used a multiline
238
+ // `$` as a stop anchor, which matches end of LINE — so it returned
239
+ // only the heading line and dropped the body, breaking downstream
240
+ // rules like triggerFormat that inspect the body.
241
+ const headingMatch = new RegExp(`^##\\s+${sectionName}\\b`, 'mi').exec(content);
242
+ if (!headingMatch) return null;
243
+ const tail = content.slice(headingMatch.index);
244
+ const next = tail.search(/\n##\s+|\n---+\s*\n/);
245
+ return next === -1 ? tail : tail.slice(0, next);
237
246
  }
238
247
 
239
248
  /**
@@ -354,36 +363,32 @@ function lintCommand(args, config) {
354
363
 
355
364
  tui.header('BI Agent Superpowers', 'Skill Linter');
356
365
 
357
- // Determine which files to lint
366
+ // Determine which files to lint. Use the shared skill loader so we
367
+ // catch both flat (`<name>.md`) and folder-based (`<name>/SKILL.md`)
368
+ // skills — a previous version filtered with `f.endsWith('.md')` and
369
+ // silently skipped every folder-based skill (e.g. `report-design`).
370
+ const allSkills = readSkillDirectory(skillsDir);
371
+ const skillsByName = new Map(allSkills.map((s) => [s.name, s]));
358
372
  let filesToLint = [];
359
373
 
360
374
  if (options.files.length > 0) {
361
- // Lint specific files
362
375
  filesToLint = options.files
363
376
  .map((f) => {
364
- // Support both full path and just filename
365
- if (fs.existsSync(f)) {
377
+ // Existing absolute / relative file path the user passed verbatim.
378
+ if (fs.existsSync(f) && fs.statSync(f).isFile()) {
366
379
  return f;
367
380
  }
368
- const fullPath = path.join(skillsDir, f);
369
- if (fs.existsSync(fullPath)) {
370
- return fullPath;
371
- }
372
- // Try adding .md extension
373
- const withExt = path.join(skillsDir, f.endsWith('.md') ? f : `${f}.md`);
374
- if (fs.existsSync(withExt)) {
375
- return withExt;
376
- }
377
- tui.warning(`File not found: ${f}`);
381
+ // Look up by skill name. normalizeSkillName accepts `dax`,
382
+ // `dax.md`, `report-design`, or `report-design/SKILL.md`.
383
+ const skill = skillsByName.get(normalizeSkillName(f));
384
+ if (skill) return skill.path;
385
+
386
+ tui.warning(`Skill not found: ${f}`);
378
387
  return null;
379
388
  })
380
389
  .filter(Boolean);
381
390
  } else {
382
- // Lint all skills
383
- filesToLint = fs
384
- .readdirSync(skillsDir)
385
- .filter((f) => f.endsWith('.md'))
386
- .map((f) => path.join(skillsDir, f));
391
+ filesToLint = allSkills.map((s) => s.path);
387
392
  }
388
393
 
389
394
  if (filesToLint.length === 0) {
@@ -418,4 +423,13 @@ function lintCommand(args, config) {
418
423
  }
419
424
  }
420
425
 
421
- module.exports = lintCommand;
426
+ // Expose primitives for tests (B3 — replace fake tests with real ones).
427
+ module.exports = Object.assign(lintCommand, {
428
+ lintFile,
429
+ parseArgs,
430
+ extractSection,
431
+ findSectionLine,
432
+ findLineWithText,
433
+ addIssue,
434
+ RULES,
435
+ });
@@ -69,13 +69,7 @@ function parseArgs(args) {
69
69
  options.tool = args[++index];
70
70
  } else if (arg === '--dry-run') {
71
71
  options.dryRun = true;
72
- } else if (
73
- arg === '--port' ||
74
- arg === '-p' ||
75
- arg === '--github' ||
76
- arg === '-g' ||
77
- arg === '--fabric'
78
- ) {
72
+ } else if (arg === '--port' || arg === '-p' || arg === '--github' || arg === '-g') {
79
73
  options.deprecatedFlags.push(arg);
80
74
  if (args[index + 1] && !args[index + 1].startsWith('-')) {
81
75
  index++;
@@ -197,8 +191,7 @@ function mcpSetupCommand(args, config) {
197
191
  tui.info(mcpDetect.getModelingMcpError());
198
192
  }
199
193
 
200
- tui.success(`Power BI Remote MCP: ${status.remote.url}`);
201
- tui.success(`Fabric MCP package: ${status.fabric.package}`);
194
+ tui.success('Microsoft Learn MCP: https://learn.microsoft.com/api/mcp');
202
195
 
203
196
  const results = {
204
197
  success: [],
@@ -228,7 +221,7 @@ function mcpSetupCommand(args, config) {
228
221
  console.log('');
229
222
  tui.section('Next Steps');
230
223
  tui.listItem(
231
- 'Install the official Microsoft Power BI Modeling MCP extension in VS Code/Cursor on Windows if you need write access to Desktop/Fabric/PBIP models.'
224
+ 'Install the official Microsoft Power BI Modeling MCP extension in VS Code/Cursor on Windows if you need write access to Desktop/PBIP semantic models.'
232
225
  );
233
226
  tui.listItem('Refresh or restart your MCP client after config changes.');
234
227
 
@@ -0,0 +1,403 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * update-check — cross-agent version-check helper for bi-superpowers.
4
+ *
5
+ * The SKILL.md preamble (see lib/generators/claude-plugin.js) invokes this
6
+ * script at the start of every skill so the agent can surface an update
7
+ * notice to the user when a newer version is on npm — without hitting the
8
+ * network on every invocation. Cache TTL is 24h; repeated calls inside
9
+ * that window are served from `~/.bi-superpowers/update-state.json`.
10
+ *
11
+ * Output (stdout, one line):
12
+ * UPTODATE when installed >= latest
13
+ * UPDATE_AVAILABLE <installed> <latest> when installed < latest
14
+ * SNOOZED <iso> when user deferred the notice
15
+ *
16
+ * Flags:
17
+ * --force bypass cache (re-fetch npm, ignore snooze TTL)
18
+ * --silent-if-uptodate suppress UPTODATE line (used by the preamble)
19
+ * --silent-if-snoozed suppress SNOOZED line (used by the preamble)
20
+ * --json emit JSON instead of text
21
+ * --snooze 24h|48h|7d|clear set (or clear) the snooze state and exit
22
+ * --reset delete the state file and exit (used post-upgrade)
23
+ * --state-dir <path> override ~/.bi-superpowers/ (for tests)
24
+ * --package-name <name> override the package name (for tests)
25
+ * -h, --help show this help
26
+ *
27
+ * Exit code is always 0 when the script itself ran — errors during the
28
+ * network fetch degrade to "no output" so the caller never blocks. A
29
+ * non-zero exit means a user error (bad flags).
30
+ *
31
+ * Pure helpers (compareVersions, isCacheFresh, isSnoozed,
32
+ * computeNextSnoozeUntil, readState, writeState, fetchLatestVersion) are
33
+ * exported so unit tests can exercise them without spawning child
34
+ * processes or hitting the network.
35
+ */
36
+
37
+ 'use strict';
38
+
39
+ const fs = require('fs');
40
+ const os = require('os');
41
+ const path = require('path');
42
+ const https = require('https');
43
+
44
+ const PACKAGE_NAME = '@luquimbo/bi-superpowers';
45
+ const CACHE_TTL_MS = 1000 * 60 * 60 * 24; // 24 hours
46
+ const HTTPS_TIMEOUT_MS = 5000;
47
+ // Rewritten at generation time when this helper is copied into
48
+ // `skills/<name>/scripts/update-check.js`. In the canonical source under
49
+ // `bin/commands/`, it stays null and we fall back to package.json.
50
+ const BUNDLED_INSTALLED_VERSION = null;
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Argument parsing
54
+ // ---------------------------------------------------------------------------
55
+
56
+ function parseArgs(argv) {
57
+ const out = {
58
+ force: false,
59
+ silentIfUptodate: false,
60
+ silentIfSnoozed: false,
61
+ json: false,
62
+ snooze: null,
63
+ reset: false,
64
+ help: false,
65
+ stateDir: null,
66
+ packageName: null,
67
+ installedVersion: null,
68
+ };
69
+ for (let i = 0; i < argv.length; i += 1) {
70
+ const a = argv[i];
71
+ if (a === '--force') out.force = true;
72
+ else if (a === '--silent-if-uptodate') out.silentIfUptodate = true;
73
+ else if (a === '--silent-if-snoozed') out.silentIfSnoozed = true;
74
+ else if (a === '--json') out.json = true;
75
+ else if (a === '--snooze') out.snooze = argv[++i];
76
+ else if (a === '--reset') out.reset = true;
77
+ else if (a === '--state-dir') out.stateDir = argv[++i];
78
+ else if (a === '--package-name') out.packageName = argv[++i];
79
+ else if (a === '--installed-version') out.installedVersion = argv[++i];
80
+ else if (a === '-h' || a === '--help') out.help = true;
81
+ else {
82
+ process.stderr.write(`update-check: unknown flag: ${a}\n`);
83
+ process.exit(1);
84
+ }
85
+ }
86
+ return out;
87
+ }
88
+
89
+ function help() {
90
+ process.stdout.write(
91
+ [
92
+ 'Usage: update-check [options]',
93
+ '',
94
+ 'Prints one of: UPTODATE, UPDATE_AVAILABLE <installed> <latest>, SNOOZED <iso>.',
95
+ '',
96
+ 'Options:',
97
+ ' --force Bypass cache and snooze TTL',
98
+ ' --silent-if-uptodate Skip the UPTODATE line',
99
+ ' --silent-if-snoozed Skip the SNOOZED line',
100
+ ' --json Emit JSON',
101
+ ' --snooze <dur> Set snooze state (24h|48h|7d) or "clear" to reset snooze',
102
+ ' --reset Delete the state file (used after a successful upgrade)',
103
+ ' --state-dir <path> Override ~/.bi-superpowers/ (tests)',
104
+ ' --package-name <name> Override the package name (tests)',
105
+ ' --installed-version <v> Override the installed version (generated skill bundles)',
106
+ ' -h, --help Show this help',
107
+ '',
108
+ ].join('\n')
109
+ );
110
+ }
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // Version comparison (semver-ish: MAJOR.MINOR.PATCH with optional -prerelease)
114
+ // No deps; handles the shapes @luquimbo/bi-superpowers uses today.
115
+ // ---------------------------------------------------------------------------
116
+
117
+ /**
118
+ * Compare two semver strings.
119
+ * Returns -1 if a < b, 0 if equal, 1 if a > b.
120
+ * Pre-release tags (`-alpha.1`) sort before the release per semver.
121
+ */
122
+ function compareVersions(a, b) {
123
+ const parse = (v) => {
124
+ const [main, pre] = String(v).split('-');
125
+ const parts = main.split('.').map((n) => parseInt(n, 10) || 0);
126
+ while (parts.length < 3) parts.push(0);
127
+ return { parts, pre: pre || null };
128
+ };
129
+ const va = parse(a);
130
+ const vb = parse(b);
131
+ for (let i = 0; i < 3; i += 1) {
132
+ if (va.parts[i] !== vb.parts[i]) return va.parts[i] < vb.parts[i] ? -1 : 1;
133
+ }
134
+ // Main equal — pre-release < release.
135
+ if (va.pre && !vb.pre) return -1;
136
+ if (!va.pre && vb.pre) return 1;
137
+ if (va.pre && vb.pre) {
138
+ if (va.pre < vb.pre) return -1;
139
+ if (va.pre > vb.pre) return 1;
140
+ }
141
+ return 0;
142
+ }
143
+
144
+ // ---------------------------------------------------------------------------
145
+ // Cache + snooze state
146
+ // ---------------------------------------------------------------------------
147
+
148
+ function defaultStateDir() {
149
+ return path.join(os.homedir(), '.bi-superpowers');
150
+ }
151
+
152
+ function stateFilePath(stateDir) {
153
+ return path.join(stateDir, 'update-state.json');
154
+ }
155
+
156
+ function readState(stateDir) {
157
+ const filePath = stateFilePath(stateDir);
158
+ if (!fs.existsSync(filePath)) return null;
159
+ try {
160
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
161
+ } catch (_) {
162
+ // Malformed → treat as no cache.
163
+ return null;
164
+ }
165
+ }
166
+
167
+ function writeState(stateDir, state) {
168
+ fs.mkdirSync(stateDir, { recursive: true });
169
+ fs.writeFileSync(stateFilePath(stateDir), JSON.stringify(state, null, 2) + '\n');
170
+ }
171
+
172
+ function resetState(stateDir) {
173
+ const filePath = stateFilePath(stateDir);
174
+ if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
175
+ }
176
+
177
+ function isCacheFresh(state, now, ttlMs) {
178
+ if (!state || !state.checkedAt) return false;
179
+ const checkedAt = Date.parse(state.checkedAt);
180
+ if (!Number.isFinite(checkedAt)) return false;
181
+ return now - checkedAt < ttlMs;
182
+ }
183
+
184
+ function isSnoozed(state, now) {
185
+ if (!state || !state.snoozeUntil) return false;
186
+ const until = Date.parse(state.snoozeUntil);
187
+ if (!Number.isFinite(until)) return false;
188
+ return until > now;
189
+ }
190
+
191
+ // Snooze escalation: 24h → 48h → 7d (capped).
192
+ function computeNextSnoozeUntil(currentLevel, now) {
193
+ const levels = [
194
+ 1000 * 60 * 60 * 24, // 24h
195
+ 1000 * 60 * 60 * 48, // 48h
196
+ 1000 * 60 * 60 * 24 * 7, // 7d
197
+ ];
198
+ const idx = Math.min(Math.max(currentLevel, 0), levels.length - 1);
199
+ return new Date(now + levels[idx]).toISOString();
200
+ }
201
+
202
+ function parseSnoozeArg(arg, now, currentLevel) {
203
+ if (arg === 'clear') return { clear: true };
204
+ if (arg === '24h') return { until: new Date(now + 1000 * 60 * 60 * 24).toISOString(), level: 0 };
205
+ if (arg === '48h') return { until: new Date(now + 1000 * 60 * 60 * 48).toISOString(), level: 1 };
206
+ if (arg === '7d')
207
+ return { until: new Date(now + 1000 * 60 * 60 * 24 * 7).toISOString(), level: 2 };
208
+ if (arg === 'auto')
209
+ return {
210
+ until: computeNextSnoozeUntil(currentLevel, now),
211
+ level: Math.min(currentLevel + 1, 2),
212
+ };
213
+ throw new Error(`invalid --snooze value: ${arg}. Expected 24h|48h|7d|auto|clear.`);
214
+ }
215
+
216
+ // ---------------------------------------------------------------------------
217
+ // npm registry fetch
218
+ // ---------------------------------------------------------------------------
219
+
220
+ /**
221
+ * Fetch the latest published version of a package from the npm registry.
222
+ * Never rejects with a network error — resolves null on timeout / failure
223
+ * so callers always degrade gracefully.
224
+ *
225
+ * @param {string} packageName - e.g. "@luquimbo/bi-superpowers"
226
+ * @returns {Promise<string|null>}
227
+ */
228
+ function fetchLatestVersion(packageName) {
229
+ return new Promise((resolve) => {
230
+ const encoded = packageName.replace('/', '%2F');
231
+ const url = `https://registry.npmjs.org/${encoded}/latest`;
232
+
233
+ const req = https.get(
234
+ url,
235
+ { headers: { Accept: 'application/vnd.npm.install-v1+json' } },
236
+ (res) => {
237
+ if (res.statusCode !== 200) {
238
+ res.resume();
239
+ resolve(null);
240
+ return;
241
+ }
242
+ let body = '';
243
+ res.setEncoding('utf8');
244
+ res.on('data', (chunk) => (body += chunk));
245
+ res.on('end', () => {
246
+ try {
247
+ const json = JSON.parse(body);
248
+ resolve(typeof json.version === 'string' ? json.version : null);
249
+ } catch (_) {
250
+ resolve(null);
251
+ }
252
+ });
253
+ }
254
+ );
255
+ req.on('error', () => resolve(null));
256
+ req.setTimeout(HTTPS_TIMEOUT_MS, () => {
257
+ req.destroy();
258
+ resolve(null);
259
+ });
260
+ });
261
+ }
262
+
263
+ // ---------------------------------------------------------------------------
264
+ // Installed version — read from our own package.json
265
+ // ---------------------------------------------------------------------------
266
+
267
+ function readInstalledVersion(explicitVersion = null) {
268
+ if (explicitVersion) {
269
+ return String(explicitVersion);
270
+ }
271
+ if (BUNDLED_INSTALLED_VERSION) {
272
+ return String(BUNDLED_INSTALLED_VERSION);
273
+ }
274
+ try {
275
+ return require(path.join(__dirname, '..', '..', 'package.json')).version;
276
+ } catch (_) {
277
+ return null;
278
+ }
279
+ }
280
+
281
+ // ---------------------------------------------------------------------------
282
+ // Emit helpers
283
+ // ---------------------------------------------------------------------------
284
+
285
+ function emit(args, kind, payload) {
286
+ if (args.json) {
287
+ process.stdout.write(JSON.stringify({ status: kind, ...payload }) + '\n');
288
+ return;
289
+ }
290
+ if (kind === 'UPTODATE' && args.silentIfUptodate) return;
291
+ if (kind === 'SNOOZED' && args.silentIfSnoozed) return;
292
+
293
+ if (kind === 'UPTODATE') process.stdout.write('UPTODATE\n');
294
+ else if (kind === 'UPDATE_AVAILABLE')
295
+ process.stdout.write(`UPDATE_AVAILABLE ${payload.installed} ${payload.latest}\n`);
296
+ else if (kind === 'SNOOZED') process.stdout.write(`SNOOZED ${payload.until}\n`);
297
+ }
298
+
299
+ // ---------------------------------------------------------------------------
300
+ // main
301
+ // ---------------------------------------------------------------------------
302
+
303
+ async function main() {
304
+ const args = parseArgs(process.argv.slice(2));
305
+ if (args.help) {
306
+ help();
307
+ return;
308
+ }
309
+
310
+ const stateDir = args.stateDir || defaultStateDir();
311
+ const packageName = args.packageName || PACKAGE_NAME;
312
+
313
+ if (args.reset) {
314
+ resetState(stateDir);
315
+ return;
316
+ }
317
+
318
+ if (args.snooze) {
319
+ const now = Date.now();
320
+ const prior = readState(stateDir) || {};
321
+ const parsed = parseSnoozeArg(args.snooze, now, prior.snoozeLevel || 0);
322
+ if (parsed.clear) {
323
+ writeState(stateDir, { ...prior, snoozeUntil: null, snoozeLevel: 0 });
324
+ } else {
325
+ writeState(stateDir, {
326
+ ...prior,
327
+ snoozeUntil: parsed.until,
328
+ snoozeLevel: parsed.level,
329
+ });
330
+ }
331
+ return;
332
+ }
333
+
334
+ const installed = readInstalledVersion(args.installedVersion);
335
+ if (!installed) {
336
+ // Installed version undetermined — nothing useful to report.
337
+ return;
338
+ }
339
+
340
+ const now = Date.now();
341
+ let state = readState(stateDir);
342
+
343
+ // Snooze short-circuits everything except --force.
344
+ if (!args.force && isSnoozed(state, now)) {
345
+ emit(args, 'SNOOZED', { until: state.snoozeUntil });
346
+ return;
347
+ }
348
+
349
+ // Use cached `latest` when the cache is fresh (unless --force).
350
+ let latest = state && state.latest;
351
+ if (args.force || !isCacheFresh(state, now, CACHE_TTL_MS)) {
352
+ const fetched = await fetchLatestVersion(packageName);
353
+ if (fetched) {
354
+ latest = fetched;
355
+ const nextState = {
356
+ installed,
357
+ latest,
358
+ checkedAt: new Date(now).toISOString(),
359
+ snoozeUntil: (state && state.snoozeUntil) || null,
360
+ snoozeLevel: (state && state.snoozeLevel) || 0,
361
+ };
362
+ writeState(stateDir, nextState);
363
+ state = nextState;
364
+ }
365
+ // If fetched is null (network fail), we keep using the previous cache
366
+ // — or emit nothing if there's no cache at all.
367
+ }
368
+
369
+ if (!latest) {
370
+ // No cached value and no fetch — nothing to say.
371
+ return;
372
+ }
373
+
374
+ if (compareVersions(installed, latest) < 0) {
375
+ emit(args, 'UPDATE_AVAILABLE', { installed, latest });
376
+ } else {
377
+ emit(args, 'UPTODATE', { installed, latest });
378
+ }
379
+ }
380
+
381
+ module.exports = {
382
+ parseArgs,
383
+ compareVersions,
384
+ isCacheFresh,
385
+ isSnoozed,
386
+ computeNextSnoozeUntil,
387
+ parseSnoozeArg,
388
+ readState,
389
+ writeState,
390
+ resetState,
391
+ fetchLatestVersion,
392
+ readInstalledVersion,
393
+ CACHE_TTL_MS,
394
+ PACKAGE_NAME,
395
+ };
396
+
397
+ if (require.main === module) {
398
+ main().catch((err) => {
399
+ // Never throw out of the CLI — the preamble must not break skill invocation.
400
+ process.stderr.write(`update-check: ${err.message}\n`);
401
+ process.exit(0);
402
+ });
403
+ }