From 6fb626d3007e98ba4344a18f8c2c9a713740d00a Mon Sep 17 00:00:00 2001 From: Matteo Benedetto Date: Sun, 16 Aug 2026 16:35:00 +0200 Subject: [PATCH] feat: estensione kde-automation per verifica UI KDE Plasma 6 Tool: kde_screenshot (spectacle), kde_click/kde_type/kde_mousemove (ydotool), kde_window_activate (KWin scripting), kde_ui_inspect (AT-SPI). Daemon ydotoold auto-avviato (sudoers NOPASSWD), promptGuidelines + skill kde-verify. --- .gitignore | 2 + index.ts | 275 +++++++++++++++++++++++++++++++++++++ skills/kde-verify/SKILL.md | 59 ++++++++ 3 files changed, 336 insertions(+) create mode 100644 .gitignore create mode 100644 index.ts create mode 100644 skills/kde-verify/SKILL.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..552f221 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +*.log diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..e3a1da6 --- /dev/null +++ b/index.ts @@ -0,0 +1,275 @@ +/** + * kde-automation — Estensione pi per automazione desktop KDE Plasma 6 (Wayland) + * + * Fornisce tool nativi per: + * - Screenshot (spectacle CLI) + * - Input simulato (ydotool: click, type, mousemove) + * - Gestione finestre (KWin scripting via D-Bus) + * - Ispezione UI (AT-SPI via python-dbus) + * + * Include la prassi di verifica UI come promptGuidelines e una skill /skill:kde-verify. + */ + +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { Type } from "typebox"; +import { execFile, spawn } from "node:child_process"; +import { promisify } from "node:util"; +import { existsSync, writeFileSync, mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { homedir } from "node:os"; + +const exec = promisify(execFile); +const SOCKET = "/run/user/1000/.ydotool_socket"; +const UID = 1000; +const TMP = "/tmp"; + +// ---------- helpers ---------- + +async function run(cmd: string, args: string[], env?: Record): Promise { + const { stdout, stderr } = await exec(cmd, args, { env: { ...process.env, ...env } }); + return stdout + (stderr || ""); +} + +/** Avvia ydotoold (idempotente) se il socket non esiste. Richiede sudoers NOPASSWD per ydotoold. */ +async function ensureDaemon(): Promise { + if (existsSync(SOCKET)) return "ydotoold attivo (socket presente)"; + try { + await run("pgrep", ["-x", "ydotoold"]); + return "ydotoold in esecuzione ma socket mancante: riavviare con sudo ydotoold -p " + SOCKET + " -P 0666 -o " + UID + ":" + UID; + } catch { + // non in esecuzione: avvia + } + try { + const child = spawn("sudo", ["-n", "ydotoold", "-p", SOCKET, "-P", "0666", "-o", `${UID}:${UID}`], { + detached: true, + stdio: "ignore", + }); + child.unref(); + await new Promise((r) => setTimeout(r, 1200)); + if (existsSync(SOCKET)) return "ydotoold avviato (socket creato)"; + return "ydotoold avviato ma socket non ancora presente"; + } catch (e) { + return "ERRORE avvio ydotoold: " + String(e); + } +} + +/** Attiva una finestra per resourceClass via KWin scripting. */ +async function activateWindow(resourceClass: string): Promise { + const script = `const target = ${JSON.stringify(resourceClass)}; +const clients = workspace.windowList(); +let found = false; +for (const c of clients) { + if (c.resourceClass === target) { + workspace.activeWindow = c; + found = true; + break; + } +} +print(found ? "ok" : "notfound");`; + const scriptPath = join(TMP, "kwin-activate.js"); + writeFileSync(scriptPath, script); + await run("qdbus6", ["org.kde.KWin", "/Scripting", "org.kde.kwin.Scripting.loadScript", scriptPath]); + await run("qdbus6", ["org.kde.KWin", "/Scripting", "org.kde.kwin.Scripting.start"]); + return "script KWin eseguito per " + resourceClass; +} + +/** Ispeziona l'albero AT-SPI (applicazioni + primi children). */ +async function inspectUI(depth: number): Promise { + const py = ` +import dbus, sys +bus = dbus.bus.BusConnection("unix:path=/run/user/1000/at-spi/bus_1") +root = bus.get_object("org.a11y.atspi.Registry", "/org/a11y/atspi/accessible/root") +acc = dbus.Interface(root, "org.a11y.atspi.Accessible") +def nm(o): + try: return str(o.Get("org.a11y.atspi.Accessible", "Name", dbus_interface="org.freedesktop.DBus.Properties")) or "?" + except Exception: return "?" +out = [] +for ch in acc.GetChildren(): + try: + o = bus.get_object(ch[0], ch[1]); a = dbus.Interface(o, "org.a11y.atspi.Accessible") + out.append("APP: %s | %s" % (nm(a), a.GetRoleName())) + if ${depth} > 0: + for k in a.GetChildren()[:${depth}]: + try: + ko = bus.get_object(k[0], k[1]); ka = dbus.Interface(ko, "org.a11y.atspi.Accessible") + out.append(" - %s | %s" % (nm(ka), ka.GetRoleName())) + except Exception: pass + except Exception: pass +print("\\n".join(out)) +`; + const scriptPath = join(TMP, "atspi-inspect.py"); + writeFileSync(scriptPath, py); + return run("python3", [scriptPath]); +} + +// ---------- estensione ---------- + +export default function kdeAutomation(pi: ExtensionAPI) { + // --- promptGuidelines: prassi di verifica UI KDE --- + pi.on("before_agent_start", async (event) => { + const guidelines = event.systemPromptOptions.promptGuidelines ?? []; + const kdeGuidelines = [ + "KDE AUTOMATION (desktop Plasma 6 Wayland): per verifiche UI usa i tool kde_* (kde_screenshot, kde_click, kde_type, kde_mousemove, kde_window_activate, kde_ui_inspect).", + " - Prassi di verifica: 1) kde_screenshot per catturare lo stato; 2) analizza l'immagine (agy_analyze se serve visione); 3) azione (kde_click/kde_type/kde_window_activate); 4) kde_screenshot di verifica e confronta.", + " - ydotool usa la mappa tastiera US: con layout IT i caratteri speciali (trattini, @, ecc.) vanno specificati con keycode o attenzione.", + " - Su Wayland non esiste iniezione input nativa: ydotool (via /dev/uinput) è il metodo; il daemon si avvia da solo (sudoers NOPASSWD configurato).", + " - Per finestre: kde_window_activate con resourceClass (es. org.kde.konsole, org.kde.dolphin).", + " - Dettagli completi: skill /skill:kde-verify e memoria qmem (project host-enne2-gk35).", + ]; + return { + systemPrompt: event.systemPrompt, + systemPromptOptions: { + ...event.systemPromptOptions, + promptGuidelines: [...guidelines, ...kdeGuidelines], + }, + }; + }); + + // --- skill kde-verify --- + pi.on("resources_discover", async () => { + return { + skillPaths: [join(homedir(), ".pi/agent/extensions/kde-automation/skills")], + }; + }); + + // --- comando /kde-verify --- + pi.registerCommand("kde-verify", { + description: "Mostra la prassi di verifica UI KDE (screenshot + input + finestre)", + handler: async (_args, ctx) => { + const text = [ + "PRASSI VERIFICA UI KDE (Plasma 6 Wayland):", + "1. kde_screenshot (mode=fullscreen|active|window|region, output=/tmp/x.png)", + "2. Analizza l'immagine (read o agy_analyze per visione)", + "3. Azione: kde_window_activate (resourceClass) / kde_click (x,y,button) / kde_type (text)", + "4. kde_screenshot di verifica e confronta con lo stato atteso", + "Nota: ydotool usa mappa US (layout IT: caratteri speciali diversi).", + "Daemon: si avvia da solo via sudoers NOPASSWD (ydotoold).", + ].join("\n"); + ctx.ui.notify(text, "info"); + }, + }); + + // --- tool: kde_screenshot --- + pi.registerTool({ + name: "kde_screenshot", + label: "KDE Screenshot", + description: + "Cattura uno screenshot del desktop KDE con Spectacle (nativo). mode: fullscreen (intero desktop), active (finestra attiva), window (finestra sotto cursore), region (selezione). output: percorso file PNG. delay: millisecondi di attesa. copy: copia in clipboard invece di salvare.", + parameters: Type.Object({ + mode: Type.Optional(Type.String({ description: "fullscreen | active | window | region (default fullscreen)" })), + output: Type.Optional(Type.String({ description: "Percorso file PNG (default /tmp/kde-shot-.png)" })), + delay: Type.Optional(Type.Number({ description: "Delay in millisecondi prima della cattura" })), + copy: Type.Optional(Type.Boolean({ description: "Copia in clipboard invece di salvare su file" })), + }), + async execute(_id, params) { + const mode = params.mode ?? "fullscreen"; + const out = params.output ?? `/tmp/kde-shot-${Date.now()}.png`; + const args = ["-b"]; + if (mode === "active") args.push("-a"); + else if (mode === "window") args.push("-u"); + else if (mode === "region") args.push("-r"); + else args.push("-f"); + if (params.delay) args.push("-d", String(params.delay)); + if (params.copy) args.push("-c"); + else args.push("-o", out); + await run("spectacle", args); + return { + content: [{ type: "text", text: params.copy ? "Screenshot copiato in clipboard" : `Screenshot salvato: ${out}` }], + details: { output: params.copy ? undefined : out }, + }; + }, + }); + + // --- tool: kde_click --- + pi.registerTool({ + name: "kde_click", + label: "KDE Click", + description: + "Simula un clic del mouse via ydotool. button: 1=sinistro, 2=centro, 3=destro. Se x e y sono forniti, prima muove il mouse a quelle coordinate. Richiede il daemon ydotoold (avvio automatico).", + parameters: Type.Object({ + button: Type.Optional(Type.Number({ description: "1=sinistro (default), 2=centro, 3=destro" })), + x: Type.Optional(Type.Number({ description: "Coordinata X (opzionale, muove il mouse prima del clic)" })), + y: Type.Optional(Type.Number({ description: "Coordinata Y (opzionale)" })), + }), + async execute(_id, params) { + const daemon = await ensureDaemon(); + const env = { YDOTOOL_SOCKET: SOCKET }; + let log = daemon + "\n"; + if (params.x !== undefined && params.y !== undefined) { + await run("ydotool", ["mousemove", String(params.x), String(params.y)], env); + log += `mouse -> ${params.x},${params.y}\n`; + } + await run("ydotool", ["click", String(params.button ?? 1)], env); + log += `click ${params.button ?? 1}`; + return { content: [{ type: "text", text: log }], details: {} }; + }, + }); + + // --- tool: kde_type --- + pi.registerTool({ + name: "kde_type", + label: "KDE Type", + description: + "Digita testo via ydotool nella finestra attiva. ATTENZIONE: ydotool usa la mappa tastiera US — con layout IT i caratteri speciali (trattini, @, ecc.) possono differire. Per testo semplice ASCII va bene.", + parameters: Type.Object({ + text: Type.String({ description: "Testo da digitare" }), + }), + async execute(_id, params) { + const daemon = await ensureDaemon(); + await run("ydotool", ["type", params.text], { YDOTOOL_SOCKET: SOCKET }); + return { + content: [{ type: "text", text: `${daemon}\nTesto digitato: "${params.text}"` }], + details: {}, + }; + }, + }); + + // --- tool: kde_mousemove --- + pi.registerTool({ + name: "kde_mousemove", + label: "KDE Mouse Move", + description: "Sposta il cursore del mouse alle coordinate (x, y) via ydotool.", + parameters: Type.Object({ + x: Type.Number({ description: "Coordinata X" }), + y: Type.Number({ description: "Coordinata Y" }), + }), + async execute(_id, params) { + const daemon = await ensureDaemon(); + await run("ydotool", ["mousemove", String(params.x), String(params.y)], { YDOTOOL_SOCKET: SOCKET }); + return { + content: [{ type: "text", text: `${daemon}\nMouse -> ${params.x},${params.y}` }], + details: {}, + }; + }, + }); + + // --- tool: kde_window_activate --- + pi.registerTool({ + name: "kde_window_activate", + label: "KDE Window Activate", + description: + "Attiva (porta in primo piano) la finestra con la resourceClass specificata via KWin scripting. Esempi: org.kde.konsole, org.kde.dolphin, org.kde.plasma-systemmonitor, firefox.", + parameters: Type.Object({ + resourceClass: Type.String({ description: "resourceClass della finestra (es. org.kde.konsole)" }), + }), + async execute(_id, params) { + const result = await activateWindow(params.resourceClass); + return { content: [{ type: "text", text: result }], details: {} }; + }, + }); + + // --- tool: kde_ui_inspect --- + pi.registerTool({ + name: "kde_ui_inspect", + label: "KDE UI Inspect", + description: + "Ispeziona l'albero di accessibilità AT-SPI: elenca le applicazioni e i loro primi controlli (nomi e ruoli). Utile per capire cosa è presente a schermo e identificare elementi per l'automazione.", + parameters: Type.Object({ + depth: Type.Optional(Type.Number({ description: "Quanti children mostrare per applicazione (default 3)" })), + }), + async execute(_id, params) { + const out = await inspectUI(params.depth ?? 3); + return { content: [{ type: "text", text: out }], details: {} }; + }, + }); +} diff --git a/skills/kde-verify/SKILL.md b/skills/kde-verify/SKILL.md new file mode 100644 index 0000000..313b8c0 --- /dev/null +++ b/skills/kde-verify/SKILL.md @@ -0,0 +1,59 @@ +--- +name: kde-verify +description: "Prassi di verifica UI su desktop KDE Plasma 6 (Wayland): screenshot con spectacle, input con ydotool, gestione finestre con KWin scripting, ispezione con AT-SPI. Usa questa skill quando devi verificare o automatizzare applicazioni a schermo." +metadata: + short-description: Verifica e automazione UI su KDE Plasma 6 +--- + +# Verifica UI KDE Plasma 6 (Wayland) + +Prassi operativa per verificare e automatizzare applicazioni a schermo su KDE Plasma 6 (Wayland), usando solo strumenti nativi + ydotool. + +## Ciclo di verifica + +1. **Cattura**: `kde_screenshot` (mode=fullscreen|active|window|region, output=/tmp/x.png) +2. **Analizza**: leggi l'immagine con `read` (se il modello supporta immagini) o `agy_analyze` (visione Gemini) per descrivere lo stato +3. **Azione**: `kde_window_activate` (resourceClass) → `kde_click` (x,y,button) → `kde_type` (text) +4. **Verifica**: nuovo `kde_screenshot` e confronta con lo stato atteso + +## Strumenti + +| Tool | Funzione | +|---|---| +| `kde_screenshot` | Spectacle: fullscreen/active/window/region, delay, clipboard | +| `kde_click` | ydotool click (1=sin, 2=centro, 3=destro), con mousemove opzionale | +| `kde_type` | ydotool type (testo nella finestra attiva) | +| `kde_mousemove` | ydotool mousemove (x, y) | +| `kde_window_activate` | KWin scripting: attiva finestra per resourceClass | +| `kde_ui_inspect` | AT-SPI: elenca applicazioni e controlli | + +## Comandi diretti (se i tool non bastano) + +```bash +# Screenshot +spectacle -b -f -o /tmp/x.png # fullscreen +spectacle -b -a -o /tmp/x.png # finestra attiva +spectacle -b -u -o /tmp/x.png # finestra sotto cursore + +# Input (daemon: sudo ydotoold -p /run/user/1000/.ydotool_socket -P 0666 -o 1000:1000 &) +export YDOTOOL_SOCKET=/run/user/1000/.ydotool_socket +ydotool mousemove 720 450 +ydotool click 1 +ydotool type "testo" + +# Finestre (KWin D-Bus) +qdbus6 org.kde.KWin /KWin org.kde.KWin.queryWindowInfo # info finestra attiva +qdbus6 org.kde.KWin /Scripting org.kde.kwin.Scripting.loadScript /tmp/script.js +qdbus6 org.kde.KWin /Scripting org.kde.kwin.Scripting.start + +# Clipboard (workaround per inserire testo) +qdbus6 org.kde.klipper /klipper org.kde.klipper.klipper.setClipboardContents "testo" +``` + +## Avvertenze + +- **ydotool usa la mappa tastiera US**: con layout IT i caratteri speciali (trattini → apostrofi, @, ecc.) vengono interpretati diversamente. Per testo semplice ASCII va bene. +- **Wayland non ha iniezione input nativa**: ydotool (via /dev/uinput) è il metodo; il daemon si avvia da solo (sudoers NOPASSWD per ydotoold configurato). +- **resourceClass comuni**: org.kde.konsole, org.kde.dolphin, org.kde.plasma-systemmonitor, firefox, org.kde.discover. +- **AT-SPI**: il bus è su unix:path=/run/user/1000/at-spi/bus_1; per uso avanzato serve pyatspi (non installato). +- **Dettagli e lezioni**: memoria qmem project `host-enne2-gk35` (record automazione KDE).