get_disk_usage
get_disk_usage
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.
Metadata
Name
get_disk_usage
Updated
2 weeks ago
Source
Risk
Low
Requires consent
false
Affected scope
user
Code
/**
* 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,
};