Clears the per-app microphone, camera, and speaker selection for one or more collab apps (Teams, Slack, Zoom, Webex) so each app re-detects on next launch. Use when the user reports an app is stuck on a disconnected or unintended A/V device. Takes a batch — pass every app to reset in a single call so the user sees one preview and approves once. Does not change broader preferences or sign the user out.
/** * mcp/skills/resetAvDeviceSelection.ts — reset_av_device_selection skill * * Clears the per-app saved microphone / camera / speaker selection so the * collab app re-detects on next launch. Used when a user reports the app * is stuck on a disconnected mic / camera even after replugging the * intended device. * * Each app stores its A/V selection in different places. This tool * targets ONLY the audio/video device selection keys, never broader * preferences (sign-in, notification settings, etc.). * * Per-app strategy * ---------------- * Zoom macOS plist `~/Library/Preferences/us.zoom.xos.plist`, * keys: ZoomChat.Audio.MicID / SpeakerID, ZoomChat.Video.CameraID * (`defaults delete` per key). * Teams New Teams settings file * `~/Library/Group Containers/UBF8T346G9.com.microsoft.teams/.../media-stack-config.json` * — JSON object, we delete the audio/video keys. * Slack `~/Library/Application Support/Slack/storage/notifications-config.json` * — Slack stores call-device prefs here when present. Best- * effort: if the file is missing we report no-op. * Webex `~/Library/Application Support/Cisco Spark/Local Storage/leveldb/` * — Webex persists its A/V choice in LevelDB; safest action is * to delete the audio/video config keys via the per-account * settings file (`accounts/<accountId>/data/devices.json`). * * Windows mirrors each path under %APPDATA% / %LOCALAPPDATA%. Where a * specific JSON or plist file exists we touch ONLY that file (read, * delete the audio/video keys, write back) — never the parent dir. * * Dry-run: returns the file paths that would be touched and which keys * would be cleared, without writing to disk. */import * as os from "os";import * as path from "path";import { promises as fs } from "fs";import { z } from "zod";import type { CollabApp } from "./checkCollabAppStatus";// -- Meta ---------------------------------------------------------------------export const meta = { name: "reset_av_device_selection", description: "Clears the per-app microphone, camera, and speaker selection for one or " + "more collab apps (Teams, Slack, Zoom, Webex) so each app re-detects on " + "next launch. Use when the user reports an app is stuck on a disconnected " + "or unintended A/V device. Takes a batch — pass every app to reset in a " + "single call so the user sees one preview and approves once. Does not " + "change broader preferences or sign the user out.", riskLevel: "medium", destructive: true, requiresConsent: true, supportsDryRun: true, affectedScope: ["user"], auditRequired: true, tccCategories: ["FullDiskAccess"], outputKeys: ["apps","platform","dryRun","actions","errors"], schema: { // Batch, not a single app: G4 gates per tool CALL, so a forEach over four // apps would raise four dry-run previews and four consent cards for one // user decision. Called once with every app, the preview lists all the // affected files and one consent covers them (the disable_startup_item / // delete_files pattern — see SKILL-AUDIT-CHECKLIST §4). apps: z .array(z.enum(["teams", "slack", "zoom", "webex"])) .min(1) .describe( "Which collab apps' A/V selection to reset, in one batch. Pass every " + "app to reset in a single call — do NOT invoke this tool once per app.", ), dryRun: z .boolean() .nullable().optional() .describe("If true, report what would be reset without modifying any files."), },} as const;// -- Per-app target file registry ---------------------------------------------interface ResetTarget { /** Absolute path to the file that holds the A/V selection. */ file: string; /** "json": parse, delete keys, write back. "plist-defaults": run `defaults delete` per key. "delete-file": rm the file. */ strategy: "json" | "plist-defaults" | "delete-file"; /** For json: top-level keys to remove. For plist: keys to `defaults delete`. */ keys: string[];}function resetTargets(app: CollabApp, platform: NodeJS.Platform): ResetTarget[] { const home = os.homedir(); const appData = process.env.APPDATA ?? path.join(home, "AppData", "Roaming"); switch (app) { case "zoom": if (platform === "darwin") { return [{ file: path.join(home, "Library", "Preferences", "us.zoom.xos.plist"), strategy: "plist-defaults", keys: [ "ZoomChat.Audio.MicID", "ZoomChat.Audio.SpeakerID", "ZoomChat.Video.CameraID", "selectedMicID", "selectedSpeakerID", "selectedCameraID", ], }]; } // Windows Zoom keeps device selection in zoomus.ini — delete the // [audio] / [video] sections by removing the file (Zoom recreates // it from defaults on next launch). We cannot use ini-edit here // without an extra dep; deleting is acceptable for the alpha. return [{ file: path.join(appData, "Zoom", "data", "zoomus.ini"), strategy: "delete-file", keys: ["audio", "video"], }]; case "teams": if (platform === "darwin") { return [{ file: path.join( home, "Library", "Group Containers", "UBF8T346G9.com.microsoft.teams", "Library", "Application Support", "Microsoft", "MSTeams", "media-stack-config.json", ), strategy: "json", keys: ["audioInputDevice", "audioOutputDevice", "videoDevice", "selectedMicrophone", "selectedSpeaker", "selectedCamera"], }]; } return [{ file: path.join(appData, "Microsoft", "Teams", "media-stack-config.json"), strategy: "json", keys: ["audioInputDevice", "audioOutputDevice", "videoDevice", "selectedMicrophone", "selectedSpeaker", "selectedCamera"], }]; case "slack": if (platform === "darwin") { return [{ file: path.join(home, "Library", "Application Support", "Slack", "storage", "notifications-config.json"), strategy: "json", keys: ["microphoneId", "speakerId", "cameraId"], }]; } return [{ file: path.join(appData, "Slack", "storage", "notifications-config.json"), strategy: "json", keys: ["microphoneId", "speakerId", "cameraId"], }]; case "webex": // Webex stores A/V selection per-account. We don't enumerate // accounts here — instead remove the well-known device-selection // file under the active-account dir. Best-effort. if (platform === "darwin") { return [{ file: path.join(home, "Library", "Application Support", "Cisco Spark", "settings", "av-devices.json"), strategy: "json", keys: ["microphoneId", "speakerId", "cameraId"], }]; } return [{ file: path.join(appData, "Cisco Spark", "settings", "av-devices.json"), strategy: "json", keys: ["microphoneId", "speakerId", "cameraId"], }]; }}// -- Filesystem helpers -------------------------------------------------------async function pathExists(p: string): Promise<boolean> { try { await fs.access(p); return true; } catch { return false; }}async function clearJsonKeys(file: string, keys: string[]): Promise<{ removed: string[] }> { const raw = await fs.readFile(file, "utf8"); let parsed: Record<string, unknown>; try { parsed = JSON.parse(raw) as Record<string, unknown>; } catch { // Not JSON — nothing to do; surface as no-op. return { removed: [] }; } const removed: string[] = []; for (const k of keys) { if (k in parsed) { delete parsed[k]; removed.push(k); } } if (removed.length > 0) { await fs.writeFile(file, JSON.stringify(parsed, null, 2), "utf8"); } return { removed };}async function clearPlistDefaults(file: string, keys: string[]): Promise<{ removed: string[] }> { // Translate plist path → bundle id. e.g. `.../us.zoom.xos.plist` → `us.zoom.xos`. const base = path.basename(file, ".plist"); const removed: string[] = []; // Lazy-require to avoid pulling child_process into platforms that won't use it. // eslint-disable-next-line @typescript-eslint/no-require-imports const { execFile } = require("child_process") as typeof import("child_process"); const { promisify } = require("util") as typeof import("util"); const execFileAsync = promisify(execFile); for (const key of keys) { try { await execFileAsync("defaults", ["delete", base, key]); removed.push(key); } catch { // `defaults delete` returns non-zero if the key doesn't exist — // treat as "already cleared", not a failure. } } return { removed };}// -- Types --------------------------------------------------------------------export interface ResetActionResult { /** Which app in the batch this row belongs to. */ app: CollabApp; file: string; strategy: ResetTarget["strategy"]; keysFound: string[]; keysCleared: string[];}export interface ResetAvDeviceSelectionResult { apps: CollabApp[]; platform: NodeJS.Platform; dryRun: boolean; actions: ResetActionResult[]; errors: { app: CollabApp; file: string; message: string }[];}// -- Exported run function ----------------------------------------------------export async function run({ apps, dryRun = false,}: { apps: CollabApp[]; dryRun?: boolean;}): Promise<ResetAvDeviceSelectionResult> { const platform = os.platform(); if (platform !== "darwin" && platform !== "win32") { throw new Error(`reset_av_device_selection: unsupported platform "${platform}"`); } const list = (apps ?? []).filter(Boolean); if (list.length === 0) { throw new Error("[reset_av_device_selection] 'apps' is required and must contain at least one app."); } // De-duplicate so a repeated app doesn't clear the same file twice. const unique = [...new Set(list)]; const actions: ResetActionResult[] = []; const errors: { app: CollabApp; file: string; message: string }[] = []; for (const app of unique) { const targets = resetTargets(app, platform); for (const t of targets) { if (!(await pathExists(t.file))) { // File absent — nothing to do for this target. NOT an error: an app // that isn't installed (or has never saved a device choice) simply has // no config file, and reports a no-op action with empty key arrays. actions.push({ app, file: t.file, strategy: t.strategy, keysFound: [], keysCleared: [] }); continue; } if (dryRun) { // Dry-run: report which keys would be cleared without touching disk. // For json, we can read + intersect; for plist + delete-file, we can // only report the configured keys (we don't enumerate live plist // contents in dry-run to avoid a `defaults read` per key). let keysFound: string[] = []; if (t.strategy === "json") { try { const raw = await fs.readFile(t.file, "utf8"); const parsed = JSON.parse(raw) as Record<string, unknown>; keysFound = t.keys.filter((k) => k in parsed); } catch { keysFound = []; } } else { // plist or delete-file — assume all configured keys are candidates. keysFound = [...t.keys]; } actions.push({ app, file: t.file, strategy: t.strategy, keysFound, keysCleared: [] }); continue; } try { if (t.strategy === "json") { const { removed } = await clearJsonKeys(t.file, t.keys); actions.push({ app, file: t.file, strategy: t.strategy, keysFound: removed, keysCleared: removed }); } else if (t.strategy === "plist-defaults") { const { removed } = await clearPlistDefaults(t.file, t.keys); actions.push({ app, file: t.file, strategy: t.strategy, keysFound: removed, keysCleared: removed }); } else { // delete-file await fs.rm(t.file, { force: true }); actions.push({ app, file: t.file, strategy: t.strategy, keysFound: t.keys, keysCleared: t.keys }); } } catch (err) { errors.push({ app, file: t.file, message: (err as Error).message }); } } } return { apps: unique, platform, dryRun, actions, errors };}// -- Test helpers -------------------------------------------------------------/** Exported for unit tests only — do not use from production code. */export const __testing = { resetTargets, pathExists, clearJsonKeys,};