Reports the system root volume's used vs total bytes as a top-level scalar — `usagePercent` (0-100) plus `freeGb` / `totalGb`. Faster + lighter than `disk_scan` (which enumerates files); designed for the proactive disk-nearly-full trigger that fires when usagePercent ≥ 90. Read-only.
/** * mcp/skills/getDiskUsage.ts — get_disk_usage skill * * Reports volume-level disk usage as a flat top-level object suitable * for the proactive evaluator's restricted DSL. Existing * `disk_scan` enumerates files but does not surface a `usagePercent` * scalar — Wave 2 Track B Trigger 1 (`disk-nearly-full`) needs that * scalar at the top level so the condition `"usagePercent >= 90"` * parses against `meta.outputKeys`. * * Platform strategy * ----------------- * darwin `df -k <path>` → 1024-byte blocks, parse "Used" + "1024-blocks" * columns. Default path is `/` — the system root volume, * which is what users mean when they say "my disk is full". * win32 PowerShell `Get-PSDrive -Name C` (or specified drive) → * `Used` + `Free` properties in bytes. Default drive is * the system drive (`%SystemDrive%` env var, falls back to * "C"). * * Telemetry contract (Track B Phase 4) * ------------------------------------ * `meta.outputKeys` includes: platform, usagePercent, freeGb, totalGb, * volume. The DSL evaluator references `usagePercent` for the * disk-nearly-full rule. * * Read-only. */import * as os from "os";import { z } from "zod";import { execAsync, runPS, isDarwin, isWin32,} from "./_shared/platform";// -- Meta ---------------------------------------------------------------------export const meta = { name: "get_disk_usage", description: "Reports the system root volume's used vs total bytes as a top-level " + "scalar — `usagePercent` (0-100) plus `freeGb` / `totalGb`. Faster + " + "lighter than `disk_scan` (which enumerates files); designed for the " + "proactive disk-nearly-full trigger that fires when usagePercent ≥ 90. " + "Read-only.", riskLevel: "low", destructive: false, requiresConsent: false, supportsDryRun: false, affectedScope: ["user"], auditRequired: false, // See docs/proactivesupport/PROACTIVE-ARCHITECTURE.md §6. outputKeys: [ "platform", "usagePercent", "freeGb", "totalGb", "volume", ], schema: { volume: z .string() .optional() .describe( "Volume / drive to probe. macOS default '/' (system root); " + "Windows default %SystemDrive% (typically 'C:'). Pass an explicit " + "value to inspect another mounted volume.", ), },} as const;// -- Types --------------------------------------------------------------------export interface GetDiskUsageResult { platform: NodeJS.Platform; /** Volume probed — `/` on darwin, `C:` on win32 by default. */ volume: string; /** 0-100, rounded to 1 decimal. */ usagePercent: number; /** Total volume size in GB, rounded to 1 decimal. */ totalGb: number; /** Free space in GB, rounded to 1 decimal. */ freeGb: number; /** Used space in GB, rounded to 1 decimal. */ usedGb: number;}// -- darwin parsing -----------------------------------------------------------/** * `df -k /` output looks like: * * Filesystem 1024-blocks Used Available Capacity iused ifree %iused Mounted on * /dev/disk3s5 971350180 823521820 140183448 86% ... * * We parse the second numeric line and read the Available column (parts[3]) * directly for freeBytes — NOT total-used. On APFS, snapshots/purgeable * space and metadata consume blocks that appear in neither Used nor Available, * so total-used overstates free space. usagePercent mirrors df's Capacity * column: used/(used+available). */function parseDarwinDfOutput(stdout: string, volume: string): GetDiskUsageResult { const lines = stdout.trim().split("\n"); if (lines.length < 2) { throw new Error(`get_disk_usage: unexpected df output for ${volume}: ${stdout.slice(0, 200)}`); } // When the device path is long, df wraps it onto its own line and puts // the numeric columns on the next line (lines[2]). Detect by counting // tokens — a valid data line has ≥5 columns. let dataLine = lines[1]; if (lines.length >= 3 && lines[1].trim().split(/\s+/).length < 5) { dataLine = lines[2]; } const parts = dataLine.trim().split(/\s+/); // When df wraps a long device name, the continuation line has no device // prefix — parts[0] is the first number, not a path. Detect by checking // whether parts[0] starts with "/" (device path) and shift accordingly. const col = parts[0].startsWith("/") ? 1 : 0; // column offset if (parts.length < col + 4) { throw new Error(`get_disk_usage: unexpected df row format: ${dataLine}`); } const blocks1k = parseInt(parts[col], 10); // 1024-blocks (total) const used1k = parseInt(parts[col + 1], 10); // Used const avail1k = parseInt(parts[col + 2], 10); // Available — NOT total-used if (Number.isNaN(blocks1k) || Number.isNaN(used1k) || Number.isNaN(avail1k) || blocks1k <= 0) { throw new Error(`get_disk_usage: could not parse df numbers from: ${dataLine.trim()}`); } const totalBytes = blocks1k * 1024; const freeBytes = avail1k * 1024; // On APFS, `df /` reports only the sealed system snapshot volume — its Used // column (16-20 GB) omits user data, swap, preboot, and other APFS volumes // that share the same free-space pool. The only reliable cross-volume // figure is total − available, which matches what Finder and About This Mac // display. const usedBytes = totalBytes - freeBytes; const usagePercent = totalBytes > 0 ? round1((usedBytes / totalBytes) * 100) : 0; return { platform: "darwin", volume, usagePercent, totalGb: round1(totalBytes / 1_000_000_000), usedGb: round1(usedBytes / 1_000_000_000), freeGb: round1(freeBytes / 1_000_000_000), };}async function getDiskUsageDarwin(volume: string): Promise<GetDiskUsageResult> { // Quote the volume to handle paths with spaces. Stick to portable -k // (1024-byte blocks); all macOS versions support it. const { stdout } = await execAsync( `df -k ${JSON.stringify(volume)} 2>/dev/null`, { timeout: 10_000 }, ); return parseDarwinDfOutput(stdout, volume);}// -- win32 parsing ------------------------------------------------------------interface WinDriveInfo { Used: string | number; // PowerShell number — emitted as JSON number, but Get-PSDrive can give string Free: string | number; Used2?: number; // Reserved for parsed value}function parseWinPSOutput(stdout: string, volume: string): GetDiskUsageResult { const trimmed = stdout.trim(); if (!trimmed) { throw new Error(`get_disk_usage: empty PowerShell output for ${volume}`); } let parsed: WinDriveInfo; try { parsed = JSON.parse(trimmed) as WinDriveInfo; } catch (err) { throw new Error(`get_disk_usage: could not parse PS output: ${(err as Error).message}`); } const used = typeof parsed.Used === "number" ? parsed.Used : parseInt(String(parsed.Used), 10); const free = typeof parsed.Free === "number" ? parsed.Free : parseInt(String(parsed.Free), 10); if (Number.isNaN(used) || Number.isNaN(free)) { throw new Error(`get_disk_usage: PS returned non-numeric Used/Free for ${volume}`); } const total = used + free; if (total === 0) { throw new Error(`get_disk_usage: total volume size is zero for ${volume}`); } return { platform: "win32", volume, usagePercent: round1((used / total) * 100), totalGb: round1(total / 1_000_000_000), usedGb: round1(used / 1_000_000_000), freeGb: round1(free / 1_000_000_000), };}async function getDiskUsageWin32(volume: string): Promise<GetDiskUsageResult> { // Get-PSDrive -Name strips the trailing colon; "C:" becomes "C". // Reject path separators so we can't be tricked into running an arbitrary // identifier. const drive = volume.replace(/[:\\\/]$/, ""); if (!/^[A-Za-z]$/.test(drive)) { throw new Error(`get_disk_usage: invalid Windows drive letter '${volume}'`); } const script = `$ErrorActionPreference = 'Stop'$d = Get-PSDrive -Name ${drive}[pscustomobject]@{ Used = $d.Used; Free = $d.Free } | ConvertTo-Json -Compress`.trim(); const stdout = await runPS(script, { timeoutMs: 10_000 }); return parseWinPSOutput(stdout, `${drive}:`);}// -- Helpers ------------------------------------------------------------------function round1(n: number): number { return Math.round(n * 10) / 10;}function defaultVolume(platform: NodeJS.Platform): string { if (platform === "darwin") return "/"; if (platform === "win32") { const sysDrive = process.env["SystemDrive"]; if (sysDrive && /^[A-Za-z]:?$/.test(sysDrive)) { return sysDrive.replace(/[:]$/, "") + ":"; } return "C:"; } return "/";}// -- Exported run function ----------------------------------------------------export async function run({ volume,}: { volume?: string } = {}): Promise<GetDiskUsageResult> { const platform = os.platform(); if (!isDarwin() && !isWin32()) { throw new Error(`get_disk_usage: unsupported platform "${platform}"`); } const target = volume ?? defaultVolume(platform); if (isDarwin()) return getDiskUsageDarwin(target); return getDiskUsageWin32(target);}// -- Test helpers -------------------------------------------------------------/** Exported for unit tests only — do not use from production code. */export const __testing = { parseDarwinDfOutput, parseWinPSOutput, defaultVolume, round1,};