Code

/**
 * mcp/skills/listUsbDevices.ts — list_usb_devices skill
 *
 * Enumerates USB devices with vendor/product IDs and power state.  Used
 * by the P0-d A/V & Peripheral Repair skill to flag unresponsive
 * peripherals (docks, hubs, external storage) before deciding whether
 * the symptom is hardware vs software.
 *
 * Platform strategy
 * -----------------
 * darwin  `system_profiler SPUSBDataType -json` returns a recursive tree
 *         of USB controllers and their attached devices.  We flatten the
 *         tree to a single array of leaves (each entry has `_name`,
 *         `vendor_id`, `product_id`, `bus_power`, `current_available`).
 * win32   PowerShell `Get-PnpDevice -Class USB,USBDevice` for the device
 *         list; the Status field (`OK` / `Error` / `Unknown`) maps
 *         directly to power-state guidance.
 *
 * Read-only.
 */
 
import * as os from "os";
import { z }   from "zod";
 
import {
  execAsync,
  runPS,
}                from "./_shared/platform";
 
// -- Meta ---------------------------------------------------------------------
 
export const meta = {
  name: "list_usb_devices",
  description:
    "Enumerates USB devices attached to the system with vendor/product IDs " +
    "and power state. Use to identify unresponsive peripherals (docks, hubs, " +
    "external storage), confirm a device the user expects to be present is " +
    "actually enumerated, or surface devices in an error/unknown state. " +
    "Read-only.",
  riskLevel:       "low",
  destructive:     false,
  requiresConsent: false,
  supportsDryRun:  false,
  affectedScope:   ["user"],
  auditRequired:   false,
  schema: {} as Record<string, z.ZodTypeAny>,
} as const;
 
// -- Types --------------------------------------------------------------------
 
export interface UsbDevice {
  name:           string;
  vendorId?:      string;
  productId?:     string;
  manufacturer?:  string;
  status:         "ok" | "error" | "unknown";
  /** Raw bus / power values surfaced for debugging. */
  busPowerMa?:    number;
  /** macOS only — short transport string (e.g. "USB 3.1 Bus"). */
  busName?:       string;
}
 
export interface ListUsbDevicesResult {
  platform: NodeJS.Platform;
  total:    number;
  devices:  UsbDevice[];
}
 
// -- darwin implementation ----------------------------------------------------
 
interface SPUsbItem {
  _name:               string;
  vendor_id?:          string;       // "0x046d (Logitech Inc.)"
  product_id?:         string;       // "0x085e"
  manufacturer?:       string;
  bus_power?:          string;       // "500"
  current_available?:  string;
  _items?:             SPUsbItem[];  // nested hubs
}
 
interface SPUsbBlock {
  _name?:    string;
  _items?:   SPUsbItem[];
}
 
interface SPUsbData {
  SPUSBDataType?: SPUsbBlock[];
}
 
function extractHexId(raw: string | undefined): string | undefined {
  if (!raw) return undefined;
  const match = raw.match(/0x([0-9A-Fa-f]+)/);
  return match ? match[1].toUpperCase().padStart(4, "0").slice(0, 4) : undefined;
}
 
function flattenDarwinTree(items: SPUsbItem[], busName: string, out: UsbDevice[]): void {
  for (const item of items) {
    // Skip pure controllers / root hubs (no vendor_id at all + has _items).
    // We DO want internal-bus devices like the Apple Internal Keyboard — those
    // have vendor_id even when nested.
    if (item.vendor_id || item.product_id || !item._items) {
      const busPowerMa = item.bus_power ? parseInt(item.bus_power, 10) : undefined;
      const dev: UsbDevice = {
        name:    item._name,
        ...(extractHexId(item.vendor_id)  && { vendorId:  extractHexId(item.vendor_id)! }),
        ...(extractHexId(item.product_id) && { productId: extractHexId(item.product_id)! }),
        ...(item.manufacturer && { manufacturer: item.manufacturer }),
        status:  "ok",
        ...(busPowerMa !== undefined && !Number.isNaN(busPowerMa) && { busPowerMa }),
        busName,
      };
      out.push(dev);
    }
    if (item._items) flattenDarwinTree(item._items, busName, out);
  }
}
 
function parseDarwinOutput(stdout: string): ListUsbDevicesResult {
  const data   = JSON.parse(stdout) as SPUsbData;
  const blocks = data.SPUSBDataType ?? [];
  const devices: UsbDevice[] = [];
  for (const block of blocks) {
    if (block._items) flattenDarwinTree(block._items, block._name ?? "USB Bus", devices);
  }
  return {
    platform: "darwin",
    total:    devices.length,
    devices,
  };
}
 
async function listUsbDarwin(): Promise<ListUsbDevicesResult> {
  const { stdout } = await execAsync(
    "system_profiler SPUSBDataType -json 2>/dev/null",
    { maxBuffer: 20 * 1024 * 1024 },
  );
  return parseDarwinOutput(stdout);
}
 
// -- win32 implementation -----------------------------------------------------
 
interface WinPnpDevice {
  Name:         string;
  Class:        string;
  Status:       string;       // "OK" | "Error" | "Unknown" | "Disabled"
  Manufacturer?: string;
  InstanceId?:  string;       // contains VID_xxxx&PID_yyyy
}
 
function parseWinVidPid(instanceId: string | undefined): { vendorId?: string; productId?: string } {
  if (!instanceId) return {};
  const match = instanceId.match(/VID_([0-9A-F]{4})&PID_([0-9A-F]{4})/i);
  if (!match) return {};
  return { vendorId: match[1].toUpperCase(), productId: match[2].toUpperCase() };
}
 
function classifyWinStatus(status: string): UsbDevice["status"] {
  const s = status.toLowerCase();
  if (s === "ok")        return "ok";
  if (s === "error")     return "error";
  return "unknown";
}
 
function parseWinOutput(stdout: string): ListUsbDevicesResult {
  const parsed = stdout.trim()
    ? (JSON.parse(stdout) as WinPnpDevice | WinPnpDevice[])
    : [];
  const raw: WinPnpDevice[] = Array.isArray(parsed) ? parsed : [parsed];
 
  const devices: UsbDevice[] = raw
    .filter((d) => d?.Name)
    .map((d) => {
      const { vendorId, productId } = parseWinVidPid(d.InstanceId);
      return {
        name: d.Name,
        ...(vendorId   && { vendorId }),
        ...(productId  && { productId }),
        ...(d.Manufacturer && { manufacturer: d.Manufacturer }),
        status: classifyWinStatus(d.Status),
      };
    });
 
  return {
    platform: "win32",
    total:    devices.length,
    devices,
  };
}
 
async function listUsbWin32(): Promise<ListUsbDevicesResult> {
  const script = `
$ErrorActionPreference = 'SilentlyContinue'
Get-PnpDevice -PresentOnly -Class USB,USBDevice |
  Select-Object Name, Class, Status, Manufacturer, InstanceId |
  ConvertTo-Json -Depth 2 -Compress`.trim();
  const raw = await runPS(script);
  return parseWinOutput(raw);
}
 
// -- Exported run function ----------------------------------------------------
 
export async function run(): Promise<ListUsbDevicesResult> {
  const platform = os.platform();
  if (platform === "darwin") return listUsbDarwin();
  if (platform === "win32")  return listUsbWin32();
  throw new Error(`list_usb_devices: unsupported platform "${platform}"`);
}
 
// -- Test helpers -------------------------------------------------------------
 
/** Exported for unit tests only — do not use from production code. */
export const __testing = {
  parseDarwinOutput,
  parseWinOutput,
  parseWinVidPid,
  classifyWinStatus,
  extractHexId,
  flattenDarwinTree,
};