/**
* 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 " +
"collab app (Teams, Slack, Zoom, Webex) so the app re-detects on next " +
"launch. Use when the user reports the app is stuck on a disconnected " +
"or unintended A/V device. Does not change broader preferences or sign " +
"the user out." ,
riskLevel: "medium" ,
destructive: true ,
requiresConsent: true ,
supportsDryRun: true ,
affectedScope: [ "user" ],
auditRequired: true ,
schema: {
app: z
. enum ([ "teams" , "slack" , "zoom" , "webex" ])
. describe ( "Which collab app's A/V selection to reset." ),
dryRun: z
. boolean ()
. 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 {
file : string ;
strategy : ResetTarget [ "strategy" ];
keysFound : string [];
keysCleared : string [];
}
export interface ResetAvDeviceSelectionResult {
app : CollabApp ;
platform : NodeJS . Platform ;
dryRun : boolean ;
actions : ResetActionResult [];
errors : { file : string ; message : string }[];
}
// -- Exported run function ----------------------------------------------------
export async function run ({
app ,
dryRun = false ,
} : {
app : 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 targets = resetTargets (app, platform);
const actions : ResetActionResult [] = [];
const errors : { file : string ; message : string }[] = [];
for ( const t of targets) {
if ( ! ( await pathExists (t.file))) {
// File absent — nothing to do for this target. Not an error.
actions. push ({ 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 ({ file: t.file, strategy: t.strategy, keysFound, keysCleared: [] });
continue ;
}
try {
if (t.strategy === "json" ) {
const { removed } = await clearJsonKeys (t.file, t.keys);
actions. push ({ 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 ({ file: t.file, strategy: t.strategy, keysFound: removed, keysCleared: removed });
} else {
// delete-file
await fs. rm (t.file, { force: true });
actions. push ({ file: t.file, strategy: t.strategy, keysFound: t.keys, keysCleared: t.keys });
}
} catch (err) {
errors. push ({ file: t.file, message: (err as Error ).message });
}
}
return { app, platform, dryRun, actions, errors };
}
// -- Test helpers -------------------------------------------------------------
/** Exported for unit tests only — do not use from production code. */
export const __testing = {
resetTargets,
pathExists,
clearJsonKeys,
};