Code

/**
 * mcp/skills/findOldDownloads.ts — find_old_downloads skill
 *
 * Lists files in the Downloads folder that haven't been accessed or modified
 * in a specified number of days. Use to identify stale downloads that can be
 * safely deleted to recover disk space.
 *
 * Platform strategy
 * -----------------
 * Both   Pure Node.js — fs.readdirSync on ~/Downloads, stat each file,
 *        filter by mtimeMs and size. Cross-platform, no shell needed.
 *
 * Smoke test
 *   npx tsx -r dotenv/config mcp/skills/findOldDownloads.ts
 */
 
import * as fsp      from "fs/promises";
import * as os       from "os";
import * as nodePath from "path";
import { z }         from "zod";
 
// -- Meta ---------------------------------------------------------------------
 
export const meta = {
  name: "find_old_downloads",
  description:
    "Lists files in the Downloads folder that haven't been accessed or modified " +
    "in a specified number of days. Use to identify stale downloads that can be " +
    "safely deleted to recover disk space.",
  riskLevel:       "low",
  destructive:     false,
  requiresConsent: false,
  supportsDryRun:  false,
  affectedScope:   ["user"],
  auditRequired:   false,
  schema: {
    olderThanDays: z
      .number()
      .optional()
      .describe("Return files not modified in this many days. Default: 90"),
    minSizeMb: z
      .number()
      .optional()
      .describe("Minimum file size in MB. Default: 0 (all files)"),
  },
} as const;
 
// -- Types --------------------------------------------------------------------
 
interface OldFile {
  name:              string;
  path:              string;
  sizeMb:            number;
  lastModified:      string; // ISO 8601
  daysSinceModified: number;
}
 
// -- Exported run function ----------------------------------------------------
 
export async function run({
  olderThanDays = 90,
  minSizeMb     = 0,
}: {
  olderThanDays?: number;
  minSizeMb?:     number;
} = {}) {
  const platform      = os.platform();
  const downloadsPath = nodePath.join(os.homedir(), "Downloads");
 
  // Ensure Downloads folder exists
  try {
    await fsp.access(downloadsPath);
  } catch {
    return {
      platform,
      downloadsPath,
      totalFiles:  0,
      oldFiles:    [] as OldFile[],
      totalSizeMb: 0,
      message:     `Downloads folder not found at: ${downloadsPath}`,
    };
  }
 
  const now        = Date.now();
  const cutoffMs   = olderThanDays * 24 * 60 * 60 * 1000;
  const minBytes   = minSizeMb * 1024 * 1024;
 
  let dirents: import("fs").Dirent[];
  try {
    dirents = await fsp.readdir(downloadsPath, { withFileTypes: true });
  } catch (err) {
    // Distinguish TCC denial from genuinely-missing folder so the user
    // sees an actionable remediation path rather than a generic error.
    const code = (err as { code?: string }).code;
    if (code === "EPERM" || code === "EACCES") {
      return {
        platform,
        downloadsPath,
        totalFiles:  0,
        oldFiles:    [] as OldFile[],
        totalSizeMb: 0,
        error:
          "Cannot read Downloads folder — macOS denied access. " +
          "Open System Settings → Privacy & Security → Files and Folders, " +
          "find AI Support Agent, and enable the Downloads Folder checkbox. " +
          "Alternatively, grant Full Disk Access. Then quit and relaunch AI Support Agent.",
      };
    }
    throw new Error(`[find_old_downloads] Cannot read Downloads folder: ${(err as Error).message}`);
  }
 
  // Only look at files in the top-level Downloads directory
  const fileEntries = dirents.filter((d) => d.isFile());
  const totalFiles  = fileEntries.length;
 
  const settled = await Promise.allSettled(
    fileEntries.map(async (d): Promise<OldFile | null> => {
      const full = nodePath.join(downloadsPath, d.name);
      try {
        const stat          = await fsp.stat(full);
        const ageMs         = now - stat.mtimeMs;
        const daysSinceMod  = Math.floor(ageMs / (1000 * 60 * 60 * 24));
        if (ageMs < cutoffMs) return null;
        if (stat.size < minBytes) return null;
        return {
          name:              d.name,
          path:              full,
          sizeMb:            Math.round((stat.size / (1024 * 1024)) * 100) / 100,
          lastModified:      stat.mtime.toISOString(),
          daysSinceModified: daysSinceMod,
        };
      } catch {
        return null;
      }
    }),
  );
 
  const oldFiles = settled
    .filter((r): r is PromiseFulfilledResult<OldFile | null> => r.status === "fulfilled")
    .map((r) => r.value)
    .filter((f): f is OldFile => f !== null)
    .sort((a, b) => b.sizeMb - a.sizeMb);
 
  const totalSizeMb = Math.round(
    oldFiles.reduce((s, f) => s + f.sizeMb, 0) * 100,
  ) / 100;
 
  return {
    platform,
    downloadsPath,
    totalFiles,
    oldFiles,
    totalSizeMb,
  };
}
 
// -- CLI smoke test -----------------------------------------------------------
 
if (false) {
  run({})
    .then(r => console.log(JSON.stringify(r, null, 2)))
    .catch((err: Error) => { console.error(err.message); process.exit(1); });
}