@pmoses-s1/s1-secops-mcp 1.3.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.
@@ -0,0 +1,444 @@
1
+ /**
2
+ * UAM Alert Interface client: pushes OCSF indicators and SecurityAlerts
3
+ * INTO Unified Alert Management via the SentinelOne HEC ingest host.
4
+ *
5
+ * This is a SEPARATE API surface from the Mgmt Console:
6
+ * Host : S1_HEC_INGEST_URL (e.g. https://ingest.us1.sentinelone.net)
7
+ * Auth : Authorization: Bearer <jwt> (NOT "ApiToken", endpoint rejects ApiToken)
8
+ * Body : concatenated JSON, gzip-compressed, Content-Encoding: gzip
9
+ * Scope : S1-Scope: <accountId>[:<siteId>[:<groupId>]] (mandatory)
10
+ *
11
+ * Endpoints:
12
+ * POST /v1/indicators : OCSF behavioral indicators (batch: N per call)
13
+ * POST /v1/alerts , OCSF SecurityAlert (ONE per call, see below)
14
+ *
15
+ * Critical constraints (empirically confirmed on your-tenant 2026-04-22):
16
+ * - ONE alert per POST /v1/alerts. Multi-alert bodies return HTTP 202 but the
17
+ * stitcher silently drops all but one. Loop callers for multiple alerts.
18
+ * - Sleep ~3s between POST /v1/indicators and POST /v1/alerts. If the alert
19
+ * lands before the indicator's metadata.uid is registered the stitcher silently
20
+ * drops the alert (still HTTP 202). ingestAlert() enforces the sleep.
21
+ * - file.hashes MUST be OCSF Fingerprint array [{algorithm_id, algorithm, value}],
22
+ * NOT a plain dict. Dict form causes silent drop even on HTTP 202.
23
+ * - finding_info.related_events[] entries MUST carry class_uid, type_uid,
24
+ * category_uid, activity_id, severity_id, time, message, and observables[]
25
+ * each with both type and typeName alongside type_id/name/value.
26
+ */
27
+
28
+ import { gzipSync } from 'zlib';
29
+ import { randomUUID } from 'crypto';
30
+ import { getCreds } from './credentials.js';
31
+
32
+ // ─── helpers ──────────────────────────────────────────────────────────────────
33
+
34
+ function hecBase() {
35
+ const url = (getCreds().S1_HEC_INGEST_URL || '').replace(/\/+$/, '');
36
+ if (!url) {
37
+ throw new Error(
38
+ 'S1_HEC_INGEST_URL not configured. Add it to credentials.json ' +
39
+ '(e.g. "S1_HEC_INGEST_URL": "https://ingest.us1.sentinelone.net"). ' +
40
+ 'Find the correct URL for your region at: ' +
41
+ 'https://community.sentinelone.com/s/article/000004961'
42
+ );
43
+ }
44
+ return url;
45
+ }
46
+
47
+ function bearerJwt() {
48
+ const tok = getCreds().S1_CONSOLE_API_TOKEN;
49
+ if (!tok) throw new Error('S1_CONSOLE_API_TOKEN not configured.');
50
+ return tok;
51
+ }
52
+
53
+ function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
54
+
55
+ /**
56
+ * POST one or more OCSF objects to a HEC ingest endpoint.
57
+ * Body is concatenated JSON (newline-separated), gzip-compressed.
58
+ * Auth is Bearer (not ApiToken).
59
+ */
60
+ async function hecPost(path, payloads, scope, retries = 3) {
61
+ const url = `${hecBase()}${path}`;
62
+ const items = Array.isArray(payloads) ? payloads : [payloads];
63
+ const body = items.map(p => JSON.stringify(p)).join('\n');
64
+ const compressed = gzipSync(Buffer.from(body, 'utf-8'));
65
+
66
+ let delay = 1000;
67
+ let lastErr;
68
+ for (let attempt = 0; attempt <= retries; attempt++) {
69
+ let res;
70
+ try {
71
+ res = await fetch(url, {
72
+ method: 'POST',
73
+ headers: {
74
+ Authorization: `Bearer ${bearerJwt()}`,
75
+ 'Content-Type': 'application/json',
76
+ 'Content-Encoding': 'gzip',
77
+ 'S1-Scope': scope,
78
+ },
79
+ body: compressed,
80
+ });
81
+ } catch (err) {
82
+ lastErr = err;
83
+ if (attempt === retries) throw err;
84
+ await sleep(delay);
85
+ delay = Math.min(delay * 2, 8000);
86
+ continue;
87
+ }
88
+
89
+ // Retry is acceptable here despite POST semantics: UAM alert/indicator
90
+ // payloads carry metadata.uid, which the stitcher dedupes on, so a re-POST
91
+ // after an ambiguous 5xx does not double-ingest. (2026-07-29 review note.)
92
+ if ((res.status === 429 || res.status >= 500) && attempt < retries) {
93
+ // Retry-After may be missing or an HTTP date. Number(null) is 0, so a
94
+ // missing header must fall back to the exponential delay, not sleep 0ms.
95
+ const raRaw = res.headers.get('Retry-After');
96
+ const ra = Number(raRaw);
97
+ await sleep(raRaw && Number.isFinite(ra) && ra >= 0 ? Math.min(ra * 1000, 30000) : delay);
98
+ delay = Math.min(delay * 2, 8000);
99
+ continue;
100
+ }
101
+
102
+ const text = await res.text();
103
+ let data;
104
+ try { data = JSON.parse(text); } catch { data = text; }
105
+
106
+ if (!res.ok) {
107
+ throw new Error(`HEC POST ${path} -> ${res.status}: ${JSON.stringify(data)}`);
108
+ }
109
+ return { status: res.status, body: data };
110
+ }
111
+ throw lastErr;
112
+ }
113
+
114
+ // ─── OCSF payload builders ────────────────────────────────────────────────────
115
+
116
+ /**
117
+ * Build an OCSF FileSystem Activity indicator (class_uid 1001).
118
+ *
119
+ * Shape matches the confirmed-working Python build_file_indicator() in
120
+ * mgmt-console-api/scripts/uam_alert_interface.py (tested
121
+ * on usea1-acme 2026-04-22). Key points:
122
+ * - metadata.version "1.6.0-dev" (not "1.6.0")
123
+ * - metadata.extensions array (not "extension" singular)
124
+ * - metadata.product omits vendor_name (just name)
125
+ * - type_uid set directly on the indicator (class_uid*100 + activity_id)
126
+ * - device carries name + hostname + type_id:1
127
+ * - actor.user carries type:"System" + type_id:3
128
+ * - attack_surface_id:1 (singular, at top level)
129
+ * - severity_id:2 (not 3)
130
+ * - file.hashes MUST be Fingerprint array [{algorithm_id,algorithm,value}]
131
+ *
132
+ * Returns a complete indicator object ready to POST to /v1/indicators.
133
+ */
134
+ export function buildFileIndicator({
135
+ indicatorUid,
136
+ filename = 'test-payload.exe',
137
+ sha256,
138
+ hostname = 'mcp-test-host',
139
+ deviceUid,
140
+ userUid,
141
+ nowMs,
142
+ } = {}) {
143
+ const ts = nowMs || Date.now();
144
+ const iUid = indicatorUid || randomUUID();
145
+ const dUid = deviceUid || randomUUID();
146
+ const uUid = userUid || randomUUID();
147
+ const sha = sha256 || '0'.repeat(64);
148
+ const activityId = 1;
149
+ const classUid = 1001;
150
+
151
+ return {
152
+ message: `File ${filename} action_${activityId}`,
153
+ time: ts,
154
+ device: {
155
+ uid: dUid,
156
+ name: hostname,
157
+ hostname,
158
+ type_id: 1,
159
+ },
160
+ metadata: {
161
+ version: '1.6.0-dev',
162
+ product: { name: 'smoke-product' },
163
+ extensions: [{ name: 's1', uid: '998', version: '0.1.0' }],
164
+ profiles: ['s1/security_indicator'],
165
+ uid: iUid,
166
+ },
167
+ type_uid: classUid * 100 + activityId,
168
+ activity_id: activityId,
169
+ class_uid: classUid,
170
+ category_uid: 1,
171
+ observables: [
172
+ { type_id: 7, type: 'File Name', typeName: 'File Name', name: 'file.name', value: filename },
173
+ { type_id: 1, type: 'Hostname', typeName: 'Hostname', name: 'device.hostname', value: hostname },
174
+ { type_id: 8, type: 'Hash', typeName: 'Hash', name: 'file.hashes.sha256', value: sha },
175
+ ],
176
+ actor: {
177
+ user: {
178
+ name: 'smoke-user',
179
+ type: 'System',
180
+ uid: uUid,
181
+ type_id: 3,
182
+ },
183
+ },
184
+ severity_id: 2,
185
+ attack_surface_id: 1,
186
+ // OCSF Fingerprint array: dict form causes silent stitcher drop even on HTTP 202
187
+ file: {
188
+ name: filename,
189
+ type_id: 1,
190
+ hashes: [{ algorithm_id: 3, algorithm: 'SHA-256', value: sha }],
191
+ },
192
+ };
193
+ }
194
+
195
+ /**
196
+ * Build an OCSF SecurityAlert (class_uid 2002) referencing one indicator.
197
+ *
198
+ * Returns a complete alert object ready to POST to /v1/alerts (one at a time).
199
+ *
200
+ * @param {boolean} [inline=false]
201
+ * false (default): related_events[] contains only the reference fields (uid, class_uid,
202
+ * type_uid, etc.) and observables. The stitcher resolves the full indicator from a
203
+ * prior /v1/indicators POST via metadata.uid. Use with ingestAlert() (two-call flow).
204
+ * true: related_events[] embeds the full indicator context (file, device, actor) inline.
205
+ * No separate /v1/indicators POST is required: everything ships in one /v1/alerts call.
206
+ * Use with ingestAlertInline() (single-call flow).
207
+ */
208
+ export function buildSecurityAlert({
209
+ alertUid,
210
+ indicator,
211
+ title = 'MCP Test Alert',
212
+ description = 'Synthetic test alert created by s1-secops-mcp uam_ingest_alert.',
213
+ detectionProduct = 'smoke-product',
214
+ detectionVendor = 'smoke-vendor',
215
+ inline = false,
216
+ nowMs,
217
+ } = {}) {
218
+ const ts = nowMs || Date.now();
219
+ const uid = indicator.metadata.uid;
220
+
221
+ // related_events[] entry: shape matches Python build_alert_referencing().
222
+ // type_uid comes from the indicator's own type_uid field (set by buildFileIndicator).
223
+ // inline=true embeds file/device/actor so the alert is fully self-contained;
224
+ // inline=false is reference-only and relies on the stitcher resolving metadata.uid.
225
+ const relatedEvent = {
226
+ message: indicator.message || '',
227
+ time: ts,
228
+ uid,
229
+ severity_id: indicator.severity_id || 2,
230
+ observables: (indicator.observables || []).map(o => ({
231
+ ...o,
232
+ typeName: o.typeName || o.type,
233
+ })),
234
+ class_uid: indicator.class_uid,
235
+ type_uid: indicator.type_uid,
236
+ category_uid: indicator.category_uid,
237
+ activity_id: indicator.activity_id || 1,
238
+ ...(inline ? {
239
+ file: indicator.file,
240
+ device: indicator.device,
241
+ actor: indicator.actor,
242
+ } : {}),
243
+ };
244
+
245
+ const aUid = alertUid || randomUUID();
246
+ const dev = indicator.device || {};
247
+
248
+ return {
249
+ finding_info: {
250
+ uid: aUid,
251
+ title,
252
+ desc: description,
253
+ related_events: [relatedEvent],
254
+ },
255
+ // Single resources[] entry keyed on first indicator's device.
256
+ // type_id:1 + type:"host" matches the Python reference implementation.
257
+ resources: [{
258
+ uid: dev.uid || 'unknown',
259
+ name: dev.hostname || dev.name || 'unknown',
260
+ type_id: 1,
261
+ type: 'host',
262
+ }],
263
+ category_uid: 2,
264
+ category_name: 'Findings',
265
+ // S1-specific extension class: NOT the generic OCSF 2002.
266
+ // Using 2002 causes silent drop; 99602001 is what the stitcher expects.
267
+ class_uid: 99602001,
268
+ class_name: 'S1 Security Alert',
269
+ type_uid: 9960200101,
270
+ type_name: 'S1 Security Alert: Create',
271
+ activity_id: 1,
272
+ metadata: {
273
+ version: '1.6.0-dev',
274
+ extension: { name: 's1', uid: '998', version: '0.1.0' },
275
+ product: { name: detectionProduct, vendor_name: detectionVendor },
276
+ logged_time: ts,
277
+ modified_time: ts,
278
+ },
279
+ time: ts,
280
+ attack_surface_ids: [1],
281
+ severity_id: 2,
282
+ state_id: 1,
283
+ s1_classification_id: 1,
284
+ };
285
+ }
286
+
287
+ // ─── High-level end-to-end helpers ────────────────────────────────────────────
288
+
289
+ /**
290
+ * Create a synthetic test alert in UAM end-to-end.
291
+ *
292
+ * Builds an OCSF FileSystem Activity indicator and a SecurityAlert,
293
+ * POSTs them to the HEC ingest host with the required 3s sleep in between,
294
+ * and returns the UIDs and HTTP responses.
295
+ *
296
+ * The alert typically surfaces in UAM within 30-60s. Search by title or
297
+ * poll uam_list_alerts.
298
+ *
299
+ * @param {object} opts
300
+ * @param {string} opts.scope accountId or "accountId:siteId" (mandatory)
301
+ * @param {string} [opts.title] Alert name shown in UAM (default: "MCP Test Alert")
302
+ * @param {string} [opts.description] Alert description
303
+ * @param {string} [opts.hostname] Hostname for the indicator device
304
+ * @param {string} [opts.filename] Filename for the FileSystem indicator
305
+ * @param {string} [opts.sha256] SHA-256 hash (64 hex chars); random if omitted
306
+ * @param {number} [opts.sleepMs=3000] Sleep between indicator POST and alert POST
307
+ */
308
+ export async function ingestAlert({
309
+ scope,
310
+ title = 'MCP Test Alert',
311
+ description = 'Synthetic test alert created by s1-secops-mcp uam_ingest_alert.',
312
+ hostname = 'mcp-test-host',
313
+ filename = 'test-payload.exe',
314
+ sha256,
315
+ sleepMs = 3000,
316
+ } = {}) {
317
+ if (!scope) throw new Error('scope is required (accountId or "accountId:siteId").');
318
+
319
+ const nowMs = Date.now();
320
+ const indicatorUid = randomUUID();
321
+ const alertUid = randomUUID();
322
+
323
+ const indicator = buildFileIndicator({
324
+ indicatorUid,
325
+ filename,
326
+ sha256,
327
+ hostname,
328
+ nowMs,
329
+ });
330
+
331
+ const indicatorResp = await hecPost('/v1/indicators', [indicator], scope);
332
+
333
+ // Wait for the stitcher to register the indicator uid before posting the alert.
334
+ // Reducing below ~2s has been observed to cause silent drops on loaded tenants.
335
+ await sleep(sleepMs);
336
+
337
+ const alert = buildSecurityAlert({
338
+ alertUid,
339
+ indicator,
340
+ title,
341
+ description,
342
+ nowMs,
343
+ });
344
+
345
+ const alertResp = await hecPost('/v1/alerts', alert, scope);
346
+
347
+ return {
348
+ indicator_uid: indicatorUid,
349
+ alert_uid: alertUid,
350
+ indicator_response: indicatorResp,
351
+ alert_response: alertResp,
352
+ next_step: `Allow 30-60s then call uam_list_alerts to find the alert by title "${title}". Use uam_get_alert with the returned ID for full details.`,
353
+ };
354
+ }
355
+
356
+ /**
357
+ * Create a synthetic test alert in UAM in a single /v1/alerts POST.
358
+ *
359
+ * Differs from ingestAlert() in two ways:
360
+ * - No separate /v1/indicators POST (no HEC indicator call at all).
361
+ * - No sleep: the indicator data is embedded inline inside the alert's
362
+ * finding_info.related_events[] entry (file, device, actor fields included),
363
+ * so the stitcher does not need to resolve a uid from a prior indicator POST.
364
+ *
365
+ * Trade-off: the alert's Indicators tab in UAM may show less detail than in the
366
+ * two-call flow (stitcher reconciliation vs inline embedding). Use two-call mode
367
+ * when deep indicator stitching is required; use inline mode for rapid testing or
368
+ * when a single round-trip is preferred.
369
+ */
370
+ export async function ingestAlertInline({
371
+ scope,
372
+ title = 'MCP Test Alert',
373
+ description = 'Synthetic test alert created by s1-secops-mcp uam_ingest_alert (inline mode).',
374
+ hostname = 'mcp-test-host',
375
+ filename = 'test-payload.exe',
376
+ sha256,
377
+ } = {}) {
378
+ if (!scope) throw new Error('scope is required (accountId or "accountId:siteId").');
379
+
380
+ const nowMs = Date.now();
381
+ const indicatorUid = randomUUID();
382
+ const alertUid = randomUUID();
383
+
384
+ const indicator = buildFileIndicator({
385
+ indicatorUid,
386
+ filename,
387
+ sha256,
388
+ hostname,
389
+ nowMs,
390
+ });
391
+
392
+ const alert = buildSecurityAlert({
393
+ alertUid,
394
+ indicator,
395
+ title,
396
+ description,
397
+ inline: true,
398
+ nowMs,
399
+ });
400
+
401
+ const alertResp = await hecPost('/v1/alerts', alert, scope);
402
+
403
+ return {
404
+ indicator_uid: indicatorUid,
405
+ alert_uid: alertUid,
406
+ alert_response: alertResp,
407
+ mode: 'inline',
408
+ next_step: `Allow 30-60s then call uam_list_alerts to find the alert by title "${title}". Use uam_get_alert with the returned ID for full details.`,
409
+ };
410
+ }
411
+
412
+ // ─── Low-level raw-payload helpers ────────────────────────────────────────────
413
+
414
+ /**
415
+ * POST raw OCSF indicators to /v1/indicators.
416
+ * Caller is responsible for correct OCSF shape.
417
+ */
418
+ export async function postIndicators({ scope, indicators }) {
419
+ if (!scope) throw new Error('scope is required.');
420
+ const items = Array.isArray(indicators) ? indicators : [indicators];
421
+ return hecPost('/v1/indicators', items, scope);
422
+ }
423
+
424
+ /**
425
+ * POST a single raw OCSF SecurityAlert to /v1/alerts.
426
+ * ONE alert per call: the stitcher silently drops all but one in multi-alert POSTs.
427
+ */
428
+ export async function postAlert({ scope, alert }) {
429
+ if (!scope) throw new Error('scope is required.');
430
+ if (Array.isArray(alert)) {
431
+ throw new Error(
432
+ 'postAlert() accepts a single alert object, not an array. ' +
433
+ 'The HEC stitcher silently drops all but one alert in multi-alert POSTs. ' +
434
+ 'Loop this call for multiple alerts.'
435
+ );
436
+ }
437
+ return hecPost('/v1/alerts', alert, scope);
438
+ }
439
+
440
+ /** True if HEC ingest credentials are configured. */
441
+ export function hasHecCreds() {
442
+ const c = getCreds();
443
+ return !!(c.S1_HEC_INGEST_URL && c.S1_CONSOLE_API_TOKEN);
444
+ }
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@pmoses-s1/s1-secops-mcp",
3
+ "version": "1.3.0",
4
+ "description": "MCP server orchestrating SentinelOne skills, APIs, and SOC analyst context. Stdio or Streamable HTTP transport with per-user bearer auth for team deployments.",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "bin": {
8
+ "s1-secops-mcp": "index.js"
9
+ },
10
+ "files": [
11
+ "index.js",
12
+ "lib/",
13
+ "tools/",
14
+ "deploy/",
15
+ "scripts/",
16
+ "README.md",
17
+ "CHANGELOG.md"
18
+ ],
19
+ "scripts": {
20
+ "start": "node index.js",
21
+ "start:http": "node index.js --transport http",
22
+ "dev": "node --watch index.js",
23
+ "test": "node --test tests/smoke.test.mjs tests/stdio-transport.test.mjs tests/http-transport.test.mjs tests/ssrf-path.test.mjs tests/http-origin-guard.test.mjs tests/regressions-2026-07-29.test.mjs tests/regressions-2026-07-31.test.mjs",
24
+ "regen:readme": "node scripts/regen-readme-tools-table.mjs"
25
+ },
26
+ "engines": {
27
+ "node": ">=18.0.0"
28
+ },
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "git+https://github.com/pmoses-s1/claude-skills.git",
32
+ "directory": "s1-secops-mcp"
33
+ },
34
+ "homepage": "https://github.com/pmoses-s1/claude-skills/tree/main/s1-secops-mcp#readme",
35
+ "bugs": "https://github.com/pmoses-s1/claude-skills/issues",
36
+ "keywords": [
37
+ "mcp",
38
+ "sentinelone",
39
+ "model-context-protocol",
40
+ "soc",
41
+ "powerquery",
42
+ "sdl",
43
+ "singularity-data-lake",
44
+ "claude"
45
+ ],
46
+ "license": "MIT",
47
+ "publishConfig": {
48
+ "access": "public"
49
+ }
50
+ }
@@ -0,0 +1,142 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Regenerate the "What this exposes" tools table in s1-secops-mcp/README.md
4
+ * directly from the live ALL_TOOLS array in server-core.js.
5
+ *
6
+ * This is the guard against the drift that produced the original
7
+ * 19-vs-21-vs-26 confusion: if the table doesn't match the registered
8
+ * tools, the build fails (when run via `npm run regen:readme -- --check`).
9
+ *
10
+ * Usage:
11
+ * node scripts/regen-readme-tools-table.mjs Rewrite README in place.
12
+ * node scripts/regen-readme-tools-table.mjs --check Exit 1 if table is stale.
13
+ */
14
+
15
+ import { readFileSync, writeFileSync } from 'node:fs';
16
+ import { dirname, resolve } from 'node:path';
17
+ import { fileURLToPath } from 'node:url';
18
+ import { ALL_TOOLS } from '../lib/server-core.js';
19
+
20
+ const __dir = dirname(fileURLToPath(import.meta.url));
21
+ const README = resolve(__dir, '..', 'README.md');
22
+
23
+ // Map each tool to its origin module + originating skill name(s).
24
+ // This is the only hand-maintained mapping; updating it is part of adding a
25
+ // new tool's row to the README.
26
+ const TOOL_SKILL = {
27
+ // PowerQuery
28
+ powerquery_enumerate_sources: 'powerquery',
29
+ powerquery_run: 'powerquery',
30
+ powerquery_schema_discover: 'powerquery',
31
+ // Mgmt Console
32
+ s1_api_get: 'mgmt-console-api',
33
+ s1_api_post: 'mgmt-console-api',
34
+ s1_api_put: 'mgmt-console-api',
35
+ s1_api_delete: 'mgmt-console-api',
36
+ s1_api_patch: 'mgmt-console-api',
37
+ purple_ai_alert_summary: 'mgmt-console-api',
38
+ uam_list_alerts: 'mgmt-console-api',
39
+ uam_get_alert: 'mgmt-console-api',
40
+ uam_add_note: 'mgmt-console-api',
41
+ uam_set_status: 'mgmt-console-api',
42
+ // SDL API
43
+ sdl_list_files: 'sdl-api / sdl-dashboard / sdl-log-parser',
44
+ sdl_get_file: 'sdl-api / sdl-dashboard / sdl-log-parser',
45
+ sdl_put_file: 'sdl-api / sdl-dashboard / sdl-log-parser',
46
+ sdl_delete_file: 'sdl-api',
47
+ hec_ingest: 'sdl-api / sdl-log-parser',
48
+ // Hyperautomation
49
+ ha_list_workflows: 'hyperautomation',
50
+ ha_get_workflow: 'hyperautomation',
51
+ ha_delete_workflow: 'hyperautomation',
52
+ ha_import_workflow: 'hyperautomation',
53
+ ha_export_workflow: 'hyperautomation',
54
+ // UAM Ingest
55
+ uam_ingest_alert: 'mgmt-console-api (UAM Alert Interface)',
56
+ uam_post_indicators: 'mgmt-console-api (UAM Alert Interface)',
57
+ uam_post_alert: 'mgmt-console-api (UAM Alert Interface)',
58
+ };
59
+
60
+ const GROUPS = [
61
+ { label: 'PowerQuery', prefix: 'powerquery_' },
62
+ { label: 'Mgmt Console', test: n => /^(s1_api_|purple_ai_|uam_(list|get|add|set))/.test(n) },
63
+ { label: 'SDL API', test: n => n.startsWith('sdl_') || n === 'hec_ingest' },
64
+ { label: 'Hyperautomation', prefix: 'ha_' },
65
+ { label: 'UAM Ingest', test: n => /^(uam_ingest_|uam_post_)/.test(n) },
66
+ ];
67
+
68
+ function groupOf(name) {
69
+ for (const g of GROUPS) {
70
+ if (g.prefix && name.startsWith(g.prefix)) return g.label;
71
+ if (g.test && g.test(name)) return g.label;
72
+ }
73
+ return '???';
74
+ }
75
+
76
+ // Build the new table block. The leading "**N tools**" header is regenerated
77
+ // too so the count and the table can't drift apart.
78
+ function buildTable() {
79
+ const sorted = [...ALL_TOOLS]
80
+ .map(t => t.name)
81
+ .sort((a, b) => {
82
+ const ga = GROUPS.findIndex(g => groupOf(a) === g.label);
83
+ const gb = GROUPS.findIndex(g => groupOf(b) === g.label);
84
+ if (ga !== gb) return ga - gb;
85
+ return a.localeCompare(b);
86
+ });
87
+
88
+ const lines = [];
89
+ lines.push(`**${ALL_TOOLS.length} tools** across PowerQuery, Mgmt Console, SDL API, Hyperautomation, and UAM Ingest:`);
90
+ lines.push('');
91
+ lines.push('| Group | Tool | Skill |');
92
+ lines.push('|-------|------|-------|');
93
+ for (const name of sorted) {
94
+ const group = groupOf(name);
95
+ const skill = TOOL_SKILL[name] || '';
96
+ if (!skill) {
97
+ throw new Error(`Missing TOOL_SKILL mapping for "${name}". Update scripts/regen-readme-tools-table.mjs.`);
98
+ }
99
+ lines.push(`| ${group} | \`${name}\` | ${skill} |`);
100
+ }
101
+ return lines.join('\n');
102
+ }
103
+
104
+ const START = '<!-- BEGIN AUTO-GENERATED TOOLS TABLE -->';
105
+ const END = '<!-- END AUTO-GENERATED TOOLS TABLE -->';
106
+
107
+ function spliceTable(readme, table) {
108
+ const start = readme.indexOf(START);
109
+ const end = readme.indexOf(END);
110
+ if (start < 0 || end < 0 || end < start) {
111
+ throw new Error(
112
+ `README is missing the BEGIN/END auto-generated markers:\n ${START}\n ${END}\n` +
113
+ `Add both to README.md around the tools table block before running this script.`
114
+ );
115
+ }
116
+ const head = readme.slice(0, start + START.length);
117
+ const tail = readme.slice(end);
118
+ return head + '\n' + table + '\n' + tail;
119
+ }
120
+
121
+ const args = process.argv.slice(2);
122
+ const checkOnly = args.includes('--check');
123
+
124
+ const before = readFileSync(README, 'utf-8');
125
+ const table = buildTable();
126
+ const after = spliceTable(before, table);
127
+
128
+ if (checkOnly) {
129
+ if (before !== after) {
130
+ process.stderr.write('README tools table is out of sync with ALL_TOOLS.\n');
131
+ process.stderr.write('Run `npm run regen:readme` to fix.\n');
132
+ process.exit(1);
133
+ }
134
+ process.stdout.write('README tools table is in sync.\n');
135
+ } else {
136
+ if (before === after) {
137
+ process.stdout.write('No changes; README tools table already in sync.\n');
138
+ } else {
139
+ writeFileSync(README, after);
140
+ process.stdout.write(`Updated README tools table (${ALL_TOOLS.length} tools).\n`);
141
+ }
142
+ }