Code

/**
 * mcp/skills/checkCollabAppStatus.ts — check_collab_app_status skill
 *
 * Detects which of Microsoft Teams / Slack / Zoom / Cisco Webex are
 * installed on the system, reports an auth-state hint, the last-modified
 * age of the app's cache directory (a proxy for "recent activity"), and
 * the on-disk cache path (reused by clear_collab_app_cache).
 *
 * This is a read-only probe — no IPC to the apps themselves, no process
 * inspection beyond "is a process of this name running".  The data comes
 * from:
 *
 *   - Installed-app detection   : macOS `mdfind`, Windows Registry
 *   - Cache directory presence  : per-app known path, stat for existence
 *   - Cache last-modified age   : stat().mtimeMs of the cache dir
 *   - Auth-state heuristic      : per-app specific file/key.  Best-effort
 *                                 — for each app we pick one signal that
 *                                 reliably differentiates "signed in"
 *                                 from "signed out".
 *
 * When a signal can't be determined the tool returns `"unknown"` rather
 * than guessing — downstream prose branches on `"unknown"` to ask the
 * user.
 */
 
import * as os   from "os";
import * as path from "path";
import { promises as fs } from "fs";
import { z }     from "zod";
 
import {
  execAsync,
  runPS,
}                from "./_shared/platform";
 
// -- Meta ---------------------------------------------------------------------
 
export const meta = {
  name: "check_collab_app_status",
  description:
    "Detects installed collaboration apps (Microsoft Teams, Slack, Zoom, " +
    "Cisco Webex) and reports per-app installation state, auth-state hint, " +
    "last activity (cache mtime), and cache directory path. Use as Step 1 " +
    "of the collab-app repair skill before deciding which app to target. " +
    "Read-only.",
  riskLevel:       "low",
  destructive:     false,
  requiresConsent: false,
  supportsDryRun:  false,
  affectedScope:   ["user"],
  auditRequired:   false,
  schema: {
    app: z
      .enum(["teams", "slack", "zoom", "webex", "all"])
      .optional()
      .describe(
        "Which app to probe. Defaults to 'all' which returns one entry " +
        "per supported app.",
      ),
  },
} as const;
 
// -- Types --------------------------------------------------------------------
 
export type CollabApp = "teams" | "slack" | "zoom" | "webex";
 
export interface CollabAppStatus {
  app:              CollabApp;
  installed:        boolean;
  installPath:      string | null;
  /** Path to the per-user cache directory.  null when the app is not installed. */
  cachePath:        string | null;
  cacheExists:      boolean;
  cacheAgeHours:    number | null;
  /** "signed-in" | "signed-out" | "unknown" — auth-state heuristic. */
  authState:        "signed-in" | "signed-out" | "unknown";
}
 
export interface CheckCollabAppStatusResult {
  platform: NodeJS.Platform;
  apps:     CollabAppStatus[];
}
 
const ALL_APPS: CollabApp[] = ["teams", "slack", "zoom", "webex"];
 
// -- Per-app path registry ----------------------------------------------------
 
interface AppPaths {
  installProbeDarwin: string[];   // paths that, if they exist, signal install
  installProbeWin32:  string[];   // Registry Run keys + install paths
  cacheDarwin:        string;
  cacheWin32:         string;
  authSignalDarwin:   string;     // file whose presence hints signed-in
  authSignalWin32:    string;
}
 
function appPaths(app: CollabApp): AppPaths {
  const home    = os.homedir();
  const appData = process.env.APPDATA      ?? path.join(home, "AppData", "Roaming");
  const local   = process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local");
 
  switch (app) {
    case "teams":
      return {
        installProbeDarwin: ["/Applications/Microsoft Teams.app", "/Applications/Microsoft Teams (work or school).app"],
        installProbeWin32:  [
          path.join(local,   "Microsoft", "Teams"),
          path.join(local,   "Packages", "MSTeams_8wekyb3d8bbwe"),
          path.join(appData, "Microsoft", "Teams"),
        ],
        cacheDarwin:        path.join(home,    "Library", "Application Support", "Microsoft", "Teams"),
        cacheWin32:         path.join(appData, "Microsoft", "Teams"),
        authSignalDarwin:   path.join(home,    "Library", "Application Support", "Microsoft", "Teams", "Cookies"),
        authSignalWin32:    path.join(appData, "Microsoft", "Teams", "Cookies"),
      };
    case "slack":
      return {
        installProbeDarwin: ["/Applications/Slack.app"],
        installProbeWin32:  [
          path.join(appData, "Slack"),
          path.join(local,   "slack"),
        ],
        cacheDarwin:        path.join(home,    "Library", "Application Support", "Slack"),
        cacheWin32:         path.join(appData, "Slack"),
        authSignalDarwin:   path.join(home,    "Library", "Application Support", "Slack", "storage", "slack-downloads"),
        authSignalWin32:    path.join(appData, "Slack", "storage", "slack-downloads"),
      };
    case "zoom":
      return {
        installProbeDarwin: ["/Applications/zoom.us.app"],
        installProbeWin32:  [
          path.join(appData, "Zoom"),
          path.join(local,   "Zoom"),
        ],
        cacheDarwin:        path.join(home,    "Library", "Application Support", "zoom.us"),
        cacheWin32:         path.join(appData, "Zoom"),
        authSignalDarwin:   path.join(home,    "Library", "Application Support", "zoom.us", "data", "zoomus.db"),
        authSignalWin32:    path.join(appData, "Zoom", "data", "zoomus.db"),
      };
    case "webex":
      return {
        installProbeDarwin: ["/Applications/Webex.app", "/Applications/Cisco Spark.app"],
        installProbeWin32:  [
          path.join(appData, "Cisco Spark"),
          path.join(appData, "Webex"),
          path.join(local,   "CiscoSparkLauncher"),
        ],
        cacheDarwin:        path.join(home,    "Library", "Application Support", "Cisco Spark"),
        cacheWin32:         path.join(appData, "Cisco Spark"),
        authSignalDarwin:   path.join(home,    "Library", "Application Support", "Cisco Spark", "accounts"),
        authSignalWin32:    path.join(appData, "Cisco Spark", "accounts"),
      };
  }
}
 
// -- Platform implementation --------------------------------------------------
 
async function pathExists(p: string): Promise<boolean> {
  try {
    await fs.access(p);
    return true;
  } catch {
    return false;
  }
}
 
async function dirMtimeHours(p: string): Promise<number | null> {
  try {
    const s = await fs.stat(p);
    return Math.round((Date.now() - s.mtimeMs) / (1000 * 60 * 60));
  } catch {
    return null;
  }
}
 
async function firstExistingPath(candidates: string[]): Promise<string | null> {
  for (const c of candidates) {
    if (await pathExists(c)) return c;
  }
  return null;
}
 
async function probeOne(app: CollabApp, platform: NodeJS.Platform): Promise<CollabAppStatus> {
  const paths = appPaths(app);
  const installProbe = platform === "darwin" ? paths.installProbeDarwin : paths.installProbeWin32;
  const cacheDir     = platform === "darwin" ? paths.cacheDarwin        : paths.cacheWin32;
  const authSignal   = platform === "darwin" ? paths.authSignalDarwin   : paths.authSignalWin32;
 
  const installPath = await firstExistingPath(installProbe);
  const cacheExists = installPath ? await pathExists(cacheDir) : false;
  const cacheAgeHours = cacheExists ? await dirMtimeHours(cacheDir) : null;
 
  let authState: CollabAppStatus["authState"] = "unknown";
  if (installPath) {
    authState = (await pathExists(authSignal)) ? "signed-in" : "signed-out";
  }
 
  return {
    app,
    installed:     !!installPath,
    installPath,
    cachePath:     installPath ? cacheDir : null,
    cacheExists,
    cacheAgeHours,
    authState,
  };
}
 
// -- Exported run function ----------------------------------------------------
 
export async function run({
  app = "all",
}: { app?: "teams" | "slack" | "zoom" | "webex" | "all" } = {}): Promise<CheckCollabAppStatusResult> {
  const platform = os.platform();
  if (platform !== "darwin" && platform !== "win32") {
    throw new Error(`check_collab_app_status: unsupported platform "${platform}"`);
  }
 
  const targets: CollabApp[] = app === "all" ? ALL_APPS : [app];
  const apps = await Promise.all(targets.map((a) => probeOne(a, platform)));
 
  return { platform, apps };
}
 
// -- Test helpers -------------------------------------------------------------
 
/** Exported for unit tests only — do not use from production code. */
export const __testing = {
  appPaths,
  pathExists,
  dirMtimeHours,
  firstExistingPath,
  probeOne,
  // Satisfies compiler — unused helper imports stay reachable for future use.
  _unused: { execAsync, runPS },
};