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,
};