diff --git a/Makefile b/Makefile
index 6b27836..512ed67 100644
--- a/Makefile
+++ b/Makefile
@@ -6,7 +6,7 @@ BUILD_DIR := build-cmake
.PHONY: all clean run install uninstall \
kf5 kf5-install kf5-uninstall \
krunner-install dolphin-install plasmoid-install plasmoid-uninstall \
- tray data
+ tray data tools-install tools-uninstall
all: build data
@@ -186,6 +186,29 @@ data:
cp -rf tools/_lib $(HOME)/.config/ollie/tools/_lib
cp -rf tools/* $(HOME)/.config/ollie/tools
+# --- GUI Tools (standalone install) ---
+
+GUI_TOOLS := gui_accessibility gui_activities gui_apps gui_clipboard \
+ gui_files gui_input gui_notify gui_screenshot \
+ gui_shortcuts gui_windows
+
+tools-install:
+ @echo "=== Installing GUI automation tools ==="
+ mkdir -p $(HOME)/.config/ollie/tools
+ @for t in $(GUI_TOOLS); do \
+ install -m755 tools/$$t $(HOME)/.config/ollie/tools/$$t; \
+ echo " installed: $$t"; \
+ done
+ @echo "=== Done. Tools available in next ollie session. ==="
+
+tools-uninstall:
+ @echo "=== Removing GUI automation tools ==="
+ @for t in $(GUI_TOOLS); do \
+ rm -f $(HOME)/.config/ollie/tools/$$t; \
+ echo " removed: $$t"; \
+ done
+ @echo "=== Done ==="
+
# --- Development helpers ---
tray: build
diff --git a/prompts/tools-gui.md b/prompts/tools-gui.md
new file mode 100644
index 0000000..fad46a0
--- /dev/null
+++ b/prompts/tools-gui.md
@@ -0,0 +1,213 @@
+# GUI Automation Tools
+
+KDE desktop automation via D-Bus and accessibility interfaces. All tools use `call_tool`.
+
+**Dependencies**: `gdbus` (always), `wmctrl`/`xdotool` (window ops), `ydotool` (Wayland input), `spectacle` (screenshots), `python3-gobject` + AT-SPI2 typelib (accessibility).
+
+---
+
+## gui_clipboard
+
+Read/write the KDE clipboard via Klipper.
+
+**Args**: `[command]` or `[command, value]`
+
+Commands: `get`, `set`, `history`, `clear`
+
+```
+call_tool: calls=[{tool: "gui_clipboard", args: ["get"]}]
+call_tool: calls=[{tool: "gui_clipboard", args: ["set", "hello world"]}]
+call_tool: calls=[{tool: "gui_clipboard", args: ["history", "2"]}]
+```
+
+---
+
+## gui_notify
+
+Show desktop notifications with optional interactive action buttons. Waits for user response when actions are specified.
+
+**Args**: `[summary]` or `[summary, body, --flags...]`
+
+Flags: `--actions=label1,label2,...`, `--timeout=ms`, `--icon=name`, `--urgency=low|normal|critical`
+
+```
+call_tool: calls=[{tool: "gui_notify", args: ["Build complete", "All tests passed"]}]
+call_tool: calls=[{tool: "gui_notify", args: ["Deploy?", "Ready to push to prod", "--actions=Yes,No", "--urgency=critical"]}]
+```
+
+**Returns**: For action notifications, prints the chosen label (e.g., `Yes`) or `dismissed`/`timeout`.
+
+---
+
+## gui_screenshot
+
+Capture screenshots. Uses spectacle (KDE) with fallbacks to ImageMagick.
+
+**Args**: `[mode]` with optional flags
+
+Modes: `active` (default), `screen`, `area`, `window`
+
+Flags: `--output=path`, `--screen=N`, `--title=substr`
+
+```
+call_tool: calls=[{tool: "gui_screenshot", args: ["active"]}]
+call_tool: calls=[{tool: "gui_screenshot", args: ["screen", "--output=/tmp/shot.png"]}]
+call_tool: calls=[{tool: "gui_screenshot", args: ["window", "--title=Firefox"]}]
+```
+
+**Returns**: output file path and size.
+
+---
+
+## gui_windows
+
+Manage windows: list, focus, close, move, resize, virtual desktops.
+
+**Args**: `[command, ...params]`
+
+Commands:
+- `list` — all windows with geometry and state
+- `focus
` — raise and focus
+- `close ` — close window
+- `minimize ` / `maximize `
+- `move ` — reposition
+- `resize ` — resize
+- `desktops` — list virtual desktops
+- `desktop ` — switch to desktop N
+
+```
+call_tool: calls=[{tool: "gui_windows", args: ["list"]}]
+call_tool: calls=[{tool: "gui_windows", args: ["focus", "Kate"]}]
+call_tool: calls=[{tool: "gui_windows", args: ["move", "Terminal", "0", "0"]}]
+call_tool: calls=[{tool: "gui_windows", args: ["resize", "Firefox", "1920", "1080"]}]
+call_tool: calls=[{tool: "gui_windows", args: ["desktop", "2"]}]
+```
+
+Title matching is case-insensitive substring.
+
+---
+
+## gui_apps
+
+Launch, list, or activate applications.
+
+**Args**: `[command]` or `[command, app_name]`
+
+Commands: `list` (running), `installed` (all .desktop), `launch `, `activate `
+
+```
+call_tool: calls=[{tool: "gui_apps", args: ["launch", "firefox"]}]
+call_tool: calls=[{tool: "gui_apps", args: ["activate", "dolphin"]}]
+call_tool: calls=[{tool: "gui_apps", args: ["installed"]}]
+```
+
+---
+
+## gui_input
+
+Simulate keyboard and mouse input. Uses ydotool (Wayland) or xdotool (X11).
+
+**Args**: `[command, ...params]`
+
+Commands:
+- `key ` — press key combo (e.g., `ctrl+c`, `Return`, `alt+F4`, `super`)
+- `type ` — type a string character by character
+- `click [button] [x y]` — mouse click (1=left, 2=middle, 3=right)
+- `move [--relative]` — move cursor
+- `scroll [amount]`
+
+```
+call_tool: calls=[{tool: "gui_input", args: ["key", "ctrl+s"]}]
+call_tool: calls=[{tool: "gui_input", args: ["type", "Hello, world!"]}]
+call_tool: calls=[{tool: "gui_input", args: ["click", "1", "500", "300"]}]
+call_tool: calls=[{tool: "gui_input", args: ["scroll", "down", "5"]}]
+```
+
+**Caution**: Input simulation is a write operation — confirm intent before using `key` with destructive combos (alt+F4, ctrl+w) or `click` on unknown targets.
+
+---
+
+## gui_activities
+
+Manage KDE Plasma Activities.
+
+**Args**: `[command]` or `[command, name_or_id]`
+
+Commands: `list`, `current`, `switch `, `create `, `remove `
+
+```
+call_tool: calls=[{tool: "gui_activities", args: ["list"]}]
+call_tool: calls=[{tool: "gui_activities", args: ["switch", "Development"]}]
+call_tool: calls=[{tool: "gui_activities", args: ["create", "Research"]}]
+```
+
+---
+
+## gui_files
+
+Search files via Baloo (KDE file indexer). Falls back to `locate`.
+
+**Args**: `[query, ...flags]`
+
+Flags: `--type=document|image|video|audio|folder`, `--path=dir`, `--limit=N`
+
+```
+call_tool: calls=[{tool: "gui_files", args: ["meeting notes"]}]
+call_tool: calls=[{tool: "gui_files", args: ["invoice", "--type=document", "--path=/home/lkn/doc"]}]
+call_tool: calls=[{tool: "gui_files", args: ["screenshot", "--type=image", "--limit=10"]}]
+```
+
+---
+
+## gui_shortcuts
+
+Manage and trigger KDE global keyboard shortcuts.
+
+**Args**: `[command, ...params]`
+
+Commands:
+- `list` — all shortcut components
+- `list ` — shortcuts for that component
+- `get ` — current key binding
+- `trigger ` — invoke the shortcut action
+- `set ` — change key binding
+
+```
+call_tool: calls=[{tool: "gui_shortcuts", args: ["list"]}]
+call_tool: calls=[{tool: "gui_shortcuts", args: ["list", "kwin"]}]
+call_tool: calls=[{tool: "gui_shortcuts", args: ["trigger", "kwin", "Overview"]}]
+call_tool: calls=[{tool: "gui_shortcuts", args: ["trigger", "plasmashell", "show dashboard"]}]
+```
+
+---
+
+## gui_accessibility
+
+Full GUI automation via the AT-SPI2 accessibility tree. Inspect, navigate, and interact with any application's UI elements.
+
+**Args**: `[command, app_name, ...params]`
+
+Commands:
+- `apps` — list all accessible applications (no app_name needed)
+- `tree [depth]` — dump widget tree (default depth: 4)
+- `inspect [depth]` — brief overview (default depth: 2)
+- `find [name]` — find elements by role and optional name
+- `read ` — read text/value of element
+- `click ` — activate/click element
+- `type ` — type into element
+- `action ` — invoke named action
+
+Paths use slash-separated indices (`0/2/1`) or `role:name` patterns (`push button:OK`).
+
+```
+call_tool: calls=[{tool: "gui_accessibility", args: ["apps"]}]
+call_tool: calls=[{tool: "gui_accessibility", args: ["inspect", "Kate"]}]
+call_tool: calls=[{tool: "gui_accessibility", args: ["find", "Firefox", "push button", "Reload"]}]
+call_tool: calls=[{tool: "gui_accessibility", args: ["click", "Firefox", "push button:Reload"]}]
+call_tool: calls=[{tool: "gui_accessibility", args: ["type", "Kate", "0/0/text:untitled", "Hello world"]}]
+call_tool: calls=[{tool: "gui_accessibility", args: ["tree", "Dolphin", "3"]}]
+```
+
+**Workflow**: Use `apps` → `inspect` → `find` to locate elements, then `click`/`type`/`action` to interact.
+
+**Requires**: `python3-gobject` and AT-SPI2 typelib (`dev-libs/at-spi2-core` on Gentoo).
diff --git a/tools/gui_accessibility b/tools/gui_accessibility
new file mode 100755
index 0000000..f9919f0
--- /dev/null
+++ b/tools/gui_accessibility
@@ -0,0 +1,464 @@
+#!/usr/bin/env python3
+# description: GUI automation via AT-SPI2 accessibility tree. Args: [app] [query...]
+"""Interact with any GUI application through the accessibility (AT-SPI2) interface.
+
+This uses the D-Bus-based accessibility tree to inspect, navigate, and interact
+with UI elements in any application that supports AT-SPI (virtually all GTK, Qt,
+and Electron apps).
+
+Commands:
+ tree - dump full widget tree
+ inspect [depth] - brief overview of top-level widgets
+ find [name] - find elements by role and optional name
+ read - read text/value of element
+ click - click/activate element
+ type - type text into element
+ action - invoke named action on element
+ apps - list accessible applications
+
+Paths are slash-separated indices (e.g., "0/2/1" = first child, third child, second child)
+or you can use "role:name" patterns (e.g., "push button:OK").
+
+Requires: python3 with gi (PyGObject) and Atspi typelib, OR the 'accerciser' package.
+"""
+import sys
+import os
+import time
+
+
+def check_deps():
+ """Check for AT-SPI2 dependencies."""
+ try:
+ import gi
+ gi.require_version('Atspi', '2.0')
+ from gi.repository import Atspi
+ return True
+ except (ImportError, ValueError):
+ return False
+
+
+def get_atspi():
+ import gi
+ gi.require_version('Atspi', '2.0')
+ from gi.repository import Atspi
+ return Atspi
+
+
+def list_apps():
+ """List all accessible applications."""
+ Atspi = get_atspi()
+ desktop = Atspi.get_desktop(0)
+ count = desktop.get_child_count()
+ print(f"{'INDEX':<6} {'NAME':<30} {'CHILDREN'}")
+ for i in range(count):
+ app = desktop.get_child_at_index(i)
+ if app:
+ name = app.get_name() or "(unnamed)"
+ children = app.get_child_count()
+ print(f"{i:<6} {name:<30} {children}")
+
+
+def find_app(name_substr):
+ """Find application by name substring."""
+ Atspi = get_atspi()
+ desktop = Atspi.get_desktop(0)
+ count = desktop.get_child_count()
+ for i in range(count):
+ app = desktop.get_child_at_index(i)
+ if app:
+ app_name = app.get_name() or ""
+ if name_substr.lower() in app_name.lower():
+ return app
+ return None
+
+
+def role_name(accessible):
+ """Get human-readable role name."""
+ Atspi = get_atspi()
+ try:
+ role = accessible.get_role()
+ return accessible.get_role_name()
+ except Exception:
+ return "unknown"
+
+
+def get_states(accessible):
+ """Get state set as list of strings."""
+ Atspi = get_atspi()
+ try:
+ state_set = accessible.get_state_set()
+ states = []
+ for s in dir(Atspi.StateType):
+ if s.startswith('_'):
+ continue
+ try:
+ st = getattr(Atspi.StateType, s)
+ if state_set.contains(st):
+ states.append(s.lower())
+ except Exception:
+ pass
+ return states
+ except Exception:
+ return []
+
+
+def resolve_path(root, path_str):
+ """Resolve a path string to an accessible element.
+
+ Path can be:
+ - Numeric indices: "0/2/1"
+ - Role:name pattern: "push button:OK"
+ - Mixed: "0/frame:Main/push button:OK"
+ """
+ if not path_str:
+ return root
+
+ parts = path_str.split('/')
+ current = root
+
+ for part in parts:
+ if ':' in part:
+ # Role:name pattern
+ target_role, target_name = part.split(':', 1)
+ found = False
+ count = current.get_child_count()
+ for i in range(count):
+ child = current.get_child_at_index(i)
+ if child:
+ r = role_name(child)
+ n = child.get_name() or ""
+ if (target_role.lower() in r.lower() and
+ target_name.lower() in n.lower()):
+ current = child
+ found = True
+ break
+ if not found:
+ print(f"ERROR: no child matching '{part}' under {role_name(current)}:{current.get_name()}",
+ file=sys.stderr)
+ sys.exit(1)
+ else:
+ # Numeric index
+ try:
+ idx = int(part)
+ child = current.get_child_at_index(idx)
+ if not child:
+ print(f"ERROR: no child at index {idx}", file=sys.stderr)
+ sys.exit(1)
+ current = child
+ except ValueError:
+ # Try as name match
+ found = False
+ count = current.get_child_count()
+ for i in range(count):
+ child = current.get_child_at_index(i)
+ if child and part.lower() in (child.get_name() or "").lower():
+ current = child
+ found = True
+ break
+ if not found:
+ print(f"ERROR: no child matching '{part}'", file=sys.stderr)
+ sys.exit(1)
+
+ return current
+
+
+def dump_tree(accessible, indent=0, max_depth=4, path=""):
+ """Dump accessibility tree."""
+ if indent > max_depth:
+ return
+
+ name = accessible.get_name() or ""
+ role = role_name(accessible)
+ children = accessible.get_child_count()
+
+ # Get text content if available
+ text = ""
+ try:
+ ti = accessible.get_text()
+ if ti:
+ text_content = ti.get_text(0, min(ti.get_character_count(), 50))
+ if text_content and text_content != name:
+ text = f' text="{text_content}"'
+ except Exception:
+ pass
+
+ # Get value if available
+ value = ""
+ try:
+ vi = accessible.get_value()
+ if vi:
+ value = f' value={vi.get_current_value()}'
+ except Exception:
+ pass
+
+ prefix = " " * indent
+ path_display = path or "/"
+
+ # States of interest
+ states = get_states(accessible)
+ state_flags = ""
+ if "focused" in states:
+ state_flags += " [focused]"
+ if "checked" in states:
+ state_flags += " [checked]"
+ if "selected" in states:
+ state_flags += " [selected]"
+ if not ("visible" in states and "showing" in states):
+ state_flags += " [hidden]"
+
+ print(f"{prefix}[{path_display}] {role}: \"{name}\"{text}{value}{state_flags} ({children} children)")
+
+ for i in range(children):
+ child = accessible.get_child_at_index(i)
+ if child:
+ child_path = f"{path}/{i}" if path else str(i)
+ dump_tree(child, indent + 1, max_depth, child_path)
+
+
+def find_elements(accessible, target_role, target_name="", results=None, path=""):
+ """Find elements matching role and optional name."""
+ if results is None:
+ results = []
+
+ r = role_name(accessible)
+ n = accessible.get_name() or ""
+
+ role_match = target_role.lower() in r.lower() if target_role else True
+ name_match = target_name.lower() in n.lower() if target_name else True
+
+ if role_match and name_match:
+ results.append((path or "/", r, n, accessible))
+
+ count = accessible.get_child_count()
+ for i in range(min(count, 200)): # cap to avoid infinite trees
+ child = accessible.get_child_at_index(i)
+ if child:
+ child_path = f"{path}/{i}" if path else str(i)
+ find_elements(child, target_role, target_name, results, child_path)
+
+ return results
+
+
+def read_element(accessible):
+ """Read text/value from element."""
+ # Try text interface
+ try:
+ ti = accessible.get_text()
+ if ti:
+ count = ti.get_character_count()
+ if count > 0:
+ text = ti.get_text(0, count)
+ print(text)
+ return
+ except Exception:
+ pass
+
+ # Try value interface
+ try:
+ vi = accessible.get_value()
+ if vi:
+ print(vi.get_current_value())
+ return
+ except Exception:
+ pass
+
+ # Fall back to name
+ name = accessible.get_name()
+ if name:
+ print(name)
+ else:
+ print("(no readable content)")
+
+
+def click_element(accessible):
+ """Activate/click an element."""
+ Atspi = get_atspi()
+
+ # Try action interface
+ try:
+ ai = accessible.get_action()
+ if ai:
+ n_actions = ai.get_n_actions()
+ for i in range(n_actions):
+ action_name = ai.get_action_name(i)
+ if action_name in ('click', 'activate', 'press', 'invoke'):
+ ai.do_action(i)
+ print(f"performed: {action_name}")
+ return
+ # If no preferred action found, do the first one
+ if n_actions > 0:
+ action_name = ai.get_action_name(0)
+ ai.do_action(0)
+ print(f"performed: {action_name}")
+ return
+ except Exception as e:
+ pass
+
+ # Try generating a click event via component interface
+ try:
+ ci = accessible.get_component()
+ if ci:
+ pos = ci.get_position(Atspi.CoordType.SCREEN)
+ size = ci.get_size()
+ # Click center
+ x = pos.x + size.x // 2
+ y = pos.y + size.y // 2
+ Atspi.generate_mouse_event(x, y, 'b1c')
+ print(f"clicked at: {x},{y}")
+ return
+ except Exception:
+ pass
+
+ print("ERROR: no way to click this element", file=sys.stderr)
+ sys.exit(1)
+
+
+def type_text(accessible, text):
+ """Type text into an element."""
+ Atspi = get_atspi()
+
+ # Try editable text interface
+ try:
+ ei = accessible.get_editable_text()
+ if ei:
+ # Clear existing and insert
+ ti = accessible.get_text()
+ if ti:
+ count = ti.get_character_count()
+ if count > 0:
+ ei.delete_text(0, count)
+ ei.insert_text(0, text, len(text))
+ print(f"typed: {len(text)} chars")
+ return
+ except Exception:
+ pass
+
+ # Fallback: focus and use keyboard events
+ try:
+ ci = accessible.get_component()
+ if ci:
+ ci.grab_focus()
+ time.sleep(0.1)
+ for ch in text:
+ Atspi.generate_keyboard_event(0, ch, Atspi.KeySynthType.STRING)
+ time.sleep(0.01)
+ print(f"typed via keyboard: {len(text)} chars")
+ return
+ except Exception:
+ pass
+
+ print("ERROR: cannot type into this element", file=sys.stderr)
+ sys.exit(1)
+
+
+def do_action(accessible, action_name):
+ """Invoke a named action on element."""
+ try:
+ ai = accessible.get_action()
+ if ai:
+ n_actions = ai.get_n_actions()
+ for i in range(n_actions):
+ if ai.get_action_name(i) == action_name:
+ ai.do_action(i)
+ print(f"performed: {action_name}")
+ return
+ # List available actions
+ available = [ai.get_action_name(i) for i in range(n_actions)]
+ print(f"ERROR: action '{action_name}' not found. Available: {available}", file=sys.stderr)
+ sys.exit(1)
+ except Exception as e:
+ print(f"ERROR: {e}", file=sys.stderr)
+ sys.exit(1)
+
+
+def main():
+ if not check_deps():
+ print("ERROR: AT-SPI2 dependencies not available", file=sys.stderr)
+ print("Install: pip install PyGObject (or system package python3-gobject)", file=sys.stderr)
+ print("Also need: gir1.2-atspi-2.0 (Debian/Ubuntu) or gobject-introspection with Atspi typelib", file=sys.stderr)
+ sys.exit(1)
+
+ if len(sys.argv) < 2:
+ print("usage: gui_accessibility [app] [args...]", file=sys.stderr)
+ sys.exit(1)
+
+ cmd = sys.argv[1]
+
+ if cmd == "apps":
+ list_apps()
+ return
+
+ if len(sys.argv) < 3:
+ print(f"usage: gui_accessibility {cmd} [args...]", file=sys.stderr)
+ sys.exit(1)
+
+ app_name = sys.argv[2]
+ app = find_app(app_name)
+ if not app:
+ print(f"ERROR: no accessible application matching '{app_name}'", file=sys.stderr)
+ print("Tip: run 'gui_accessibility apps' to see available applications", file=sys.stderr)
+ sys.exit(1)
+
+ if cmd == "tree":
+ depth = int(sys.argv[3]) if len(sys.argv) > 3 else 4
+ dump_tree(app, max_depth=depth)
+
+ elif cmd == "inspect":
+ depth = int(sys.argv[3]) if len(sys.argv) > 3 else 2
+ dump_tree(app, max_depth=depth)
+
+ elif cmd == "find":
+ if len(sys.argv) < 4:
+ print("usage: gui_accessibility find [name]", file=sys.stderr)
+ sys.exit(1)
+ target_role = sys.argv[3]
+ target_name = sys.argv[4] if len(sys.argv) > 4 else ""
+ results = find_elements(app, target_role, target_name)
+ if not results:
+ print(f"no elements found matching role='{target_role}' name='{target_name}'")
+ else:
+ print(f"{'PATH':<20} {'ROLE':<20} {'NAME'}")
+ for path, role, name, _ in results[:50]:
+ print(f"{path:<20} {role:<20} {name}")
+
+ elif cmd == "read":
+ if len(sys.argv) < 4:
+ print("usage: gui_accessibility read ", file=sys.stderr)
+ sys.exit(1)
+ path = sys.argv[3]
+ element = resolve_path(app, path)
+ read_element(element)
+
+ elif cmd == "click":
+ if len(sys.argv) < 4:
+ print("usage: gui_accessibility click ", file=sys.stderr)
+ sys.exit(1)
+ path = sys.argv[3]
+ element = resolve_path(app, path)
+ click_element(element)
+
+ elif cmd == "type":
+ if len(sys.argv) < 5:
+ print("usage: gui_accessibility type ", file=sys.stderr)
+ sys.exit(1)
+ path = sys.argv[3]
+ text = sys.argv[4]
+ element = resolve_path(app, path)
+ type_text(element, text)
+
+ elif cmd == "action":
+ if len(sys.argv) < 5:
+ print("usage: gui_accessibility action ", file=sys.stderr)
+ sys.exit(1)
+ path = sys.argv[3]
+ action_name = sys.argv[4]
+ element = resolve_path(app, path)
+ do_action(element, action_name)
+
+ else:
+ print(f"unknown command: {cmd}", file=sys.stderr)
+ sys.exit(1)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tools/gui_activities b/tools/gui_activities
new file mode 100755
index 0000000..3f21310
--- /dev/null
+++ b/tools/gui_activities
@@ -0,0 +1,137 @@
+#!/usr/bin/env bash
+# description: Manage KDE Plasma Activities via D-Bus. Args: [name_or_id]
+set -euo pipefail
+
+SERVICE="org.kde.ActivityManager"
+OBJ_PATH="/ActivityManager/Activities"
+IFACE="org.kde.ActivityManager.Activities"
+
+cmd="${1:-list}"
+
+case "$cmd" in
+ list)
+ # List all activities with their names and states
+ ids=$(gdbus call --session --dest "$SERVICE" \
+ --object-path "$OBJ_PATH" \
+ --method "$IFACE.ListActivities" 2>/dev/null | \
+ sed "s/^(\[//;s/\],)$//;s/'//g;s/, /\n/g")
+
+ if [ -z "$ids" ]; then
+ echo "ERROR: cannot list activities (ActivityManager not running?)" >&2
+ exit 1
+ fi
+
+ current=$(gdbus call --session --dest "$SERVICE" \
+ --object-path "$OBJ_PATH" \
+ --method "$IFACE.CurrentActivity" 2>/dev/null | \
+ sed "s/^('//;s/',)$//")
+
+ echo "ID STATE NAME"
+ while IFS= read -r id; do
+ [ -z "$id" ] && continue
+ name=$(gdbus call --session --dest "$SERVICE" \
+ --object-path "$OBJ_PATH" \
+ --method "$IFACE.ActivityName" "$id" 2>/dev/null | \
+ sed "s/^('//;s/',)$//")
+ if [ "$id" = "$current" ]; then
+ state="active"
+ else
+ state="stopped"
+ fi
+ echo "$id $state $name"
+ done <<< "$ids"
+ ;;
+ current)
+ id=$(gdbus call --session --dest "$SERVICE" \
+ --object-path "$OBJ_PATH" \
+ --method "$IFACE.CurrentActivity" 2>/dev/null | \
+ sed "s/^('//;s/',)$//")
+ name=$(gdbus call --session --dest "$SERVICE" \
+ --object-path "$OBJ_PATH" \
+ --method "$IFACE.ActivityName" "$id" 2>/dev/null | \
+ sed "s/^('//;s/',)$//")
+ echo "$id $name"
+ ;;
+ switch)
+ target="${2:?usage: gui_activities switch }"
+
+ # Try as ID first
+ result=$(gdbus call --session --dest "$SERVICE" \
+ --object-path "$OBJ_PATH" \
+ --method "$IFACE.SetCurrentActivity" "$target" 2>/dev/null) && {
+ echo "switched to: $target"
+ exit 0
+ }
+
+ # Search by name
+ ids=$(gdbus call --session --dest "$SERVICE" \
+ --object-path "$OBJ_PATH" \
+ --method "$IFACE.ListActivities" 2>/dev/null | \
+ sed "s/^(\[//;s/\],)$//;s/'//g;s/, /\n/g")
+
+ while IFS= read -r id; do
+ [ -z "$id" ] && continue
+ name=$(gdbus call --session --dest "$SERVICE" \
+ --object-path "$OBJ_PATH" \
+ --method "$IFACE.ActivityName" "$id" 2>/dev/null | \
+ sed "s/^('//;s/',)$//")
+ if echo "$name" | grep -qi "$target"; then
+ gdbus call --session --dest "$SERVICE" \
+ --object-path "$OBJ_PATH" \
+ --method "$IFACE.SetCurrentActivity" "$id" >/dev/null
+ echo "switched to: $name ($id)"
+ exit 0
+ fi
+ done <<< "$ids"
+
+ echo "ERROR: no activity matching '$target'" >&2
+ exit 1
+ ;;
+ create)
+ name="${2:?usage: gui_activities create }"
+ id=$(gdbus call --session --dest "$SERVICE" \
+ --object-path "$OBJ_PATH" \
+ --method "$IFACE.AddActivity" "$name" 2>/dev/null | \
+ sed "s/^('//;s/',)$//")
+ echo "created: $id ($name)"
+ ;;
+ remove)
+ target="${2:?usage: gui_activities remove }"
+
+ # Try as ID directly
+ gdbus call --session --dest "$SERVICE" \
+ --object-path "$OBJ_PATH" \
+ --method "$IFACE.RemoveActivity" "$target" 2>/dev/null && {
+ echo "removed: $target"
+ exit 0
+ }
+
+ # Search by name
+ ids=$(gdbus call --session --dest "$SERVICE" \
+ --object-path "$OBJ_PATH" \
+ --method "$IFACE.ListActivities" 2>/dev/null | \
+ sed "s/^(\[//;s/\],)$//;s/'//g;s/, /\n/g")
+
+ while IFS= read -r id; do
+ [ -z "$id" ] && continue
+ name=$(gdbus call --session --dest "$SERVICE" \
+ --object-path "$OBJ_PATH" \
+ --method "$IFACE.ActivityName" "$id" 2>/dev/null | \
+ sed "s/^('//;s/',)$//")
+ if echo "$name" | grep -qi "$target"; then
+ gdbus call --session --dest "$SERVICE" \
+ --object-path "$OBJ_PATH" \
+ --method "$IFACE.RemoveActivity" "$id" >/dev/null
+ echo "removed: $name ($id)"
+ exit 0
+ fi
+ done <<< "$ids"
+
+ echo "ERROR: no activity matching '$target'" >&2
+ exit 1
+ ;;
+ *)
+ echo "usage: gui_activities [name_or_id]" >&2
+ exit 1
+ ;;
+esac
diff --git a/tools/gui_apps b/tools/gui_apps
new file mode 100755
index 0000000..590f0b6
--- /dev/null
+++ b/tools/gui_apps
@@ -0,0 +1,104 @@
+#!/usr/bin/env bash
+# description: Launch/list/activate applications via D-Bus and KService. Args: [app_name_or_desktop_file]
+set -euo pipefail
+
+cmd="${1:-list}"
+
+case "$cmd" in
+ list)
+ # List running applications via KWin
+ if command -v wmctrl &>/dev/null; then
+ echo "CLASS TITLE"
+ wmctrl -l -x | while read -r wid desktop class host title; do
+ echo "$class $title"
+ done
+ elif command -v xdotool &>/dev/null; then
+ echo "WID NAME"
+ xdotool search --onlyvisible --name '' 2>/dev/null | while read -r wid; do
+ name=$(xdotool getwindowname "$wid" 2>/dev/null || echo '?')
+ echo "$wid $name"
+ done
+ else
+ # D-Bus fallback: list org.freedesktop.DBus registered names
+ echo "ERROR: install wmctrl or xdotool for window listing" >&2
+ exit 1
+ fi
+ ;;
+ launch)
+ app="${2:?usage: gui_apps launch }"
+
+ # Try gtk-launch first (handles .desktop files)
+ if command -v gtk-launch &>/dev/null; then
+ gtk-launch "$app" 2>/dev/null && { echo "launched: $app"; exit 0; }
+ fi
+
+ # Try kioclient
+ if command -v kioclient5 &>/dev/null || command -v kioclient &>/dev/null; then
+ kio=$(command -v kioclient5 || command -v kioclient)
+ "$kio" exec "applications:$app.desktop" 2>/dev/null && { echo "launched: $app"; exit 0; }
+ fi
+
+ # Try gio
+ if command -v gio &>/dev/null; then
+ # Find .desktop file
+ desktop_file=$(find /usr/share/applications ~/.local/share/applications \
+ -name "*${app}*.desktop" 2>/dev/null | head -1)
+ if [ -n "$desktop_file" ]; then
+ gio launch "$desktop_file" && { echo "launched: $app"; exit 0; }
+ fi
+ fi
+
+ # Try D-Bus activation
+ gdbus call --session \
+ --dest org.freedesktop.DBus \
+ --object-path /org/freedesktop/DBus \
+ --method org.freedesktop.DBus.StartServiceByName \
+ "$app" 0 2>/dev/null && { echo "activated: $app"; exit 0; }
+
+ # Last resort: just exec it
+ if command -v "$app" &>/dev/null; then
+ setsid "$app" &>/dev/null &
+ echo "launched: $app (pid $!)"
+ else
+ echo "ERROR: cannot find or launch '$app'" >&2
+ exit 1
+ fi
+ ;;
+ activate)
+ app="${2:?usage: gui_apps activate }"
+ # Focus existing window
+ if command -v wmctrl &>/dev/null; then
+ # Try by class first
+ wid=$(wmctrl -l -x | grep -i "$app" | head -1 | awk '{print $1}')
+ if [ -n "$wid" ]; then
+ wmctrl -i -a "$wid"
+ echo "activated: $wid"
+ exit 0
+ fi
+ fi
+ if command -v xdotool &>/dev/null; then
+ wid=$(xdotool search --name "$app" 2>/dev/null | head -1)
+ if [ -n "$wid" ]; then
+ xdotool windowactivate "$wid"
+ echo "activated: $wid"
+ exit 0
+ fi
+ fi
+ echo "ERROR: no window matching '$app'" >&2
+ exit 1
+ ;;
+ installed)
+ # List installed .desktop files
+ find /usr/share/applications ~/.local/share/applications \
+ -name '*.desktop' 2>/dev/null | \
+ xargs grep -l '^Name=' | \
+ while read -r f; do
+ name=$(grep '^Name=' "$f" | head -1 | cut -d= -f2)
+ echo "$(basename "$f" .desktop) $name"
+ done | sort
+ ;;
+ *)
+ echo "usage: gui_apps [app]" >&2
+ exit 1
+ ;;
+esac
diff --git a/tools/gui_clipboard b/tools/gui_clipboard
new file mode 100755
index 0000000..fd59789
--- /dev/null
+++ b/tools/gui_clipboard
@@ -0,0 +1,43 @@
+#!/usr/bin/env bash
+# ollie:parallel read
+# description: Read/write KDE clipboard via Klipper D-Bus. Args: [get|set|history] [text|index]
+set -euo pipefail
+
+SERVICE="org.kde.klipper"
+PATH_OBJ="/klipper"
+IFACE="org.kde.klipper.klipper"
+
+cmd="${1:-get}"
+
+case "$cmd" in
+ get)
+ gdbus call --session --dest "$SERVICE" \
+ --object-path "$PATH_OBJ" \
+ --method "$IFACE.getClipboardContents" | \
+ sed "s/^('//;s/')$//;s/\\\\n/\n/g"
+ ;;
+ set)
+ text="${2:?usage: gui_clipboard set }"
+ gdbus call --session --dest "$SERVICE" \
+ --object-path "$PATH_OBJ" \
+ --method "$IFACE.setClipboardContents" "$text" >/dev/null
+ echo "OK: clipboard set"
+ ;;
+ history)
+ index="${2:-0}"
+ gdbus call --session --dest "$SERVICE" \
+ --object-path "$PATH_OBJ" \
+ --method "$IFACE.getClipboardHistoryItem" "$index" | \
+ sed "s/^('//;s/')$//;s/\\\\n/\n/g"
+ ;;
+ clear)
+ gdbus call --session --dest "$SERVICE" \
+ --object-path "$PATH_OBJ" \
+ --method "$IFACE.clearClipboardHistory" >/dev/null
+ echo "OK: clipboard history cleared"
+ ;;
+ *)
+ echo "usage: gui_clipboard [get|set|history|clear] [text|index]" >&2
+ exit 1
+ ;;
+esac
diff --git a/tools/gui_files b/tools/gui_files
new file mode 100755
index 0000000..e67870e
--- /dev/null
+++ b/tools/gui_files
@@ -0,0 +1,83 @@
+#!/usr/bin/env bash
+# ollie:parallel read
+# description: Search files using Baloo (KDE file indexer). Args: [--type=document|image|video|audio] [--path=dir] [--limit=n]
+set -euo pipefail
+
+query="${1:?usage: gui_files [--type=TYPE] [--path=DIR] [--limit=N]}"
+shift
+
+# Parse flags
+file_type=""
+path_filter=""
+limit="50"
+
+while [ $# -gt 0 ]; do
+ case "$1" in
+ --type=*) file_type="${1#--type=}" ;;
+ --path=*) path_filter="${1#--path=}" ;;
+ --limit=*) limit="${1#--limit=}" ;;
+ *) ;;
+ esac
+ shift
+done
+
+# Build baloosearch command
+cmd=(baloosearch)
+
+# Add type filter
+case "$file_type" in
+ document|doc) cmd+=(-t Document) ;;
+ image|img) cmd+=(-t Image) ;;
+ video|vid) cmd+=(-t Video) ;;
+ audio|snd) cmd+=(-t Audio) ;;
+ folder|dir) cmd+=(-t Folder) ;;
+ "")
+ ;;
+ *)
+ cmd+=(-t "$file_type")
+ ;;
+esac
+
+# Add path filter
+if [ -n "$path_filter" ]; then
+ cmd+=(-d "$path_filter")
+fi
+
+# Add limit
+cmd+=(-l "$limit")
+
+# Add query
+cmd+=("$query")
+
+# Try baloosearch first
+if command -v baloosearch &>/dev/null; then
+ "${cmd[@]}" 2>/dev/null
+ exit $?
+fi
+
+# Fallback: try baloosearch6
+if command -v baloosearch6 &>/dev/null; then
+ cmd[0]="baloosearch6"
+ "${cmd[@]}" 2>/dev/null
+ exit $?
+fi
+
+# Fallback: D-Bus query to Baloo
+if gdbus introspect --session --dest org.kde.baloo --object-path /org/kde/baloo &>/dev/null 2>&1; then
+ result=$(gdbus call --session \
+ --dest org.kde.baloo \
+ --object-path /org/kde/baloo \
+ --method org.kde.baloo.main.search "$query" "$limit" 2>/dev/null)
+ echo "$result" | sed "s/^(\[//;s/\],)$//;s/'//g;s/, /\n/g"
+ exit 0
+fi
+
+# Last fallback: locate
+if command -v locate &>/dev/null; then
+ echo "# (using locate fallback - results may be stale)" >&2
+ locate -i "$query" | head -n "$limit"
+ exit 0
+fi
+
+echo "ERROR: Baloo not available and no fallback found (install baloo-utils or mlocate)" >&2
+exit 1
diff --git a/tools/gui_input b/tools/gui_input
new file mode 100755
index 0000000..b19a723
--- /dev/null
+++ b/tools/gui_input
@@ -0,0 +1,116 @@
+#!/usr/bin/env bash
+# description: Synthetic keyboard/mouse input via ydotool (Wayland) or xdotool (X11). Args: [args...]
+set -euo pipefail
+
+cmd="${1:?usage: gui_input [args...]}"
+shift
+
+# Detect input method: prefer ydotool (Wayland-safe), fall back to xdotool
+if command -v ydotool &>/dev/null; then
+ INPUT_TOOL="ydotool"
+elif command -v xdotool &>/dev/null; then
+ INPUT_TOOL="xdotool"
+else
+ echo "ERROR: install ydotool (Wayland) or xdotool (X11)" >&2
+ exit 1
+fi
+
+case "$cmd" in
+ key)
+ # Simulate key press. Examples: "Return", "ctrl+c", "alt+F4", "super"
+ keys="${1:?usage: gui_input key }"
+ if [ "$INPUT_TOOL" = "ydotool" ]; then
+ # ydotool uses key codes; map common names
+ ydotool key "$keys"
+ else
+ xdotool key "$keys"
+ fi
+ echo "key: $keys"
+ ;;
+ type)
+ # Type a string
+ text="${1:?usage: gui_input type }"
+ if [ "$INPUT_TOOL" = "ydotool" ]; then
+ ydotool type -- "$text"
+ else
+ xdotool type --clearmodifiers -- "$text"
+ fi
+ echo "typed: ${#text} chars"
+ ;;
+ click)
+ # Mouse click. Args: [button] [x y]
+ button="${1:-1}" # 1=left, 2=middle, 3=right
+ if [ $# -ge 3 ]; then
+ x="$2"; y="$3"
+ if [ "$INPUT_TOOL" = "ydotool" ]; then
+ ydotool mousemove --absolute "$x" "$y"
+ sleep 0.05
+ ydotool click "$button"
+ else
+ xdotool mousemove "$x" "$y"
+ sleep 0.05
+ xdotool click "$button"
+ fi
+ echo "click: button=$button at $x,$y"
+ else
+ if [ "$INPUT_TOOL" = "ydotool" ]; then
+ ydotool click "$button"
+ else
+ xdotool click "$button"
+ fi
+ echo "click: button=$button at cursor"
+ fi
+ ;;
+ move)
+ # Move mouse. Args: [--relative]
+ x="${1:?usage: gui_input move [--relative]}"
+ y="${2:?usage: gui_input move [--relative]}"
+ relative="${3:-}"
+ if [ "$INPUT_TOOL" = "ydotool" ]; then
+ if [ "$relative" = "--relative" ]; then
+ ydotool mousemove -- "$x" "$y"
+ else
+ ydotool mousemove --absolute "$x" "$y"
+ fi
+ else
+ if [ "$relative" = "--relative" ]; then
+ xdotool mousemove_relative "$x" "$y"
+ else
+ xdotool mousemove "$x" "$y"
+ fi
+ fi
+ echo "moved: $x,$y${relative:+ (relative)}"
+ ;;
+ scroll)
+ # Scroll. Args: [amount]
+ direction="${1:?usage: gui_input scroll [amount]}"
+ amount="${2:-3}"
+ if [ "$INPUT_TOOL" = "ydotool" ]; then
+ case "$direction" in
+ up) ydotool mousemove --wheel -- 0 "-$amount" ;;
+ down) ydotool mousemove --wheel -- 0 "$amount" ;;
+ left) ydotool mousemove --wheel -- "-$amount" 0 ;;
+ right) ydotool mousemove --wheel -- "$amount" 0 ;;
+ esac
+ else
+ case "$direction" in
+ up) xdotool click --repeat "$amount" 4 ;;
+ down) xdotool click --repeat "$amount" 5 ;;
+ left) xdotool click --repeat "$amount" 6 ;;
+ right) xdotool click --repeat "$amount" 7 ;;
+ esac
+ fi
+ echo "scroll: $direction $amount"
+ ;;
+ *)
+ echo "usage: gui_input [args...]" >&2
+ echo "" >&2
+ echo "Commands:" >&2
+ echo " key - press key combo (e.g. ctrl+c, Return, alt+F4)" >&2
+ echo " type - type a string" >&2
+ echo " click [btn] [x y] - mouse click (1=left 2=mid 3=right)" >&2
+ echo " move [--relative] - move cursor" >&2
+ echo " scroll [amount] - scroll (up/down/left/right)" >&2
+ exit 1
+ ;;
+esac
diff --git a/tools/gui_notify b/tools/gui_notify
new file mode 100755
index 0000000..7b83a29
--- /dev/null
+++ b/tools/gui_notify
@@ -0,0 +1,119 @@
+#!/usr/bin/env python3
+# description: Show desktop notifications with optional actions. Args: [body] [--actions=label1,label2,...] [--timeout=ms] [--icon=name]
+"""Send interactive notifications via org.freedesktop.Notifications D-Bus interface.
+
+If --actions are specified, waits for user response and prints chosen action label.
+Timeout defaults to 10000ms (10s) for action notifications, 5000ms otherwise.
+"""
+import sys
+import argparse
+import subprocess
+import json
+
+def parse_args():
+ p = argparse.ArgumentParser(description="Desktop notification with optional actions")
+ p.add_argument("summary", help="Notification title")
+ p.add_argument("body", nargs="?", default="", help="Notification body text")
+ p.add_argument("--actions", default="", help="Comma-separated action labels")
+ p.add_argument("--timeout", type=int, default=0, help="Timeout in ms (0=default)")
+ p.add_argument("--icon", default="ollie", help="Icon name (default: ollie)")
+ p.add_argument("--urgency", choices=["low", "normal", "critical"], default="normal")
+ return p.parse_args()
+
+def main():
+ args = parse_args()
+
+ # Build actions array: ["id1", "label1", "id2", "label2", ...]
+ actions_list = []
+ if args.actions:
+ labels = [a.strip() for a in args.actions.split(",") if a.strip()]
+ for i, label in enumerate(labels):
+ actions_list.append(str(i))
+ actions_list.append(label)
+
+ timeout = args.timeout
+ if timeout == 0:
+ timeout = 10000 if actions_list else 5000
+
+ urgency_map = {"low": 0, "normal": 1, "critical": 2}
+
+ # Build gdbus call
+ # Notify(app_name, replaces_id, icon, summary, body, actions, hints, timeout)
+ actions_str = "[" + ", ".join(f"'{a}'" for a in actions_list) + "]"
+ hints_str = "{'urgency': }" % urgency_map[args.urgency]
+
+ cmd = [
+ "gdbus", "call", "--session",
+ "--dest", "org.freedesktop.Notifications",
+ "--object-path", "/org/freedesktop/Notifications",
+ "--method", "org.freedesktop.Notifications.Notify",
+ "ollie", # app_name
+ "0", # replaces_id (uint32)
+ args.icon, # icon
+ args.summary, # summary
+ args.body, # body
+ actions_str, # actions
+ hints_str, # hints
+ str(timeout), # timeout
+ ]
+
+ result = subprocess.run(cmd, capture_output=True, text=True)
+ if result.returncode != 0:
+ print(f"ERROR: {result.stderr.strip()}", file=sys.stderr)
+ sys.exit(1)
+
+ # Parse notification ID from "(uint32 N,)"
+ nid = result.stdout.strip().strip("()").split(",")[0].replace("uint32 ", "")
+
+ if not actions_list:
+ print(f"notification:{nid}")
+ return
+
+ # Wait for ActionInvoked or NotificationClosed signal
+ labels = [actions_list[i+1] for i in range(0, len(actions_list), 2)]
+
+ monitor_cmd = [
+ "gdbus", "monitor", "--session",
+ "--dest", "org.freedesktop.Notifications",
+ "--object-path", "/org/freedesktop/Notifications",
+ ]
+
+ import signal
+
+ class TimeoutError(Exception):
+ pass
+
+ def alarm_handler(signum, frame):
+ raise TimeoutError()
+
+ signal.signal(signal.SIGALRM, alarm_handler)
+ signal.alarm((timeout // 1000) + 2) # extra 2s grace
+
+ try:
+ proc = subprocess.Popen(monitor_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
+ for line in proc.stdout:
+ if "ActionInvoked" in line and nid in line:
+ # Parse: /org/freedesktop/Notifications: org.freedesktop.Notifications.ActionInvoked (uint32 N, 'action_id')
+ parts = line.split("'")
+ if len(parts) >= 2:
+ action_id = parts[1]
+ try:
+ idx = int(action_id)
+ print(labels[idx])
+ except (ValueError, IndexError):
+ print(action_id)
+ proc.terminate()
+ return
+ if "NotificationClosed" in line and nid in line:
+ print("dismissed")
+ proc.terminate()
+ return
+ except TimeoutError:
+ proc.terminate()
+ print("timeout")
+ except Exception as e:
+ print(f"ERROR: {e}", file=sys.stderr)
+ sys.exit(1)
+
+if __name__ == "__main__":
+ main()
diff --git a/tools/gui_screenshot b/tools/gui_screenshot
new file mode 100755
index 0000000..b748b35
--- /dev/null
+++ b/tools/gui_screenshot
@@ -0,0 +1,158 @@
+#!/usr/bin/env python3
+# description: Capture screenshots via KWin ScreenShot2 D-Bus (Wayland-safe). Args: [active|screen|area|window] [--output=path] [--screen=n]
+"""KDE screenshot tool using org.kde.KWin.ScreenShot2.
+
+Modes:
+ active - capture the currently active window (default)
+ screen - capture entire screen (or specific with --screen=N)
+ area - capture a region (interactive picker)
+ window - capture window by title substring match
+
+Output defaults to /tmp/ollie_screenshot_.png
+"""
+import sys
+import os
+import time
+import argparse
+import subprocess
+import tempfile
+
+SERVICE = "org.kde.KWin"
+OBJ_PATH = "/org/kde/KWin/ScreenShot2"
+IFACE = "org.kde.KWin.ScreenShot2"
+
+
+def parse_args():
+ p = argparse.ArgumentParser(description="KDE screenshot via KWin D-Bus")
+ p.add_argument("mode", nargs="?", default="active",
+ choices=["active", "screen", "area", "window"],
+ help="Capture mode (default: active)")
+ p.add_argument("--output", "-o", default="",
+ help="Output file path (default: /tmp/ollie_screenshot_.png)")
+ p.add_argument("--screen", type=int, default=0,
+ help="Screen index for 'screen' mode")
+ p.add_argument("--title", default="",
+ help="Window title substring for 'window' mode")
+ p.add_argument("--include-cursor", action="store_true", default=False)
+ p.add_argument("--include-decoration", action="store_true", default=True)
+ return p.parse_args()
+
+
+def get_output_path(args):
+ if args.output:
+ return args.output
+ ts = int(time.time())
+ return f"/tmp/ollie_screenshot_{ts}.png"
+
+
+def capture_with_spectacle(mode, output, title=""):
+ """Fallback: use spectacle CLI if ScreenShot2 is unavailable."""
+ cmd = ["spectacle", "-b", "-n", "-o", output]
+ if mode == "active":
+ cmd.append("-a")
+ elif mode == "screen":
+ cmd.append("-f")
+ elif mode == "area":
+ cmd.append("-r")
+ elif mode == "window":
+ cmd.append("-a") # fall back to active window
+
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
+ if result.returncode != 0:
+ return None
+ return output
+
+
+def find_window_id_by_title(title):
+ """Use KWin scripting to find window UUID by title."""
+ script = f"""
+ const clients = workspace.windowList();
+ for (let i = 0; i < clients.length; i++) {{
+ if (clients[i].caption.toLowerCase().includes('{title.lower()}')) {{
+ print(clients[i].internalId);
+ break;
+ }}
+ }}
+ """
+ # Load script via D-Bus
+ load_cmd = [
+ "gdbus", "call", "--session",
+ "--dest", "org.kde.KWin",
+ "--object-path", "/Scripting",
+ "--method", "org.kde.kwin.Scripting.loadScript", "", script
+ ]
+ # This is complex; for now fall back to xdotool/wmctrl
+ try:
+ result = subprocess.run(
+ ["xdotool", "search", "--name", title],
+ capture_output=True, text=True, timeout=5
+ )
+ if result.stdout.strip():
+ return result.stdout.strip().split("\n")[0]
+ except (FileNotFoundError, subprocess.TimeoutExpired):
+ pass
+ return None
+
+
+def capture_via_dbus(mode, output, args):
+ """Use KWin ScreenShot2 portal-style capture with pipe fd."""
+ hints = {
+ "include-cursor": args.include_cursor,
+ "include-decoration": args.include_decoration,
+ }
+
+ # ScreenShot2 methods use file descriptors which gdbus can't handle directly.
+ # Use the portal interface instead, or fall back to spectacle.
+ # Try the portal first:
+ portal_cmd = [
+ "gdbus", "call", "--session",
+ "--dest", "org.freedesktop.portal.Desktop",
+ "--object-path", "/org/freedesktop/portal/desktop",
+ "--method", "org.freedesktop.portal.Screenshot.Screenshot",
+ "", # parent_window
+ "{'interactive': }",
+ ]
+
+ # Spectacle is more reliable for non-interactive capture
+ result = capture_with_spectacle(mode, output, args.title)
+ if result and os.path.isfile(result):
+ return result
+
+ # Last resort: try import (ImageMagick) for X11
+ if os.environ.get("DISPLAY"):
+ if mode == "active":
+ wid = subprocess.run(
+ ["xdotool", "getactivewindow"],
+ capture_output=True, text=True
+ ).stdout.strip()
+ if wid:
+ r = subprocess.run(["import", "-window", wid, output],
+ capture_output=True, timeout=10)
+ if r.returncode == 0:
+ return output
+ elif mode == "screen":
+ r = subprocess.run(["import", "-window", "root", output],
+ capture_output=True, timeout=10)
+ if r.returncode == 0:
+ return output
+
+ return None
+
+
+def main():
+ args = parse_args()
+ output = get_output_path(args)
+
+ result = capture_via_dbus(args.mode, output, args)
+
+ if result and os.path.isfile(result):
+ size = os.path.getsize(result)
+ print(f"{result} ({size} bytes)")
+ else:
+ print("ERROR: screenshot capture failed", file=sys.stderr)
+ print("Ensure spectacle or imagemagick is installed", file=sys.stderr)
+ sys.exit(1)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tools/gui_shortcuts b/tools/gui_shortcuts
new file mode 100755
index 0000000..c195513
--- /dev/null
+++ b/tools/gui_shortcuts
@@ -0,0 +1,95 @@
+#!/usr/bin/env bash
+# description: Manage KDE global shortcuts via KGlobalAccel D-Bus. Args: [component] [shortcut] [keys]
+set -euo pipefail
+
+SERVICE="org.kde.kglobalaccel"
+OBJ_PATH="/kglobalaccel"
+IFACE="org.kde.KGlobalAccel"
+
+# Also component interface
+COMP_OBJ="/component"
+COMP_IFACE="org.kde.kglobalaccel.Component"
+
+cmd="${1:-list}"
+
+case "$cmd" in
+ list)
+ # List all registered shortcut components
+ components=$(gdbus call --session --dest "$SERVICE" \
+ --object-path "$OBJ_PATH" \
+ --method "$IFACE.allComponents" 2>/dev/null | \
+ sed "s/^(\[//;s/\],)$//;s/'//g;s/, /\n/g")
+
+ if [ -z "$components" ]; then
+ echo "ERROR: KGlobalAccel not responding" >&2
+ exit 1
+ fi
+
+ if [ -n "${2:-}" ]; then
+ # List shortcuts for specific component
+ comp="$2"
+ echo "# Shortcuts for: $comp"
+ gdbus call --session --dest "$SERVICE" \
+ --object-path "/component/$comp" \
+ --method "$COMP_IFACE.shortcutNames" 2>/dev/null | \
+ sed "s/^(\[//;s/\],)$//;s/'//g;s/, /\n/g"
+ else
+ echo "$components"
+ fi
+ ;;
+ get)
+ # Get key binding for a shortcut
+ comp="${2:?usage: gui_shortcuts get }"
+ name="${3:?usage: gui_shortcuts get }"
+
+ gdbus call --session --dest "$SERVICE" \
+ --object-path "$OBJ_PATH" \
+ --method "$IFACE.shortcut" \
+ "['$comp', '$name', '', '']" 2>/dev/null
+ ;;
+ trigger)
+ # Trigger (invoke) a global shortcut by component/name
+ comp="${2:?usage: gui_shortcuts trigger }"
+ name="${3:?usage: gui_shortcuts trigger }"
+
+ # First get the shortcut keys, then simulate them
+ # Or use the invokeShortcut method if available
+ gdbus call --session --dest "$SERVICE" \
+ --object-path "/component/$comp" \
+ --method "$COMP_IFACE.invokeShortcut" "$name" 2>/dev/null && {
+ echo "triggered: $comp/$name"
+ exit 0
+ }
+
+ echo "ERROR: could not trigger $comp/$name" >&2
+ exit 1
+ ;;
+ set)
+ # Set key binding for a shortcut
+ comp="${2:?usage: gui_shortcuts set }"
+ name="${3:?usage: gui_shortcuts set }"
+ keys="${4:?usage: gui_shortcuts set }"
+
+ gdbus call --session --dest "$SERVICE" \
+ --object-path "$OBJ_PATH" \
+ --method "$IFACE.setShortcut" \
+ "['$comp', '$name', '', '']" \
+ "[['$keys']]" 0 2>/dev/null && {
+ echo "set: $comp/$name = $keys"
+ exit 0
+ }
+
+ echo "ERROR: could not set shortcut" >&2
+ exit 1
+ ;;
+ *)
+ echo "usage: gui_shortcuts [component] [shortcut] [keys]" >&2
+ echo "" >&2
+ echo "Examples:" >&2
+ echo " gui_shortcuts list - list all components" >&2
+ echo " gui_shortcuts list kwin - list kwin shortcuts" >&2
+ echo " gui_shortcuts trigger kwin 'Walk Through Windows' - trigger Alt+Tab" >&2
+ echo " gui_shortcuts get kwin 'Walk Through Windows'" >&2
+ exit 1
+ ;;
+esac
diff --git a/tools/gui_windows b/tools/gui_windows
new file mode 100755
index 0000000..4f080bf
--- /dev/null
+++ b/tools/gui_windows
@@ -0,0 +1,414 @@
+#!/usr/bin/env python3
+# description: KWin window management via D-Bus scripting. Args: [options]
+"""Manage KDE windows via KWin D-Bus scripting interface.
+
+Commands:
+ list - list all windows (title, class, geometry, desktop, state)
+ focus - raise and focus window matching title
+ close - close window matching title
+ minimize - minimize window
+ maximize - toggle maximize on window
+ move - move window to position
+ resize - resize window
+ desktops - list virtual desktops
+ desktop - switch to virtual desktop
+ tile - tile window to half-screen
+"""
+import sys
+import subprocess
+import json
+import tempfile
+import os
+import time
+
+
+def kwin_script(js_code):
+ """Execute a KWin script and return output."""
+ # Write script to temp file
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.js', delete=False) as f:
+ f.write(js_code)
+ script_path = f.name
+
+ try:
+ # Load the script
+ load_result = subprocess.run([
+ "gdbus", "call", "--session",
+ "--dest", "org.kde.KWin",
+ "--object-path", "/Scripting",
+ "--method", "org.kde.kwin.Scripting.loadScript",
+ script_path, "ollie_gui_windows"
+ ], capture_output=True, text=True, timeout=5)
+
+ if load_result.returncode != 0:
+ print(f"ERROR: loadScript failed: {load_result.stderr}", file=sys.stderr)
+ return None
+
+ # Parse script ID from "(int32 N,)"
+ script_id = load_result.stdout.strip().strip("()").split(",")[0].replace("int32 ", "")
+
+ # Run the script
+ run_result = subprocess.run([
+ "gdbus", "call", "--session",
+ "--dest", "org.kde.KWin",
+ "--object-path", f"/Scripting/Script{script_id}",
+ "--method", "org.kde.kwin.Script.run"
+ ], capture_output=True, text=True, timeout=5)
+
+ # Give script time to execute (async)
+ time.sleep(0.3)
+
+ # Read output from KWin's journal/stdout - scripts use print()
+ # Unfortunately KWin script output goes to systemd journal, not easily capturable.
+ # For listing we'll use wmctrl/qdbus instead.
+
+ # Unload
+ subprocess.run([
+ "gdbus", "call", "--session",
+ "--dest", "org.kde.KWin",
+ "--object-path", "/Scripting",
+ "--method", "org.kde.kwin.Scripting.unloadScript",
+ "ollie_gui_windows"
+ ], capture_output=True, text=True, timeout=5)
+
+ return True
+ finally:
+ os.unlink(script_path)
+
+
+def list_windows_wmctrl():
+ """List windows using wmctrl (works on X11 and XWayland)."""
+ try:
+ result = subprocess.run(
+ ["wmctrl", "-l", "-G", "-p"],
+ capture_output=True, text=True, timeout=5
+ )
+ if result.returncode == 0:
+ return result.stdout
+ except FileNotFoundError:
+ pass
+ return None
+
+
+def list_windows_qdbus():
+ """List windows using KWin's D-Bus interface for Wayland."""
+ # Use KWin scripting to dump window list to a known file
+ out_file = "/tmp/ollie_winlist.txt"
+ js = f"""
+ var clients = workspace.windowList();
+ var lines = [];
+ for (var i = 0; i < clients.length; i++) {{
+ var c = clients[i];
+ if (c.skipTaskbar || c.specialWindow) continue;
+ var geo = c.frameGeometry;
+ var state = [];
+ if (c.minimized) state.push('minimized');
+ if (c.maximized) state.push('maximized');
+ if (c.fullScreen) state.push('fullscreen');
+ if (c.active) state.push('active');
+ lines.push(
+ c.caption + '\\t' +
+ c.resourceClass + '\\t' +
+ geo.x + ',' + geo.y + ',' + geo.width + ',' + geo.height + '\\t' +
+ 'desktop:' + c.desktop + '\\t' +
+ state.join(',')
+ );
+ }}
+ callDBus('org.ollie.SessionManager', '/org/ollie/SessionManager',
+ 'org.ollie.SessionManager', 'SetConfig',
+ 'winlist', lines.join('\\n'));
+ """
+ # Alternative: write to file via script. But KWin scripts are sandboxed.
+ # Use qdbus to get window list if available
+ try:
+ result = subprocess.run(
+ ["qdbus6", "org.kde.KWin", "/KWin", "org.kde.KWin.queryWindowInfo"],
+ capture_output=True, text=True, timeout=5
+ )
+ if result.returncode == 0:
+ return result.stdout
+ except FileNotFoundError:
+ try:
+ result = subprocess.run(
+ ["qdbus", "org.kde.KWin", "/KWin", "org.kde.KWin.queryWindowInfo"],
+ capture_output=True, text=True, timeout=5
+ )
+ if result.returncode == 0:
+ return result.stdout
+ except FileNotFoundError:
+ pass
+ return None
+
+
+def list_windows():
+ """List windows using best available method."""
+ # Try wmctrl first (most parseable)
+ result = list_windows_wmctrl()
+ if result:
+ print("ID\tDESKTOP\tPID\tX\tY\tW\tH\tTITLE")
+ for line in result.strip().split("\n"):
+ parts = line.split(None, 7)
+ if len(parts) >= 8:
+ wid, desktop, pid, x, y, w, h = parts[0], parts[1], parts[2], parts[3], parts[4], parts[5], parts[6]
+ title = parts[7] if len(parts) > 7 else ""
+ # Strip hostname prefix from title
+ if " " in title:
+ title = title.split(" ", 1)[1] if title.split(" ", 1)[0].isalpha() else title
+ print(f"{wid}\t{desktop}\t{pid}\t{x}\t{y}\t{w}\t{h}\t{title}")
+ return
+
+ # Try xdotool
+ try:
+ result = subprocess.run(
+ ["xdotool", "search", "--onlyvisible", "--name", ""],
+ capture_output=True, text=True, timeout=5
+ )
+ if result.returncode == 0 and result.stdout.strip():
+ print("ID\tTITLE")
+ for wid in result.stdout.strip().split("\n"):
+ name_result = subprocess.run(
+ ["xdotool", "getwindowname", wid],
+ capture_output=True, text=True, timeout=2
+ )
+ name = name_result.stdout.strip() if name_result.returncode == 0 else "?"
+ print(f"{wid}\t{name}")
+ return
+ except FileNotFoundError:
+ pass
+
+ print("ERROR: no window listing tool available (install wmctrl or xdotool)", file=sys.stderr)
+ sys.exit(1)
+
+
+def find_window(title_substr):
+ """Find window ID by title substring."""
+ try:
+ result = subprocess.run(
+ ["wmctrl", "-l"],
+ capture_output=True, text=True, timeout=5
+ )
+ if result.returncode == 0:
+ for line in result.stdout.strip().split("\n"):
+ if title_substr.lower() in line.lower():
+ return line.split()[0]
+ except FileNotFoundError:
+ pass
+
+ try:
+ result = subprocess.run(
+ ["xdotool", "search", "--name", title_substr],
+ capture_output=True, text=True, timeout=5
+ )
+ if result.returncode == 0 and result.stdout.strip():
+ return result.stdout.strip().split("\n")[0]
+ except FileNotFoundError:
+ pass
+
+ return None
+
+
+def focus_window(title_substr):
+ wid = find_window(title_substr)
+ if not wid:
+ print(f"ERROR: no window matching '{title_substr}'", file=sys.stderr)
+ sys.exit(1)
+
+ try:
+ subprocess.run(["wmctrl", "-i", "-a", wid], check=True, timeout=5)
+ except (FileNotFoundError, subprocess.CalledProcessError):
+ try:
+ subprocess.run(["xdotool", "windowactivate", wid], check=True, timeout=5)
+ except (FileNotFoundError, subprocess.CalledProcessError) as e:
+ print(f"ERROR: {e}", file=sys.stderr)
+ sys.exit(1)
+ print(f"focused: {wid}")
+
+
+def close_window(title_substr):
+ wid = find_window(title_substr)
+ if not wid:
+ print(f"ERROR: no window matching '{title_substr}'", file=sys.stderr)
+ sys.exit(1)
+ try:
+ subprocess.run(["wmctrl", "-i", "-c", wid], check=True, timeout=5)
+ except (FileNotFoundError, subprocess.CalledProcessError):
+ try:
+ subprocess.run(["xdotool", "windowclose", wid], check=True, timeout=5)
+ except (FileNotFoundError, subprocess.CalledProcessError) as e:
+ print(f"ERROR: {e}", file=sys.stderr)
+ sys.exit(1)
+ print(f"closed: {wid}")
+
+
+def minimize_window(title_substr):
+ wid = find_window(title_substr)
+ if not wid:
+ print(f"ERROR: no window matching '{title_substr}'", file=sys.stderr)
+ sys.exit(1)
+ try:
+ subprocess.run(["xdotool", "windowminimize", wid], check=True, timeout=5)
+ except (FileNotFoundError, subprocess.CalledProcessError):
+ # KWin script fallback
+ kwin_script(f"""
+ var clients = workspace.windowList();
+ for (var i = 0; i < clients.length; i++) {{
+ if (clients[i].caption.toLowerCase().includes('{title_substr.lower()}')) {{
+ clients[i].minimized = true;
+ break;
+ }}
+ }}
+ """)
+ print(f"minimized: {wid}")
+
+
+def maximize_window(title_substr):
+ # KWin script is the clean way
+ kwin_script(f"""
+ var clients = workspace.windowList();
+ for (var i = 0; i < clients.length; i++) {{
+ if (clients[i].caption.toLowerCase().includes('{title_substr.lower()}')) {{
+ clients[i].setMaximize(!clients[i].maximized, !clients[i].maximized);
+ break;
+ }}
+ }}
+ """)
+ print(f"toggled maximize: {title_substr}")
+
+
+def move_window(title_substr, x, y):
+ wid = find_window(title_substr)
+ if not wid:
+ print(f"ERROR: no window matching '{title_substr}'", file=sys.stderr)
+ sys.exit(1)
+ try:
+ subprocess.run(["wmctrl", "-i", "-r", wid, "-e", f"0,{x},{y},-1,-1"],
+ check=True, timeout=5)
+ except (FileNotFoundError, subprocess.CalledProcessError):
+ try:
+ subprocess.run(["xdotool", "windowmove", wid, str(x), str(y)],
+ check=True, timeout=5)
+ except (FileNotFoundError, subprocess.CalledProcessError) as e:
+ print(f"ERROR: {e}", file=sys.stderr)
+ sys.exit(1)
+ print(f"moved {wid} to {x},{y}")
+
+
+def resize_window(title_substr, w, h):
+ wid = find_window(title_substr)
+ if not wid:
+ print(f"ERROR: no window matching '{title_substr}'", file=sys.stderr)
+ sys.exit(1)
+ try:
+ subprocess.run(["wmctrl", "-i", "-r", wid, "-e", f"0,-1,-1,{w},{h}"],
+ check=True, timeout=5)
+ except (FileNotFoundError, subprocess.CalledProcessError):
+ try:
+ subprocess.run(["xdotool", "windowsize", wid, str(w), str(h)],
+ check=True, timeout=5)
+ except (FileNotFoundError, subprocess.CalledProcessError) as e:
+ print(f"ERROR: {e}", file=sys.stderr)
+ sys.exit(1)
+ print(f"resized {wid} to {w}x{h}")
+
+
+def list_desktops():
+ try:
+ result = subprocess.run(
+ ["wmctrl", "-d"],
+ capture_output=True, text=True, timeout=5
+ )
+ if result.returncode == 0:
+ print(result.stdout.rstrip())
+ return
+ except FileNotFoundError:
+ pass
+
+ # Fallback via KWin D-Bus
+ try:
+ result = subprocess.run([
+ "gdbus", "call", "--session",
+ "--dest", "org.kde.KWin",
+ "--object-path", "/VirtualDesktopManager",
+ "--method", "org.freedesktop.DBus.Properties.Get",
+ "org.kde.KWin.VirtualDesktopManager", "desktops"
+ ], capture_output=True, text=True, timeout=5)
+ if result.returncode == 0:
+ print(result.stdout.rstrip())
+ return
+ except FileNotFoundError:
+ pass
+ print("ERROR: cannot list desktops", file=sys.stderr)
+ sys.exit(1)
+
+
+def switch_desktop(num):
+ try:
+ subprocess.run(["wmctrl", "-s", str(int(num) - 1)], check=True, timeout=5)
+ print(f"switched to desktop {num}")
+ except (FileNotFoundError, subprocess.CalledProcessError):
+ try:
+ subprocess.run([
+ "gdbus", "call", "--session",
+ "--dest", "org.kde.KWin",
+ "--object-path", "/VirtualDesktopManager",
+ "--method", "org.kde.KWin.VirtualDesktopManager.setCurrent",
+ str(num)
+ ], check=True, capture_output=True, timeout=5)
+ print(f"switched to desktop {num}")
+ except Exception as e:
+ print(f"ERROR: {e}", file=sys.stderr)
+ sys.exit(1)
+
+
+def main():
+ if len(sys.argv) < 2:
+ print("usage: gui_windows [args...]", file=sys.stderr)
+ sys.exit(1)
+
+ cmd = sys.argv[1]
+
+ if cmd == "list":
+ list_windows()
+ elif cmd == "focus":
+ if len(sys.argv) < 3:
+ print("usage: gui_windows focus ", file=sys.stderr)
+ sys.exit(1)
+ focus_window(sys.argv[2])
+ elif cmd == "close":
+ if len(sys.argv) < 3:
+ print("usage: gui_windows close ", file=sys.stderr)
+ sys.exit(1)
+ close_window(sys.argv[2])
+ elif cmd == "minimize":
+ if len(sys.argv) < 3:
+ print("usage: gui_windows minimize ", file=sys.stderr)
+ sys.exit(1)
+ minimize_window(sys.argv[2])
+ elif cmd == "maximize":
+ if len(sys.argv) < 3:
+ print("usage: gui_windows maximize ", file=sys.stderr)
+ sys.exit(1)
+ maximize_window(sys.argv[2])
+ elif cmd == "move":
+ if len(sys.argv) < 5:
+ print("usage: gui_windows move ", file=sys.stderr)
+ sys.exit(1)
+ move_window(sys.argv[2], sys.argv[3], sys.argv[4])
+ elif cmd == "resize":
+ if len(sys.argv) < 5:
+ print("usage: gui_windows resize ", file=sys.stderr)
+ sys.exit(1)
+ resize_window(sys.argv[2], sys.argv[3], sys.argv[4])
+ elif cmd == "desktops":
+ list_desktops()
+ elif cmd == "desktop":
+ if len(sys.argv) < 3:
+ print("usage: gui_windows desktop ", file=sys.stderr)
+ sys.exit(1)
+ switch_desktop(sys.argv[2])
+ else:
+ print(f"unknown command: {cmd}", file=sys.stderr)
+ sys.exit(1)
+
+
+if __name__ == "__main__":
+ main()