/**
* mcp/skills/listPrinters.ts — list_printers skill
*
* Lists all configured printers with their status, type (local/network), and
* current queue depth. Use at the start of any printer troubleshooting workflow.
*
* Platform strategy
* -----------------
* darwin `lpstat -p -d` for printer list and default printer,
* `lpstat -a` for acceptance status
* win32 PowerShell Get-Printer with status and job count
*
* Smoke test
* npx tsx -r dotenv/config mcp/skills/listPrinters.ts
*/
import * as os from "os" ;
import { exec } from "child_process" ;
import { promisify } from "util" ;
import { z } from "zod" ;
import { parsePrinterStatuses } from "./_shared/lpstatStatus" ;
const execAsync = promisify (exec);
// -- Meta ---------------------------------------------------------------------
export const meta = {
name: "list_printers" ,
description:
"Lists all configured printers with a canonical status (idle/processing/stopped/" +
"disabled/offline/error/unknown), canonical type (network/local/virtual), the " +
"host (IP/hostname when derivable), and current queue depth. Use at the start of " +
"any printer troubleshooting workflow." ,
riskLevel: "low" ,
destructive: false ,
requiresConsent: false ,
supportsDryRun: false ,
affectedScope: [ "user" ],
auditRequired: false ,
schema: {} as Record < string , z . ZodTypeAny >,
} as const ;
// -- Types --------------------------------------------------------------------
interface PrinterEntry {
name : string ;
/** Canonical across platforms: idle | processing | stopped | disabled | offline | error | unknown */
status : string ;
isDefault : boolean ;
/** Canonical across platforms: network | local | virtual | unknown */
type : string ;
/** IP/hostname when derivable from the device URI/port (network printers); null for USB/virtual/Bonjour. */
host : string | null ;
queueDepth : number ;
}
interface ListPrintersResult {
printers : PrinterEntry [];
defaultPrinter : string | null ;
total : number ;
}
// -- PowerShell helper --------------------------------------------------------
async function runPS ( script : string ) : Promise < string > {
const encoded = Buffer. from (script, "utf16le" ). toString ( "base64" );
const { stdout } = await execAsync (
`powershell.exe -NoProfile -NonInteractive -EncodedCommand ${ encoded }` ,
{ maxBuffer: 20 * 1024 * 1024 },
);
return stdout. trim ();
}
// -- darwin implementation ----------------------------------------------------
async function listPrintersDarwin () : Promise < ListPrintersResult > {
// Get printer status lines
let lpstatOut = "" ;
try {
({ stdout: lpstatOut } = await execAsync ( "lpstat -p -d 2>/dev/null" , {
maxBuffer: 5 * 1024 * 1024 ,
}));
} catch (err) {
lpstatOut = (err as { stdout ?: string }).stdout ?? "" ;
}
// Get acceptance status
let acceptOut = "" ;
try {
({ stdout: acceptOut } = await execAsync ( "lpstat -a 2>/dev/null" , {
maxBuffer: 5 * 1024 * 1024 ,
}));
} catch { /* ignore */ }
// Get queue depths via lpstat -o
let queueOut = "" ;
try {
({ stdout: queueOut } = await execAsync ( "lpstat -o 2>/dev/null" , {
maxBuffer: 5 * 1024 * 1024 ,
}));
} catch { /* ignore */ }
// Parse default printer
let defaultPrinter : string | null = null ;
const defaultMatch = lpstatOut. match ( / system default destination: \s + ( \S + ) / );
if (defaultMatch) defaultPrinter = defaultMatch[ 1 ];
// Count jobs per printer
const queueDepths : Map < string , number > = new Map ();
for ( const line of queueOut. split ( " \n " ). filter (Boolean)) {
// job lines: "PrinterName-NNN owner size date"
const jobMatch = line. match ( / ^ ( [ ^ -\s] + )- \d + \s / );
if (jobMatch) {
const pname = jobMatch[ 1 ];
queueDepths. set (pname, (queueDepths. get (pname) ?? 0 ) + 1 );
}
}
// Parse lpstat -p output via the shared canonical parser (handles the
// "disabled since" / no-"is" wording that the old inline regex missed —
// see _shared/lpstatStatus.ts). Kept in lockstep with check_print_queue.
const printers : PrinterEntry [] = [];
const statuses = parsePrinterStatuses (lpstatOut);
const printerLines = lpstatOut. split ( " \n " ). filter (( l ) => l. startsWith ( "printer " ));
for ( const line of printerLines) {
const nameMatch = line. match ( / ^ printer \s + ( \S + ) \s / );
if ( ! nameMatch) continue ;
const name = nameMatch[ 1 ];
const status = statuses. get (name) ?? "unknown" ;
// Resolve canonical type + the host from the device URI. The URI is read
// but only the host (IP/hostname) is surfaced — the full URI carries printer
// serial numbers (PII + Layer-4 entropy). For ipp/ipps/socket/lpd the host
// is right there in the authority; Bonjour (dnssd://) resolves via mDNS so
// the URI has no literal host (left null → Step 3 fallback prompt).
let type = "unknown" ;
let host : string | null = null ;
try {
const { stdout : uriOut } = await execAsync (
`lpstat -v "${ name . replace ( / " / g , ' \\ "' ) }" 2>/dev/null` ,
{ maxBuffer: 1 * 1024 * 1024 },
);
if ( / (?:ipps ?| socket | lpd): \/\/ / i . test (uriOut)) {
type = "network" ;
const hm = uriOut. match ( / (?:ipps ?| socket | lpd): \/\/ (?: [ ^ @/\s] * @) ? ( [ ^ /\s:?] + ) / i );
host = hm ? hm[ 1 ] : null ;
} else if ( / dnssd: \/\/ / i . test (uriOut)) {
type = "network" ; // Bonjour — host not literal in the URI
} else if ( / usb: \/\/ / i . test (uriOut)) {
type = "local" ;
} else if ( / file: |\b pdf \b / i . test (uriOut)) {
type = "virtual" ;
} else {
type = "local" ;
}
} catch { /* skip */ }
printers. push ({
name,
status, // idle | stopped | processing | disabled | unknown
isDefault: name === defaultPrinter,
type,
host,
queueDepth: queueDepths. get (name) ?? 0 ,
});
}
return { printers, defaultPrinter, total: printers. length };
}
// -- win32 implementation -----------------------------------------------------
async function listPrintersWin32 () : Promise < ListPrintersResult > {
// Location dropped — see darwin path comment above. Type alone conveys
// USB / network / shared which is what callers need.
const ps = `
$ErrorActionPreference = 'SilentlyContinue'
$printers = Get-Printer | Select-Object Name,DriverName,PortName,PrinterStatus,JobCount,Shared,Type
$default = (Get-ItemProperty -Path 'HKCU: \\ Software \\ Microsoft \\ Windows NT \\ CurrentVersion \\ Windows' -Name 'Device' -ErrorAction SilentlyContinue).Device
[PSCustomObject]@{
printers = $printers | ConvertTo-Json -Depth 2 -Compress
default = if ($default) { ($default -split ',')[0].Trim() } else { $null }
} | ConvertTo-Json -Depth 3 -Compress` . trim ();
const raw = await runPS (ps);
if ( ! raw) return { printers: [], defaultPrinter: null , total: 0 };
let outer : { printers : string ; default : string | null };
try {
outer = JSON . parse (raw);
} catch {
return { printers: [], defaultPrinter: null , total: 0 };
}
const defaultPrinter = outer.default ?? null ;
let printers : PrinterEntry [] = [];
if (outer.printers) {
try {
const arr = JSON . parse (outer.printers);
const list = Array. isArray (arr) ? arr : [arr];
printers = (list as Record < string , unknown >[]). map (( p ) => {
// Normalise Windows PrinterStatus into the canonical vocabulary.
const rawStatus = String (p.PrinterStatus ?? "" ). toLowerCase ();
const status =
rawStatus. includes ( "offline" ) ? "offline" :
rawStatus. includes ( "error" ) ? "error" :
rawStatus. includes ( "paused" ) ? "stopped" :
rawStatus. includes ( "printing" ) ? "processing" :
(rawStatus. includes ( "normal" ) || rawStatus. includes ( "idle" )) ? "idle" :
"unknown" ;
// Windows classes TCP/IP printers as Type "Local" with an IP-bearing
// port, so derive type + host from PortName, not Type. (USB/LPT/COM are
// truly local; WSD and IP/hostname ports are network.)
const port = String (p.PortName ?? "" );
let host : string | null = null ;
let type : string ;
if ( / ^ (?:usb | lpt | com | dot4 | nul | file | portprompt) / i . test (port)) {
type = "local" ;
} else if ( / ^ wsd / i . test (port)) {
type = "network" ;
} else {
const h = port. replace ( / ^ IP_ / i , "" );
if ( / ^ (?: \d {1,3} \. ) {3} \d {1,3}$ / . test (h) || / ^ [A-Za-z][A-Za-z0-9. \- ] +$ / . test (h)) {
type = "network" ;
host = h;
} else {
type = (p.Shared || String (p.Type) === "Connection" ) ? "network" : "local" ;
}
}
if (p.Shared) type = "network" ;
return {
name: String (p.Name ?? "Unknown" ),
status,
isDefault: String (p.Name) === defaultPrinter,
type,
host,
queueDepth: typeof p.JobCount === "number" ? p.JobCount : 0 ,
};
});
} catch { /* ignore */ }
}
return { printers, defaultPrinter, total: printers. length };
}
// -- Exported run function ----------------------------------------------------
export async function run ( _args : Record < string , never > = {}) : Promise < ListPrintersResult > {
const platform = os. platform ();
return platform === "win32"
? listPrintersWin32 ()
: listPrintersDarwin ();
}
// -- Smoke test ---------------------------------------------------------------
if ( false ) {
run ({})
. then ( r => console. log ( JSON . stringify (r, null , 2 )))
. catch (( err : Error ) => { console. error (err.message); process. exit ( 1 ); });
}