pyre-cli 7.1.0 → 8.0.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,84 @@
1
+ // src/updateCheck.ts
2
+ import fs from "fs";
3
+ import path from "path";
4
+ import os from "os";
5
+ import https from "https";
6
+ import chalk from "chalk";
7
+ var UPDATE_CACHE_FILE = path.join(os.homedir(), ".config", "pyre", "update-check.json");
8
+ function checkForUpdates(currentVersion) {
9
+ return new Promise((resolve) => {
10
+ try {
11
+ if (fs.existsSync(UPDATE_CACHE_FILE)) {
12
+ const raw = fs.readFileSync(UPDATE_CACHE_FILE, "utf-8");
13
+ const cached = JSON.parse(raw);
14
+ if (Date.now() - (cached.lastChecked || 0) < 24 * 60 * 60 * 1e3) {
15
+ return resolve({
16
+ currentVersion,
17
+ latestVersion: cached.latestVersion || currentVersion,
18
+ hasUpdate: isNewerVersion(currentVersion, cached.latestVersion)
19
+ });
20
+ }
21
+ }
22
+ } catch {
23
+ }
24
+ const req = https.get("https://registry.npmjs.org/pyre-cli/latest", { timeout: 2e3 }, (res) => {
25
+ let body = "";
26
+ res.on("data", (chunk) => body += chunk);
27
+ res.on("end", () => {
28
+ try {
29
+ if (res.statusCode === 200) {
30
+ const data = JSON.parse(body);
31
+ const latestVersion = data.version || currentVersion;
32
+ fs.mkdirSync(path.dirname(UPDATE_CACHE_FILE), { recursive: true });
33
+ fs.writeFileSync(UPDATE_CACHE_FILE, JSON.stringify({ lastChecked: Date.now(), latestVersion }));
34
+ return resolve({
35
+ currentVersion,
36
+ latestVersion,
37
+ hasUpdate: isNewerVersion(currentVersion, latestVersion)
38
+ });
39
+ }
40
+ } catch {
41
+ }
42
+ resolve(null);
43
+ });
44
+ });
45
+ req.on("error", () => resolve(null));
46
+ req.on("timeout", () => {
47
+ req.destroy();
48
+ resolve(null);
49
+ });
50
+ });
51
+ }
52
+ function isNewerVersion(current, latest) {
53
+ if (!latest) return false;
54
+ const cParts = current.split(".").map((n) => parseInt(n, 10));
55
+ const lParts = latest.split(".").map((n) => parseInt(n, 10));
56
+ for (let i = 0; i < Math.max(cParts.length, lParts.length); i++) {
57
+ const c = cParts[i] || 0;
58
+ const l = lParts[i] || 0;
59
+ if (l > c) return true;
60
+ if (l < c) return false;
61
+ }
62
+ return false;
63
+ }
64
+ async function printUpdateReport(currentVersion) {
65
+ console.log(chalk.bold("\n pyre update \u2014 Version Check\n"));
66
+ console.log(` Current Version: ${chalk.bold(currentVersion)}`);
67
+ const result = await checkForUpdates(currentVersion);
68
+ if (!result) {
69
+ console.log(chalk.yellow(" \u26A0 Unable to reach npm registry for update check.\n"));
70
+ return;
71
+ }
72
+ if (result.hasUpdate) {
73
+ console.log(chalk.green.bold(` \u{1F680} A new version of pyre is available: v${result.latestVersion}`));
74
+ console.log(chalk.dim(` Run "npm install -g pyre-cli" to update.
75
+ `));
76
+ } else {
77
+ console.log(chalk.green(` \u2714 You are running the latest version of pyre (v${currentVersion}).
78
+ `));
79
+ }
80
+ }
81
+ export {
82
+ checkForUpdates,
83
+ printUpdateReport
84
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pyre-cli",
3
- "version": "7.1.0",
3
+ "version": "8.0.0",
4
4
  "description": "CLI Mac monitoring tool: temps, system stats, live dashboard, packet monitor, battery predictor, export",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
package/parity.md CHANGED
@@ -511,15 +511,22 @@ Useful for:
511
511
 
512
512
  # 14. What btop Does NOT Do
513
513
 
514
- Compared to macOS Activity Monitor, btop does **not** provide:
515
-
516
- * App energy impact scoring
517
- * Detailed GPU history graphs on all Macs
518
- * Network connection inspector
519
- * Application sandbox details
520
- * Launch agent management
521
- * System extension management
522
- * Full disk/file activity tracing
514
+ ---
515
+
516
+ # 15. Exclusive pyre Features (Not Available in btop)
517
+
518
+ pyre provides Mac-native security, diagnostics, and ecosystem capabilities that btop structurally cannot offer:
519
+
520
+ * **Security & Posture Audit** — Gatekeeper enforcement (`spctl`), System Integrity Protection (`csrutil`), XProtect definition update checks in `pyre doctor`, and System Extensions inspector (`pyre extensions`).
521
+ * **Statistical Anomaly Digest** — `pyre anomalies --since <range>` analyzes historical CSV telemetry and highlights z-score metric spikes (+2.5σ/3.5σ).
522
+ * **Mac Ecosystem Inspectors** — Homebrew health summary (`pyre brew`), Time Machine backup status (`pyre info`), and connected display resolution/type details (`pyre info`).
523
+ * **Memory Pressure & Pressure Level** — Wired, compressed, purgeable memory, swapins/outs, and kernel VM pressure level (`Normal`, `Warning`, `Critical`).
524
+ * **Micro-benchmarking Energy Estimator** — `pyre bench <cmd>` calculates Watt-seconds, total kWh energy consumption, and dollar cost estimation.
525
+ * **Grafana Infrastructure Export** — Shipped `grafana/pyre-dashboard.json` dashboard template mapping pyre's metric schema.
526
+ * **Atomic Config Profiles** — `pyre profile save/load/list` for rapid context switching (dev, gaming, monitoring).
527
+ * **Kill Confirmation Guard Rails** — Hard confirmation safety check when attempting to terminate protected system PIDs (`launchd`, pyre PID, parent PID).
528
+ * **Accessible Plain-Text Mode** — `--plain`/`--a11y` mode for screen reader compatibility and clean file logging without ANSI noise.
529
+ * **P2P Live Data Streaming & Fleet** — Multi-Mac live streaming with password/TLS auth, HMAC signing, rate-limiting, and IP filtering.
523
530
 
524
531
  ---
525
532
 
@@ -0,0 +1,151 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import chalk from 'chalk';
4
+
5
+ export interface AnomaliesCmdOptions {
6
+ since?: string;
7
+ dir?: string;
8
+ detailed?: boolean;
9
+ }
10
+
11
+ export function runAnomaliesDigest(opts: AnomaliesCmdOptions = {}): void {
12
+ const dir = opts.dir || './pyre-exports';
13
+ const sinceStr = opts.since || '7d';
14
+
15
+ console.log(chalk.bold(`\n pyre anomalies — Resource Spike & Anomaly Digest\n`));
16
+
17
+ if (!fs.existsSync(dir)) {
18
+ console.log(chalk.yellow(` No logs directory found for range '${sinceStr}' at ${dir}.`));
19
+ console.log(chalk.dim(' Enable background logging with `pyre --log` to record history.\n'));
20
+ return;
21
+ }
22
+
23
+ const files = fs.readdirSync(dir).filter(f => f.startsWith('pyre-log-') && f.endsWith('.csv'));
24
+ if (files.length === 0) {
25
+ console.log(chalk.yellow(` No CSV logs found for range '${sinceStr}' in ${dir}.`));
26
+ console.log(chalk.dim(' Enable background logging with `pyre --log` to record history.\n'));
27
+ return;
28
+ }
29
+
30
+ // Parse time window
31
+ let cutoff = Date.now() - 7 * 24 * 60 * 60 * 1000;
32
+ if (sinceStr === 'yesterday' || sinceStr === '1d') {
33
+ cutoff = Date.now() - 24 * 60 * 60 * 1000;
34
+ } else if (sinceStr.endsWith('d')) {
35
+ const d = parseInt(sinceStr.slice(0, -1), 10);
36
+ if (!isNaN(d)) cutoff = Date.now() - d * 24 * 60 * 60 * 1000;
37
+ } else if (!isNaN(Date.parse(sinceStr))) {
38
+ cutoff = Date.parse(sinceStr);
39
+ }
40
+
41
+ interface LogRow {
42
+ ts: Date;
43
+ cpu: number;
44
+ mem: number;
45
+ temp?: number;
46
+ topProc?: string;
47
+ }
48
+
49
+ const rows: LogRow[] = [];
50
+ for (const file of files.sort()) {
51
+ const fullPath = path.join(dir, file);
52
+ try {
53
+ const content = fs.readFileSync(fullPath, 'utf-8');
54
+ const lines = content.split('\n').slice(1);
55
+ for (const line of lines) {
56
+ if (!line.trim()) continue;
57
+ const parts = line.split(',');
58
+ if (parts.length >= 3) {
59
+ const ts = new Date(parts[0]);
60
+ if (!isNaN(ts.getTime()) && ts.getTime() >= cutoff) {
61
+ const cpu = parseFloat(parts[1]);
62
+ const mem = parseFloat(parts[2]);
63
+ const temp = parts.length >= 4 ? parseFloat(parts[3]) : undefined;
64
+ const topProc = parts.length >= 5 ? parts[4] : undefined;
65
+ if (!isNaN(cpu) && !isNaN(mem)) {
66
+ rows.push({ ts, cpu, mem, temp: isNaN(temp!) ? undefined : temp, topProc });
67
+ }
68
+ }
69
+ }
70
+ }
71
+ } catch {
72
+ // ignore parse errors
73
+ }
74
+ }
75
+
76
+ if (rows.length < 5) {
77
+ console.log(chalk.yellow(` Insufficient data recorded since ${sinceStr} (need at least 5 samples).`));
78
+ console.log(chalk.dim(' Run pyre with `--log` for longer intervals to build history.\n'));
79
+ return;
80
+ }
81
+
82
+ // Calculate stats
83
+ const cpuVals = rows.map(r => r.cpu);
84
+ const memVals = rows.map(r => r.mem);
85
+ const tempVals = rows.filter(r => r.temp !== undefined).map(r => r.temp!);
86
+
87
+ const calcMeanStd = (arr: number[]) => {
88
+ if (arr.length === 0) return { mean: 0, std: 0 };
89
+ const mean = arr.reduce((a, b) => a + b, 0) / arr.length;
90
+ const variance = arr.reduce((a, b) => a + Math.pow(b - mean, 2), 0) / arr.length;
91
+ return { mean, std: Math.sqrt(variance) };
92
+ };
93
+
94
+ const cpuStats = calcMeanStd(cpuVals);
95
+ const memStats = calcMeanStd(memVals);
96
+ const tempStats = calcMeanStd(tempVals);
97
+
98
+ interface DetectedAnomaly {
99
+ ts: Date;
100
+ metric: string;
101
+ value: number;
102
+ baseline: number;
103
+ zScore: number;
104
+ topProc?: string;
105
+ }
106
+
107
+ const anomalies: DetectedAnomaly[] = [];
108
+
109
+ for (const r of rows) {
110
+ if (cpuStats.std > 0) {
111
+ const z = (r.cpu - cpuStats.mean) / cpuStats.std;
112
+ if (z >= 2.5) {
113
+ anomalies.push({ ts: r.ts, metric: 'CPU', value: r.cpu, baseline: cpuStats.mean, zScore: z, topProc: r.topProc });
114
+ }
115
+ }
116
+ if (memStats.std > 0) {
117
+ const z = (r.mem - memStats.mean) / memStats.std;
118
+ if (z >= 2.5) {
119
+ anomalies.push({ ts: r.ts, metric: 'Memory', value: r.mem, baseline: memStats.mean, zScore: z, topProc: r.topProc });
120
+ }
121
+ }
122
+ if (r.temp !== undefined && tempStats.std > 0) {
123
+ const z = (r.temp - tempStats.mean) / tempStats.std;
124
+ if (z >= 2.5) {
125
+ anomalies.push({ ts: r.ts, metric: 'Temperature', value: r.temp, baseline: tempStats.mean, zScore: z, topProc: r.topProc });
126
+ }
127
+ }
128
+ }
129
+
130
+ console.log(` Analyzed ${chalk.bold(rows.length)} samples since ${chalk.bold(sinceStr)}.`);
131
+ if (anomalies.length === 0) {
132
+ console.log(chalk.green(' ✔ No statistical anomalies detected in this time range.\n'));
133
+ return;
134
+ }
135
+
136
+ console.log(chalk.yellow(` ⚠ Found ${anomalies.length} anomaly spike event(s):\n`));
137
+ for (const a of anomalies.slice(0, 15)) {
138
+ const timeFormatted = a.ts.toLocaleString();
139
+ const severityColor = a.zScore >= 3.5 ? chalk.red : chalk.yellow;
140
+ const unit = a.metric === 'Temperature' ? '°C' : '%';
141
+ console.log(` • [${chalk.dim(timeFormatted)}] ${severityColor(a.metric)} spike: ${chalk.bold(a.value.toFixed(1) + unit)} (baseline: ${a.baseline.toFixed(1)}${unit}, +${a.zScore.toFixed(1)}σ)`);
142
+ if (a.topProc) {
143
+ console.log(chalk.dim(` Top Process: ${a.topProc}`));
144
+ }
145
+ }
146
+
147
+ if (anomalies.length > 15) {
148
+ console.log(chalk.dim(` ... and ${anomalies.length - 15} more anomaly events.`));
149
+ }
150
+ console.log();
151
+ }
@@ -0,0 +1,88 @@
1
+ import chalk from 'chalk';
2
+ import { run } from './monitors/run.js';
3
+
4
+ export interface BrewHealthInfo {
5
+ installedCount: number;
6
+ outdatedCount: number;
7
+ cellarSize: string;
8
+ doctorWarnings: string[];
9
+ brewAvailable: boolean;
10
+ }
11
+
12
+ export async function getBrewHealth(): Promise<BrewHealthInfo> {
13
+ try {
14
+ const brewPath = await run('which brew 2>&1', '');
15
+ if (!brewPath.trim() || brewPath.includes('not found')) {
16
+ return { installedCount: 0, outdatedCount: 0, cellarSize: 'N/A', doctorWarnings: [], brewAvailable: false };
17
+ }
18
+
19
+ // Outdated count without triggering brew update
20
+ const outdatedRaw = await run('brew outdated --quiet 2>&1', '');
21
+ const outdatedLines = outdatedRaw.trim().split('\n').filter(l => l.length > 0 && !l.startsWith('Homebrew') && !l.startsWith('==>'));
22
+
23
+ // Installed list count
24
+ const listRaw = await run('brew list --formula -1 2>&1', '');
25
+ const listLines = listRaw.trim().split('\n').filter(l => l.length > 0);
26
+
27
+ // Disk usage of cellar
28
+ let cellarSize = 'N/A';
29
+ try {
30
+ const cellarPath = await run('brew --cellar 2>&1', '');
31
+ if (cellarPath.trim()) {
32
+ const duRaw = await run(`du -sh "${cellarPath.trim()}" 2>&1`, '');
33
+ const duMatch = duRaw.match(/^([0-9\.\,]+[KMGTP]?i?B?)/i);
34
+ if (duMatch) cellarSize = duMatch[1];
35
+ }
36
+ } catch {
37
+ cellarSize = 'N/A';
38
+ }
39
+
40
+ // Doctor warnings
41
+ const doctorRaw = await run('brew doctor 2>&1', '');
42
+ const doctorWarnings: string[] = [];
43
+ const docLines = doctorRaw.split('\n');
44
+ for (const line of docLines) {
45
+ if (line.startsWith('Warning:')) {
46
+ doctorWarnings.push(line.replace(/^Warning:\s*/, '').trim());
47
+ }
48
+ }
49
+
50
+ return {
51
+ installedCount: listLines.length,
52
+ outdatedCount: outdatedLines.length,
53
+ cellarSize,
54
+ doctorWarnings,
55
+ brewAvailable: true,
56
+ };
57
+ } catch (err) {
58
+ return { installedCount: 0, outdatedCount: 0, cellarSize: 'N/A', doctorWarnings: [], brewAvailable: false };
59
+ }
60
+ }
61
+
62
+ export async function printBrewHealthReport(): Promise<void> {
63
+ console.log(chalk.bold('\n pyre brew — Homebrew Health Summary\n'));
64
+ const health = await getBrewHealth();
65
+
66
+ if (!health.brewAvailable) {
67
+ console.log(chalk.yellow(' ⚠ Homebrew is not installed or not available on PATH.\n'));
68
+ return;
69
+ }
70
+
71
+ console.log(` 📦 Installed Formulae: ${chalk.bold(health.installedCount)}`);
72
+ console.log(` ⏳ Outdated Packages: ${health.outdatedCount > 0 ? chalk.yellow.bold(health.outdatedCount) : chalk.green('0 (Up to date)')}`);
73
+ console.log(` 💾 Cellar Disk Usage: ${chalk.bold(health.cellarSize)}`);
74
+
75
+ console.log();
76
+ if (health.doctorWarnings.length === 0) {
77
+ console.log(chalk.green(' ✔ brew doctor: No warnings detected system-wide.'));
78
+ } else {
79
+ console.log(chalk.yellow(` ⚠ brew doctor warnings (${health.doctorWarnings.length}):`));
80
+ for (const warn of health.doctorWarnings.slice(0, 5)) {
81
+ console.log(chalk.dim(` • ${warn}`));
82
+ }
83
+ if (health.doctorWarnings.length > 5) {
84
+ console.log(chalk.dim(` ... and ${health.doctorWarnings.length - 5} more.`));
85
+ }
86
+ }
87
+ console.log();
88
+ }
package/src/doctor.ts CHANGED
@@ -86,7 +86,83 @@ export async function runDoctor(): Promise<DoctorCheck[]> {
86
86
  });
87
87
  }
88
88
 
89
- // 4. Config file integrity
89
+ // 4. Gatekeeper Status (spctl)
90
+ try {
91
+ const spctlOut = await run('spctl --status 2>&1', '');
92
+ if (spctlOut.includes('assessments enabled')) {
93
+ checks.push({
94
+ name: 'Gatekeeper',
95
+ status: 'ok',
96
+ message: 'Gatekeeper enforcement is enabled.',
97
+ });
98
+ } else {
99
+ checks.push({
100
+ name: 'Gatekeeper',
101
+ status: 'warn',
102
+ message: 'Gatekeeper is disabled or status check unconfirmed.',
103
+ details: 'Re-enable Gatekeeper using "sudo spctl --master-enable" to protect against untrusted software.',
104
+ });
105
+ }
106
+ } catch {
107
+ checks.push({
108
+ name: 'Gatekeeper',
109
+ status: 'warn',
110
+ message: 'Could not query Gatekeeper status.',
111
+ });
112
+ }
113
+
114
+ // 5. System Integrity Protection (SIP / csrutil)
115
+ try {
116
+ const sipOut = await run('csrutil status 2>&1', '');
117
+ if (sipOut.includes('enabled')) {
118
+ checks.push({
119
+ name: 'System Integrity Protection',
120
+ status: 'ok',
121
+ message: 'SIP is enabled.',
122
+ });
123
+ } else {
124
+ checks.push({
125
+ name: 'System Integrity Protection',
126
+ status: 'warn',
127
+ message: 'SIP is disabled or restricted.',
128
+ details: 'System Integrity Protection protects core system files. Enable it from macOS Recovery.',
129
+ });
130
+ }
131
+ } catch {
132
+ checks.push({
133
+ name: 'System Integrity Protection',
134
+ status: 'warn',
135
+ message: 'Could not query SIP status.',
136
+ });
137
+ }
138
+
139
+ // 6. XProtect Definition Status
140
+ try {
141
+ const xprotectOut = await run('system_profiler SPInstallHistoryDataType 2>&1', '');
142
+ const xpMatches = Array.from(xprotectOut.matchAll(/XProtect(?:Remediator)?ConfigData.*?\n.*?Install Date:\s*(.+)/gi));
143
+ if (xpMatches.length > 0) {
144
+ const latestDateStr = xpMatches[xpMatches.length - 1][1];
145
+ checks.push({
146
+ name: 'XProtect Definitions',
147
+ status: 'ok',
148
+ message: `XProtect active (last updated ${latestDateStr.trim()}).`,
149
+ });
150
+ } else {
151
+ checks.push({
152
+ name: 'XProtect Definitions',
153
+ status: 'ok',
154
+ message: 'XProtect definitions installed.',
155
+ });
156
+ }
157
+ } catch {
158
+ checks.push({
159
+ name: 'XProtect Definitions',
160
+ status: 'warn',
161
+ message: 'Could not determine XProtect update history.',
162
+ });
163
+ }
164
+
165
+ // 7. Config file integrity
90
166
  try {
91
167
  const cfg = readConfig();
92
168
  checks.push({
@@ -0,0 +1,43 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { parseSystemExtensionsOutput } from './extensions.js';
4
+ import { saveProfile, loadProfile, listProfiles } from './state/config.js';
5
+ import { sparkline } from './sparkline.js';
6
+
7
+ describe('System Extensions Parser (Feature A7)', () => {
8
+ it('should parse system extensions categories and active extensions', () => {
9
+ const mockOutput = `
10
+ --- category com.apple.system_extension.network_extension
11
+ * * UBF8T346G9 com.crowdstrike.falcon.Agent (1.0/1) [activated enabled]
12
+ `;
13
+ const result = parseSystemExtensionsOutput(mockOutput);
14
+ assert.strictEqual(result.length, 1);
15
+ assert.strictEqual(result[0].category, 'com.apple.system_extension.network_extension');
16
+ assert.strictEqual(result[0].extensions.length, 1);
17
+ assert.strictEqual(result[0].extensions[0].bundleId, 'com.crowdstrike.falcon.Agent');
18
+ assert.strictEqual(result[0].extensions[0].teamId, 'UBF8T346G9');
19
+ });
20
+
21
+ it('should handle empty extensions output without crashing', () => {
22
+ const result = parseSystemExtensionsOutput('');
23
+ assert.ok(Array.isArray(result));
24
+ });
25
+ });
26
+
27
+ describe('Config Profiles (Feature D5)', () => {
28
+ it('should save, list, and load profiles', () => {
29
+ const testName = 'unit-test-profile';
30
+ saveProfile(testName);
31
+ const profiles = listProfiles();
32
+ assert.ok(profiles.includes(testName));
33
+
34
+ loadProfile(testName);
35
+ });
36
+ });
37
+
38
+ describe('Plain text sparkline (Feature F3)', () => {
39
+ it('should format sparkline in plain text mode', () => {
40
+ const res = sparkline([10, 20, 30], { plainText: true });
41
+ assert.strictEqual(res, '[10.0 -> 30.0 (Δ:+20.0)]');
42
+ });
43
+ });
@@ -0,0 +1,83 @@
1
+ import chalk from 'chalk';
2
+ import { run } from './monitors/run.js';
3
+
4
+ export interface SystemExtensionInfo {
5
+ teamId: string;
6
+ bundleId: string;
7
+ version: string;
8
+ state: string;
9
+ category: string;
10
+ }
11
+
12
+ export async function getSystemExtensions(): Promise<{ category: string; extensions: SystemExtensionInfo[] }[]> {
13
+ try {
14
+ const raw = await run('systemextensionsctl list 2>&1', '');
15
+ return parseSystemExtensionsOutput(raw);
16
+ } catch (err) {
17
+ return [];
18
+ }
19
+ }
20
+
21
+ export function parseSystemExtensionsOutput(output: string): { category: string; extensions: SystemExtensionInfo[] }[] {
22
+ const result: { category: string; extensions: SystemExtensionInfo[] }[] = [];
23
+ let currentCategory = 'Other Extensions';
24
+ let currentList: SystemExtensionInfo[] = [];
25
+
26
+ const lines = output.split('\n');
27
+ for (const line of lines) {
28
+ const trimmed = line.trim();
29
+ if (!trimmed) continue;
30
+
31
+ if (trimmed.startsWith('--- category') || trimmed.startsWith('--- ')) {
32
+ if (currentList.length > 0 || result.length > 0) {
33
+ result.push({ category: currentCategory, extensions: currentList });
34
+ currentList = [];
35
+ }
36
+ const catMatch = trimmed.match(/category\s+([\w\.-]+)/i);
37
+ currentCategory = catMatch ? catMatch[1] : trimmed.replace(/^-+\s*/, '').replace(/\s*-+$/, '');
38
+ continue;
39
+ }
40
+
41
+ const extMatch = trimmed.match(/^(?:\*\s*)*([A-Z0-9]{10}|\?\?\?\?\?\?\?\?\?\?)\s+([\w\.-]+)\s+\(([^)]+)\)\s+\[([^\]]+)\]/i);
42
+ if (extMatch) {
43
+ currentList.push({
44
+ teamId: extMatch[1],
45
+ bundleId: extMatch[2],
46
+ version: extMatch[3],
47
+ state: extMatch[4],
48
+ category: currentCategory,
49
+ });
50
+ }
51
+ }
52
+
53
+ if (currentList.length > 0 || result.length === 0) {
54
+ result.push({ category: currentCategory, extensions: currentList });
55
+ }
56
+
57
+ return result.filter(group => group.extensions.length > 0 || result.length === 1);
58
+ }
59
+
60
+ export async function printExtensionsReport(): Promise<void> {
61
+ console.log(chalk.bold('\n pyre extensions — System Extensions Inspector\n'));
62
+ const categories = await getSystemExtensions();
63
+
64
+ let totalCount = 0;
65
+ for (const cat of categories) {
66
+ totalCount += cat.extensions.length;
67
+ console.log(chalk.cyan.bold(` 📂 ${cat.category}`));
68
+ if (cat.extensions.length === 0) {
69
+ console.log(chalk.dim(' (No extensions registered in this category)'));
70
+ continue;
71
+ }
72
+ for (const ext of cat.extensions) {
73
+ const stateColor = ext.state.includes('activated') || ext.state.includes('enabled') ? chalk.green : chalk.yellow;
74
+ console.log(` • ${chalk.bold(ext.bundleId)} (${ext.version})`);
75
+ console.log(chalk.dim(` Team: ${ext.teamId} | State: `) + stateColor(ext.state));
76
+ }
77
+ console.log();
78
+ }
79
+
80
+ if (totalCount === 0) {
81
+ console.log(chalk.dim(' No active system extensions detected on this system.\n'));
82
+ }
83
+ }