diff --git a/9p b/9p index f9a2e32..ebc82f3 160000 --- a/9p +++ b/9p @@ -1 +1 @@ -Subproject commit f9a2e3249d96bf81c5c0838d7a2ff35af610e73f +Subproject commit ebc82f338b165d6fa64d0c07d3819ef58e1a7df5 diff --git a/justfile b/justfile new file mode 100644 index 0000000..be95793 --- /dev/null +++ b/justfile @@ -0,0 +1,157 @@ +# Ollie monorepo build system +# +# Two paths: +# just (default) 9P frontend: core, 9p, acme, httpgw, webui + install +# just dbus D-Bus frontend: core, dbus (ollied), kde, httpgw, webui + install +# +# Build and install are separate phases. No sudo. + +set dotenv-load := false + +home := env_var('HOME') +cfg := home / ".config/ollie" +bin := home / "bin" + +# === Default (9P) path === +default: build-9p install-9p + +# === D-Bus path === +dbus: build-dbus install-dbus + +# === Build targets === + +build-9p: core ninep acme httpgw webui + +build-dbus: core dbus-bin kde httpgw webui + +core: + @echo === Building ollie-core === + cd core && go build ./... + +ninep: + @echo === Building ollie-9p === + cd 9p && go build -buildvcs=false -o {{bin}}/olliesrv . + cd 9p && go build -buildvcs=false -o {{bin}}/ollie-9p-mount ./cmd/ollie-9p-mount/ + +dbus-bin: + @echo === Building ollie-dbus (ollied) === + cd dbus && go build -o ollied . + +acme: + @echo === Building ollie-acme === + cd acme && go build -o {{bin}}/Ollie . + cp acme/scripts/Kmpl {{bin}}/Kmpl + cp acme/scripts/Oi {{bin}}/Oi + chmod 755 {{bin}}/Kmpl {{bin}}/Oi + +httpgw: + @echo === Building ollie-httpgw === + cd httpgw && go build -o {{bin}}/ollie-httpgw . + +webui: + @echo === Building ollie-webui === + cd webui && npm run build + +kde: + @echo === Building kde tools === + cd kde && cmake -B build-cmake -S . + cd kde && cmake --build build-cmake -j$(nproc) + +# === Install targets === + +install-9p: install-data install-scripts install-contrib + cp -f prompts/SYSTEM_PROMPT-9p.md {{cfg}}/prompts/SYSTEM_PROMPT.md + @echo === Install complete \(9P\) === + +install-dbus: install-data install-scripts install-contrib + cp -f prompts/SYSTEM_PROMPT-dbus.md {{cfg}}/prompts/SYSTEM_PROMPT.md + @echo === Install complete \(D-Bus\) === + +install-data: + @echo === Installing data === + mkdir -p {{cfg}}/agents {{cfg}}/sandbox {{cfg}}/prompts {{cfg}}/skills {{cfg}}/tools + cp -rf agents/* {{cfg}}/agents/ + cp -rf prompts/* {{cfg}}/prompts/ + cp -rf sandbox/* {{cfg}}/sandbox/ + cp -rf skills/* {{cfg}}/skills/ + cp -rf tools/* {{cfg}}/tools/ + cp doc/help.md {{cfg}}/help.md + +install-scripts: + @echo === Installing scripts === + mkdir -p {{cfg}}/scripts/s {{cfg}}/scripts/u {{cfg}}/scripts/x + cp -f scripts/s/sh {{cfg}}/scripts/s/sh + cp -f scripts/s/ls {{cfg}}/scripts/s/ls + cp -f scripts/s/kill {{cfg}}/scripts/s/kill + cp -f scripts/s/b {{cfg}}/scripts/s/b + cp -f scripts/s/bfg {{cfg}}/scripts/s/bfg + cp -f scripts/s/bbg {{cfg}}/scripts/s/bbg + cp -f scripts/s/cleanup {{cfg}}/scripts/s/cleanup + cp -f scripts/u/optimize {{cfg}}/scripts/u/optimize + cp -f scripts/u/complete {{cfg}}/scripts/u/complete + cp -f scripts/u/cascade {{cfg}}/scripts/u/cascade + cp -f scripts/x/elevate {{cfg}}/scripts/x/elevate + cp -f scripts/x/elevate-adapter-superpowerd {{cfg}}/scripts/x/elevate-adapter-superpowerd + cp -f scripts/x/elevate-adapter-start {{cfg}}/scripts/x/elevate-adapter-start + cp -f scripts/x/bd {{cfg}}/scripts/x/bd + cp -f scripts/x/prime {{cfg}}/scripts/x/prime + cp -f scripts/x/freeloader {{cfg}}/scripts/x/freeloader + chmod 755 \ + {{cfg}}/scripts/s/b \ + {{cfg}}/scripts/s/bfg \ + {{cfg}}/scripts/s/bbg \ + {{cfg}}/scripts/s/cleanup \ + {{cfg}}/scripts/u/optimize \ + {{cfg}}/scripts/u/complete \ + {{cfg}}/scripts/u/cascade \ + {{cfg}}/scripts/x/elevate \ + {{cfg}}/scripts/x/elevate-adapter-superpowerd \ + {{cfg}}/scripts/x/elevate-adapter-start \ + {{cfg}}/scripts/x/bd \ + {{cfg}}/scripts/x/prime \ + {{cfg}}/scripts/x/freeloader + +install-contrib: + @echo === Installing contrib scripts === + mkdir -p {{bin}} + cp -f contrib/ollie-remount {{bin}}/ollie-remount + cp -f scripts/s/ollie-watchdog {{bin}}/ollie-watchdog + chmod 755 {{bin}}/ollie-remount {{bin}}/ollie-watchdog + +# === Uninstall === + +uninstall: + @echo === Uninstalling ollie === + rm -f {{bin}}/olliesrv {{bin}}/ollie-9p-mount + rm -f {{bin}}/ollie-httpgw + rm -f {{bin}}/Ollie {{bin}}/Kmpl {{bin}}/Oi + rm -f {{bin}}/ollie-remount {{bin}}/ollie-watchdog + rm -rf {{cfg}} + cd dbus && make uninstall 2>/dev/null || true + cd kde && make uninstall 2>/dev/null || true + @echo === Uninstall complete === + +# === Clean === + +clean: + cd core && go clean ./... 2>/dev/null || true + rm -f {{bin}}/olliesrv {{bin}}/ollie-9p-mount + rm -f {{bin}}/ollie-httpgw + rm -f {{bin}}/Ollie {{bin}}/Kmpl {{bin}}/Oi + cd dbus && rm -f ollied 2>/dev/null || true + cd kde && rm -rf build-cmake 2>/dev/null || true + +# === Help === + +help: + @echo 'Ollie monorepo build targets:' + @echo ' just Build 9P path + install (default)' + @echo ' just dbus Build D-Bus path + install' + @echo ' just build-9p Build only (9P): core, 9p, acme, httpgw, webui' + @echo ' just build-dbus Build only (D-Bus): core, ollied, kde, httpgw, webui' + @echo ' just install-9p Install with 9P prompt' + @echo ' just install-dbus Install with D-Bus prompt' + @echo ' just uninstall Remove all installed files' + @echo ' just clean Clean build artifacts' + @echo '' + @echo 'Individual: just core | 9p | dbus-bin | acme | httpgw | webui | kde' \ No newline at end of file diff --git a/kde b/kde index 6515479..51af575 160000 --- a/kde +++ b/kde @@ -1 +1 @@ -Subproject commit 65154790bd2c6bcad6e4790fade5540cbeef1862 +Subproject commit 51af575e489ef53468b761dc1954230eef11d85f diff --git a/tools/_lib/__init__.py b/tools/_lib/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tools/_lib/lsp/__init__.py b/tools/_lib/lsp/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tools/_lib/lsp/adapters/__init__.py b/tools/_lib/lsp/adapters/__init__.py new file mode 100644 index 0000000..bf6d72b --- /dev/null +++ b/tools/_lib/lsp/adapters/__init__.py @@ -0,0 +1,22 @@ +"""Adapter registry: detect language from file path, return appropriate adapter.""" + +import os +from .gopls import Gopls +from .clangd import Clangd +from .intelephense import Intelephense + +_ADAPTERS = [Gopls, Clangd, Intelephense] + +_EXT_MAP: dict[str, type] = {} +for _a in _ADAPTERS: + for _ext in _a.extensions: + _EXT_MAP[_ext] = _a + + +def detect(filepath: str): + """Return an adapter class for the given file, or None.""" + ext = os.path.splitext(filepath)[1] + cls = _EXT_MAP.get(ext) + if cls: + return cls() + return None diff --git a/tools/_lib/lsp/adapters/clangd.py b/tools/_lib/lsp/adapters/clangd.py new file mode 100644 index 0000000..9c7df7d --- /dev/null +++ b/tools/_lib/lsp/adapters/clangd.py @@ -0,0 +1,19 @@ +"""clangd adapter.""" + +from ..workspace import find_root + + +class Clangd: + name = "clangd" + extensions = [".c", ".h", ".cc", ".cpp", ".cxx", ".hpp", ".hxx"] + root_markers = ["compile_commands.json", "compile_flags.txt", ".clangd", "CMakeLists.txt", "Makefile"] + cmd = ["clangd", "--background-index"] + + def initialization_options(self): + return {} + + def workspace_root(self, filepath: str, fallback: str | None = None) -> str: + return find_root(filepath, self.root_markers, fallback) + + def prepare_open(self, filepath: str) -> dict | None: + return None diff --git a/tools/_lib/lsp/adapters/gopls.py b/tools/_lib/lsp/adapters/gopls.py new file mode 100644 index 0000000..c7ed160 --- /dev/null +++ b/tools/_lib/lsp/adapters/gopls.py @@ -0,0 +1,24 @@ +"""gopls adapter.""" + +from ..workspace import find_root + + +class Gopls: + name = "gopls" + extensions = [".go"] + root_markers = ["go.work", "go.mod"] + cmd = ["gopls", "serve"] + + def initialization_options(self): + return { + "usePlaceholders": True, + "staticcheck": True, + "gofumpt": True, + } + + def workspace_root(self, filepath: str, fallback: str | None = None) -> str: + return find_root(filepath, self.root_markers, fallback) + + def prepare_open(self, filepath: str) -> dict | None: + """Return extra didOpen params if needed. gopls needs didOpen before queries.""" + return None # handled generically by the bridge diff --git a/tools/_lib/lsp/adapters/intelephense.py b/tools/_lib/lsp/adapters/intelephense.py new file mode 100644 index 0000000..34b45e2 --- /dev/null +++ b/tools/_lib/lsp/adapters/intelephense.py @@ -0,0 +1,19 @@ +"""intelephense adapter (PHP).""" + +from ..workspace import find_root + + +class Intelephense: + name = "intelephense" + extensions = [".php"] + root_markers = ["composer.json", "composer.lock", ".git"] + cmd = ["intelephense", "--stdio"] + + def initialization_options(self): + return {} + + def workspace_root(self, filepath: str, fallback: str | None = None) -> str: + return find_root(filepath, self.root_markers, fallback) + + def prepare_open(self, filepath: str) -> dict | None: + return None diff --git a/tools/_lib/lsp/bridge.py b/tools/_lib/lsp/bridge.py new file mode 100644 index 0000000..7d2a3ed --- /dev/null +++ b/tools/_lib/lsp/bridge.py @@ -0,0 +1,418 @@ +"""LSP bridge daemon: manages LSP server processes, accepts requests over a Unix socket.""" + +import asyncio +import json +import os +import signal +import sys +import time +from pathlib import Path +from urllib.parse import quote as url_quote + +# Ensure _lib is importable when running as a standalone daemon +_tools_dir = str(Path(__file__).resolve().parent.parent.parent) +if _tools_dir not in sys.path: + sys.path.insert(0, _tools_dir) + +SOCK_PATH = os.path.join(os.environ.get("XDG_RUNTIME_DIR", "/tmp"), "ollie-lsp.sock") +PID_PATH = SOCK_PATH + ".pid" +IDLE_TIMEOUT = 300 # 5 minutes + + +def file_uri(path: str) -> str: + return "file://" + os.path.abspath(path) + + +def uri_to_path(uri: str) -> str: + if uri.startswith("file://"): + return uri[7:] + return uri + + +class LspServer: + """A running LSP server process.""" + + def __init__(self, adapter, root: str): + self.adapter = adapter + self.root = root + self.proc: asyncio.subprocess.Process | None = None + self.req_id = 0 + self.pending: dict[int, asyncio.Future] = {} + self.initialized = False + self.open_files: set[str] = set() + self._read_task: asyncio.Task | None = None + self._buf = b"" + + async def start(self): + self.proc = await asyncio.create_subprocess_exec( + *self.adapter.cmd, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.DEVNULL, + ) + self._read_task = asyncio.create_task(self._read_loop()) + await self._initialize() + + async def stop(self): + if self.proc and self.proc.returncode is None: + await self._request("shutdown", {}) + self._notify("exit", None) + try: + await asyncio.wait_for(self.proc.wait(), timeout=5) + except asyncio.TimeoutError: + self.proc.kill() + if self._read_task: + self._read_task.cancel() + + async def _initialize(self): + result = await self._request("initialize", { + "processId": os.getpid(), + "rootUri": file_uri(self.root), + "rootPath": self.root, + "capabilities": { + "textDocument": { + "definition": {"linkSupport": True}, + "references": {}, + "hover": {"contentFormat": ["plaintext", "markdown"]}, + "rename": {"prepareSupport": True}, + "documentSymbol": {"hierarchicalDocumentSymbolSupport": True}, + "completion": {"completionItem": {"snippetSupport": False}}, + "publishDiagnostics": {}, + }, + "workspace": { + "workspaceFolders": True, + "symbol": {}, + }, + }, + "initializationOptions": self.adapter.initialization_options(), + "workspaceFolders": [{"uri": file_uri(self.root), "name": os.path.basename(self.root)}], + }) + self._notify("initialized", {}) + self.initialized = True + return result + + async def ensure_open(self, filepath: str): + if filepath in self.open_files: + return + try: + with open(filepath, "r") as f: + text = f.read() + except (OSError, UnicodeDecodeError): + text = "" + ext = os.path.splitext(filepath)[1] + lang_map = { + ".go": "go", ".c": "c", ".h": "c", ".cc": "cpp", ".cpp": "cpp", + ".cxx": "cpp", ".hpp": "cpp", ".hxx": "cpp", ".php": "php", + } + self._notify("textDocument/didOpen", { + "textDocument": { + "uri": file_uri(filepath), + "languageId": lang_map.get(ext, "plaintext"), + "version": 1, + "text": text, + } + }) + self.open_files.add(filepath) + # Give the server a moment to index the file + await asyncio.sleep(0.1) + + async def definition(self, filepath: str, line: int, col: int): + await self.ensure_open(filepath) + return await self._request("textDocument/definition", { + "textDocument": {"uri": file_uri(filepath)}, + "position": {"line": line, "character": col}, + }) + + async def references(self, filepath: str, line: int, col: int): + await self.ensure_open(filepath) + return await self._request("textDocument/references", { + "textDocument": {"uri": file_uri(filepath)}, + "position": {"line": line, "character": col}, + "context": {"includeDeclaration": True}, + }) + + async def hover(self, filepath: str, line: int, col: int): + await self.ensure_open(filepath) + return await self._request("textDocument/hover", { + "textDocument": {"uri": file_uri(filepath)}, + "position": {"line": line, "character": col}, + }) + + async def completion(self, filepath: str, line: int, col: int): + await self.ensure_open(filepath) + return await self._request("textDocument/completion", { + "textDocument": {"uri": file_uri(filepath)}, + "position": {"line": line, "character": col}, + }) + + async def rename(self, filepath: str, line: int, col: int, new_name: str): + await self.ensure_open(filepath) + return await self._request("textDocument/rename", { + "textDocument": {"uri": file_uri(filepath)}, + "position": {"line": line, "character": col}, + "newName": new_name, + }) + + async def document_symbols(self, filepath: str): + await self.ensure_open(filepath) + return await self._request("textDocument/documentSymbol", { + "textDocument": {"uri": file_uri(filepath)}, + }) + + async def workspace_symbols(self, query: str): + return await self._request("workspace/symbol", {"query": query}) + + async def diagnostics(self, filepath: str): + await self.ensure_open(filepath) + # Most servers push diagnostics via notification; we wait briefly then return cached + await asyncio.sleep(1.0) + return self._cached_diagnostics.get(file_uri(filepath), []) + + _cached_diagnostics: dict[str, list] = {} + + def _notify(self, method: str, params): + msg = {"jsonrpc": "2.0", "method": method} + if params is not None: + msg["params"] = params + self._send(msg) + + async def _request(self, method: str, params: dict, timeout: float = 30.0) -> dict | list | None: + self.req_id += 1 + rid = self.req_id + msg = {"jsonrpc": "2.0", "id": rid, "method": method, "params": params} + fut = asyncio.get_event_loop().create_future() + self.pending[rid] = fut + self._send(msg) + try: + return await asyncio.wait_for(fut, timeout=timeout) + except asyncio.TimeoutError: + self.pending.pop(rid, None) + raise + + def _send(self, msg: dict): + body = json.dumps(msg).encode() + header = f"Content-Length: {len(body)}\r\n\r\n".encode() + if self.proc and self.proc.stdin: + self.proc.stdin.write(header + body) + + async def _read_loop(self): + try: + while self.proc and self.proc.returncode is None: + # Read header + header_line = await self.proc.stdout.readline() + if not header_line: + break + content_length = 0 + while header_line and header_line.strip(): + if header_line.startswith(b"Content-Length:"): + content_length = int(header_line.split(b":")[1].strip()) + header_line = await self.proc.stdout.readline() + if content_length == 0: + continue + body = await self.proc.stdout.readexactly(content_length) + msg = json.loads(body) + self._handle_message(msg) + except (asyncio.CancelledError, asyncio.IncompleteReadError): + pass + + def _handle_message(self, msg: dict): + if "id" in msg and "method" not in msg: + # Response + rid = msg["id"] + fut = self.pending.pop(rid, None) + if fut and not fut.done(): + if "error" in msg: + fut.set_exception(RuntimeError(msg["error"].get("message", str(msg["error"])))) + else: + fut.set_result(msg.get("result")) + elif "method" in msg and "id" not in msg: + # Notification + if msg["method"] == "textDocument/publishDiagnostics": + params = msg.get("params", {}) + self._cached_diagnostics[params.get("uri", "")] = params.get("diagnostics", []) + + +class Bridge: + """The bridge daemon: manages LSP servers, handles tool requests.""" + + def __init__(self): + self.servers: dict[str, LspServer] = {} # key: "adapter_name:root" + self.last_activity = time.time() + + async def handle_request(self, data: dict) -> dict: + self.last_activity = time.time() + method = data.get("method") + filepath = data.get("file") + if not filepath: + return {"error": "missing 'file' parameter"} + + filepath = os.path.abspath(filepath) + + from _lib.lsp.adapters import detect + adapter = detect(filepath) + if not adapter: + return {"error": f"no adapter for file: {filepath}"} + + fallback = data.get("cwd") + root = adapter.workspace_root(filepath, fallback) + server = await self._get_server(adapter, root) + + try: + if method == "definition": + result = await server.definition(filepath, data["line"], data["col"]) + elif method == "references": + result = await server.references(filepath, data["line"], data["col"]) + elif method == "hover": + result = await server.hover(filepath, data["line"], data["col"]) + elif method == "completion": + result = await server.completion(filepath, data["line"], data["col"]) + elif method == "rename": + result = await server.rename(filepath, data["line"], data["col"], data["new_name"]) + if result: + applied = apply_workspace_edit(result) + return {"result": applied} + return {"result": None} + elif method == "symbols": + if data.get("workspace"): + result = await server.workspace_symbols(data.get("query", "")) + else: + result = await server.document_symbols(filepath) + elif method == "diagnostics": + result = await server.diagnostics(filepath) + else: + return {"error": f"unknown method: {method}"} + return {"result": result} + except Exception as e: + return {"error": str(e)} + + async def _get_server(self, adapter, root: str) -> LspServer: + key = f"{adapter.name}:{root}" + if key not in self.servers: + server = LspServer(adapter, root) + await server.start() + self.servers[key] = server + return self.servers[key] + + async def shutdown(self): + for server in self.servers.values(): + await server.stop() + self.servers.clear() + + +def apply_workspace_edit(edit: dict) -> dict: + """Apply a WorkspaceEdit to disk. Returns summary of changes.""" + changes = {} + # Handle documentChanges (versioned) or changes (simple) + if "documentChanges" in edit: + for doc_change in edit["documentChanges"]: + if doc_change.get("kind") in ("create", "rename", "delete"): + continue # skip resource operations for now + uri = doc_change["textDocument"]["uri"] + edits = doc_change.get("edits", []) + path = uri_to_path(uri) + changes.setdefault(path, []).extend(edits) + elif "changes" in edit: + for uri, edits in edit["changes"].items(): + path = uri_to_path(uri) + changes[path] = edits + + summary = {} + for path, edits in changes.items(): + try: + with open(path, "r") as f: + lines = f.readlines() + except OSError: + continue + + # Apply edits in reverse order (bottom-up) to preserve positions + sorted_edits = sorted(edits, key=lambda e: ( + e["range"]["start"]["line"], + e["range"]["start"]["character"] + ), reverse=True) + + for e in sorted_edits: + start = e["range"]["start"] + end = e["range"]["end"] + new_text = e["newText"] + + # Convert line/char positions to string manipulation + start_line, start_char = start["line"], start["character"] + end_line, end_char = end["line"], end["character"] + + # Build the new content + before = "".join(lines[:start_line]) + lines[start_line][:start_char] if start_line < len(lines) else "".join(lines) + after = lines[end_line][end_char:] + "".join(lines[end_line + 1:]) if end_line < len(lines) else "" + content = before + new_text + after + lines = content.splitlines(True) + + with open(path, "w") as f: + f.writelines(lines) + summary[path] = len(sorted_edits) + + return {"files_changed": summary, "total_edits": sum(summary.values())} + + +async def run_bridge(): + bridge = Bridge() + + async def handle_client(reader: asyncio.StreamReader, writer: asyncio.StreamWriter): + try: + data = await asyncio.wait_for(reader.read(1024 * 1024), timeout=5.0) + if not data: + return + request = json.loads(data) + if request.get("method") == "__shutdown": + response = {"result": "ok"} + writer.write(json.dumps(response).encode()) + await writer.drain() + writer.close() + raise SystemExit(0) + response = await bridge.handle_request(request) + writer.write(json.dumps(response).encode()) + await writer.drain() + except (json.JSONDecodeError, asyncio.TimeoutError) as e: + writer.write(json.dumps({"error": str(e)}).encode()) + await writer.drain() + finally: + writer.close() + + # Clean up stale socket + if os.path.exists(SOCK_PATH): + os.unlink(SOCK_PATH) + + server = await asyncio.start_unix_server(handle_client, path=SOCK_PATH) + os.chmod(SOCK_PATH, 0o600) + + # Write PID + with open(PID_PATH, "w") as f: + f.write(str(os.getpid())) + + # Idle timeout loop + async def idle_watchdog(): + while True: + await asyncio.sleep(30) + if time.time() - bridge.last_activity > IDLE_TIMEOUT: + await bridge.shutdown() + server.close() + return + + watchdog = asyncio.create_task(idle_watchdog()) + try: + await server.serve_forever() + except (asyncio.CancelledError, SystemExit): + pass + finally: + watchdog.cancel() + await bridge.shutdown() + if os.path.exists(SOCK_PATH): + os.unlink(SOCK_PATH) + if os.path.exists(PID_PATH): + os.unlink(PID_PATH) + + +def main(): + asyncio.run(run_bridge()) + + +if __name__ == "__main__": + main() diff --git a/tools/_lib/lsp/client.py b/tools/_lib/lsp/client.py new file mode 100644 index 0000000..cedb56a --- /dev/null +++ b/tools/_lib/lsp/client.py @@ -0,0 +1,77 @@ +"""Client library for LSP tool scripts. Connects to the bridge daemon, starting it if needed.""" + +import json +import os +import socket +import subprocess +import sys +import time + +SOCK_PATH = os.path.join(os.environ.get("XDG_RUNTIME_DIR", "/tmp"), "ollie-lsp.sock") +PID_PATH = SOCK_PATH + ".pid" + + +def _bridge_running() -> bool: + if not os.path.exists(PID_PATH): + return False + try: + pid = int(open(PID_PATH).read().strip()) + os.kill(pid, 0) + return True + except (OSError, ValueError): + return False + + +def _start_bridge(): + """Start the bridge daemon as a background process.""" + lib_dir = os.path.dirname(os.path.abspath(__file__)) + bridge_script = os.path.join(lib_dir, "bridge.py") + subprocess.Popen( + [sys.executable, bridge_script], + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + # Wait for socket to appear + for _ in range(50): + if os.path.exists(SOCK_PATH): + return + time.sleep(0.1) + print("error: bridge daemon failed to start", file=sys.stderr) + sys.exit(1) + + +def request(method: str, **kwargs) -> dict: + """Send a request to the bridge and return the response.""" + if not _bridge_running(): + _start_bridge() + + payload = {"method": method, **kwargs} + data = json.dumps(payload).encode() + + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + try: + sock.connect(SOCK_PATH) + sock.sendall(data) + sock.shutdown(socket.SHUT_WR) + chunks = [] + while True: + chunk = sock.recv(4096) + if not chunk: + break + chunks.append(chunk) + response = json.loads(b"".join(chunks)) + except ConnectionRefusedError: + # Stale socket, restart + if os.path.exists(SOCK_PATH): + os.unlink(SOCK_PATH) + _start_bridge() + return request(method, **kwargs) + finally: + sock.close() + + if "error" in response: + print(f"error: {response['error']}", file=sys.stderr) + sys.exit(1) + return response.get("result") diff --git a/tools/_lib/lsp/workspace.py b/tools/_lib/lsp/workspace.py new file mode 100644 index 0000000..8a64f13 --- /dev/null +++ b/tools/_lib/lsp/workspace.py @@ -0,0 +1,20 @@ +"""Workspace root detection: walk up from a file to find language-specific markers.""" + +import os +from pathlib import Path + + +def find_root(filepath: str, markers: list[str], fallback: str | None = None) -> str: + """Walk up from filepath looking for any marker file/dir. Return the directory containing it.""" + p = Path(filepath).resolve() + if p.is_file(): + p = p.parent + while True: + for marker in markers: + if (p / marker).exists(): + return str(p) + parent = p.parent + if parent == p: + break + p = parent + return fallback or os.getcwd() diff --git a/tools/browser_screencap b/tools/browser_screencap new file mode 100755 index 0000000..d7a9a78 --- /dev/null +++ b/tools/browser_screencap @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# capabilities: web +# description: Capture browser window showing a specific URL: capture_browser.sh [output_path] [url_pattern] +set -e + +# Verify running under landrun (test filesystem restriction) + +OUTPUT="${1:-${TMPDIR:-/tmp}/browser_capture.png}" +URL_PATTERN="${2:-localhost}" + +case "$(uname -s)" in + Darwin) + # macOS: use osascript + screencapture + osascript -e 'tell application "Google Chrome" to activate' 2>/dev/null + sleep 0.3 + osascript -e "tell application \"Google Chrome\" to set index of (first window whose name contains \"$URL_PATTERN\") to 1" 2>/dev/null + sleep 0.3 + screencapture -x "$OUTPUT" + ;; + Linux) + if [ -z "$DISPLAY" ]; then + echo "ERROR: DISPLAY not set" >&2 + exit 1 + fi + + # Linux: use brotab + wmctrl + import + TAB_INFO=$(brotab list | grep -i "$URL_PATTERN" | head -1) + if [ -z "$TAB_INFO" ]; then + echo "ERROR: No tab matching '$URL_PATTERN'" >&2 + exit 1 + fi + + TAB_ID=$(echo "$TAB_INFO" | cut -f1) + TAB_TITLE=$(echo "$TAB_INFO" | cut -f2) + echo "Found tab: $TAB_ID ($TAB_TITLE)" >&2 + + brotab activate "$TAB_ID" + sleep 0.5 + + WIN_ID=$(wmctrl -l | grep -i "$TAB_TITLE" | head -1 | awk '{print $1}') + if [ -z "$WIN_ID" ]; then + WIN_ID=$(wmctrl -l | grep -iE "firefox|chrome|chromium" | head -1 | awk '{print $1}') + fi + + if [ -z "$WIN_ID" ]; then + echo "ERROR: Could not find browser window" >&2 + exit 1 + fi + + echo "Capturing window: $WIN_ID" >&2 + wmctrl -i -a "$WIN_ID" + sleep 0.3 + import -window "$WIN_ID" "$OUTPUT" + ;; + *) + echo "ERROR: Unsupported OS: $(uname -s)" >&2 + exit 1 + ;; +esac + +if [ -f "$OUTPUT" ]; then + echo "$OUTPUT" +else + echo "ERROR: Failed to capture" >&2 + exit 1 +fi diff --git a/tools/denote_view b/tools/denote_view new file mode 100755 index 0000000..7516496 --- /dev/null +++ b/tools/denote_view @@ -0,0 +1,31 @@ +#!/bin/bash +# ollie:parallel read +# capabilities: agent-kb +# description: View a denote document by identifier +# Usage: view_denote.sh --id +set -euo pipefail + +DENOTE="${DENOTE_9MOUNT:-$HOME/mnt/denote}" + +ID="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --id) ID="$2"; shift 2 ;; + *) echo "unknown argument: $1" >&2; exit 1 ;; + esac +done + +if [ -z "$ID" ]; then + echo "usage: view_denote.sh --id " >&2 + exit 1 +fi + +ID="${ID#denote:}" + +if [ -d "$DENOTE/n/$ID" ]; then + cat "$DENOTE/n/$ID/body" +else + echo "Error: no denote document found for identifier $ID" >&2 + exit 1 +fi diff --git a/tools/env b/tools/env new file mode 100755 index 0000000..01018af --- /dev/null +++ b/tools/env @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +# ollie:parallel read +# description: Print the agent's runtime environment variables. +set -euo pipefail + +env | sort diff --git a/tools/file_edit b/tools/file_edit new file mode 100644 index 0000000..f0e70fe --- /dev/null +++ b/tools/file_edit @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +# description: Replace text in a file; old_string not found is the guard +# Args: [replace_all=true|1|yes] + +import sys +import os +import difflib + +def format_diff(path, old, new): + old_lines = old.splitlines(keepends=True) + new_lines = new.splitlines(keepends=True) + diff = ''.join(difflib.unified_diff(old_lines, new_lines, fromfile=path, tofile=path)) + if not diff: + return None + return f"```diff\n{diff}```" + +def find_exact(content, find): + return find if find in content else None + +def find_ws_normalized(content, find): + nf = ' '.join(find.split()) + find_lines = find.split('\n') + n = len(find_lines) + lines = content.split('\n') + for i in range(len(lines) - n + 1): + block = '\n'.join(lines[i:i+n]) + if ' '.join(block.split()) == nf: + return block + return None + +def find_indent_flexible(content, find): + def deindent(text): + ls = text.split('\n') + min_indent = float('inf') + for l in ls: + stripped = l.lstrip(' \t') + if stripped: + min_indent = min(min_indent, len(l) - len(stripped)) + if min_indent == float('inf'): + return text + return '\n'.join(l[min_indent:] if len(l) > min_indent else l.lstrip(' \t') for l in ls) + nf = deindent(find) + find_lines = find.split('\n') + n = len(find_lines) + lines = content.split('\n') + for i in range(len(lines) - n + 1): + block = '\n'.join(lines[i:i+n]) + if deindent(block) == nf: + return block + return None + +def find_trimmed_boundary(content, find): + trimmed = find.strip() + if trimmed == find: + return None + if trimmed in content: + return trimmed + find_lines = find.split('\n') + n = len(find_lines) + lines = content.split('\n') + for i in range(len(lines) - n + 1): + block = '\n'.join(lines[i:i+n]) + if block.strip() == trimmed: + return block + return None + +def robust_replace(content, old_string, new_string, replace_all): + match = (find_exact(content, old_string) or + find_ws_normalized(content, old_string) or + find_indent_flexible(content, old_string) or + find_trimmed_boundary(content, old_string)) + if match is None: + return None, "old_string not found in file", False + if replace_all: + return content.replace(match, new_string), None, False + n = content.count(match) + if n > 1: + return None, f"old_string is not unique ({n} occurrences); use replace_all=true or provide more context", True + return content.replace(match, new_string, 1), None, False + +if len(sys.argv) < 4: + print("usage: file_edit [replace_all=true|1|yes]") + sys.exit(1) + +file_path = sys.argv[1] +old_string = sys.argv[2] +new_string = sys.argv[3] +_ra = sys.argv[4].lower() if len(sys.argv) > 4 else '' +replace_all = _ra.split('=')[-1] in ('true', '1', 'yes') + +if not os.path.isabs(file_path): + print(f"STATUS=error STATE_CHANGED=false RETRYABLE=false") + print(f"error: path must be absolute, got: {file_path}") + sys.exit(1) +if old_string == new_string: + print("STATUS=no_change STATE_CHANGED=false RETRYABLE=false") + print("error: old_string and new_string must be different") + sys.exit(1) +if not os.path.exists(file_path): + print(f"STATUS=error STATE_CHANGED=false RETRYABLE=false") + print(f"error: file not found: {file_path}") + sys.exit(1) + +try: + with open(file_path, 'r', encoding='utf-8') as f: + original = f.read() +except UnicodeDecodeError: + print(f"STATUS=error STATE_CHANGED=false RETRYABLE=false") + print(f"error: file is not valid UTF-8: {file_path}") + sys.exit(1) + +new_content, err, retryable = robust_replace(original, old_string, new_string, replace_all) +if err: + print(f"STATUS=error STATE_CHANGED=false RETRYABLE={'true' if retryable else 'false'}") + print(f"error: {err}") + sys.exit(1) + +try: + with open(file_path, 'w', encoding='utf-8') as f: + f.write(new_content) +except OSError as e: + print(f"STATUS=error STATE_CHANGED=false RETRYABLE=false") + print(f"error: {e}") + sys.exit(1) + +diff = format_diff(file_path, original, new_content) +if diff is None: + print("STATUS=no_change STATE_CHANGED=false RETRYABLE=false") + print("(no changes)") +else: + print("STATUS=done STATE_CHANGED=true") + print(diff) diff --git a/tools/file_glob b/tools/file_glob new file mode 100644 index 0000000..2706408 --- /dev/null +++ b/tools/file_glob @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# ollie:parallel read +# description: Find files by name/path pattern, sorted by modification time (newest first) +# Args: [path] + +pattern="$1" +search_path="${2:-$PWD}" + +[ -z "$pattern" ] && echo "usage: file_glob [path]" && exit 1 + +if [ ! -d "$search_path" ]; then + echo "STATUS=error" + echo "error: path is not a directory: $search_path" + exit 1 +fi + +# collect matches and sort by mtime descending +rg_out=$(rg --no-config --files -g "$pattern" "$search_path" 2>&1) +rg_exit=$? +if [ $rg_exit -gt 1 ]; then + echo "STATUS=error" + echo "error: $rg_out" + exit 1 +fi +if [ -z "$rg_out" ]; then + echo "STATUS=no_matches MATCH_COUNT=0" + echo "no matches" + exit 0 +fi + +sorted=$(echo "$rg_out" \ + | xargs -d '\n' stat --printf '%Y\t%n\n' 2>&1 \ + | sort -rn \ + | cut -f2-) + +count=$(echo "$sorted" | wc -l | tr -d ' ') +echo "STATUS=ok MATCH_COUNT=$count" +echo "$sorted" diff --git a/tools/file_grep b/tools/file_grep new file mode 100644 index 0000000..1401bdb --- /dev/null +++ b/tools/file_grep @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +# ollie:parallel read +# description: Search file contents using ripgrep. Default output: file:lineno| content (one match per line). +# Args: [--path=] [--mode=files_with_matches|content|count] [--glob=] [--type=] [-i] [-A ] [-B ] [-C ] [--multiline] [--head=] [--offset=] + +import sys +import os +import subprocess +import argparse + +parser = argparse.ArgumentParser(prog='file_grep', add_help=False) +parser.add_argument('pattern') +parser.add_argument('--path', default='') +parser.add_argument('--mode', default='content', + choices=['files_with_matches', 'content', 'count']) +parser.add_argument('--glob', default='') +parser.add_argument('--type', default='') +parser.add_argument('-i', action='store_true') +parser.add_argument('-n', action='store_true', default=True) +parser.add_argument('-A', type=int, default=None) +parser.add_argument('-B', type=int, default=None) +parser.add_argument('-C', type=int, default=None) +parser.add_argument('--multiline', action='store_true') +parser.add_argument('--head', type=int, default=0) +parser.add_argument('--offset', type=int, default=0) + +args = parser.parse_args() + +search_path = args.path or os.getcwd() + +cmd = ['rg', '--no-config', '--color=never'] + +if args.mode == 'files_with_matches': + cmd.append('-l') +elif args.mode == 'count': + cmd.append('--count') +elif args.mode == 'content': + if args.n: + cmd.append('-n') + +if args.i: + cmd.append('-i') +if args.multiline: + cmd += ['-U', '--multiline-dotall'] +if args.glob: + cmd += ['-g', args.glob] +if args.type: + cmd += ['-t', args.type] +if args.C is not None: + cmd += ['-C', str(args.C)] +if args.A is not None: + cmd += ['-A', str(args.A)] +if args.B is not None: + cmd += ['-B', str(args.B)] + +cmd += ['-e', args.pattern, search_path] + +result = subprocess.run(cmd, capture_output=True, text=True) +if result.returncode == 1: + print("STATUS=no_matches MATCH_COUNT=0") + print("no matches") + sys.exit(0) +if result.returncode > 1: + print(f"STATUS=error") + print(f"error: {result.stderr.strip() or f'rg exited with {result.returncode}'}") + sys.exit(1) + +lines = result.stdout.splitlines() +total = len(lines) + +if args.offset: + lines = lines[args.offset:] + +max_matches = args.head if args.head else 50 +capped = len(lines) > max_matches +if capped: + lines = lines[:max_matches] + +if args.mode == 'content': + formatted = [] + for line in lines: + if line == '--': + formatted.append(line) + continue + for sep in (':', '-'): + parts = line.split(sep, 2) + if len(parts) == 3 and parts[1].isdigit(): + formatted.append(f"{parts[0]}:{parts[1]:>5}| {parts[2]}") + break + else: + formatted.append(line) + lines = formatted + +print(f"STATUS=ok MATCH_COUNT={total}") +print('\n'.join(lines)) +if capped: + print(f"\n(showing {max_matches} of {total} matches; use --head=N or --offset=N for more)") diff --git a/tools/file_read b/tools/file_read new file mode 100644 index 0000000..bcada32 --- /dev/null +++ b/tools/file_read @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +# ollie:parallel read +# ollie:tier cold +# description: Read a file with accurate line numbers. Prefer segments (start/end) over full-file reads. +# Args: [start] [end] [--head=] + +import sys +import os +from itertools import islice + +MAX_LINES = 500 # default window for whole-file reads (no start/end) +MAX_SEGMENT = 2000 # max window when start/end are given +MAX_LINE_LEN = 2000 + +# Parse --head=N from args +head = None +args = [] +for a in sys.argv[1:]: + if a.startswith('--head='): + head = int(a.split('=', 1)[1]) + else: + args.append(a) + +if not args: + print("usage: file_read [start] [end] [--head=N]") + sys.exit(1) + +file_path = args[0] +start = int(args[1]) if len(args) > 1 else 1 +end = int(args[2]) if len(args) > 2 else None + +if not os.path.isabs(file_path): + print(f"STATUS=error") + print(f"error: path must be absolute, got: {file_path}") + sys.exit(1) + +if not os.path.exists(file_path): + print("STATUS=not_found") + print("(new file)") + sys.exit(0) + +if not os.path.isfile(file_path): + print(f"STATUS=error") + print(f"error: not a regular file: {file_path}") + sys.exit(1) + +with open(file_path, 'rb') as f: + raw_head = f.read(8192) + if b'\x00' in raw_head: + # Binary file -- try to detect image type + IMAGE_SIGS = [ + b'\x89PNG\r\n\x1a\n', b'\xff\xd8\xff', + b'GIF87a', b'GIF89a', b'RIFF', + ] + is_image = any(raw_head.startswith(s) for s in IMAGE_SIGS) + if is_image: + print("STATUS=ok BINARY=true IMAGE=true") + print("(image file — use image_read for visual content)") + else: + print("STATUS=ok BINARY=true") + print("(binary file)") + sys.exit(0) + +# Count total lines +try: + with open(file_path, 'r', encoding='utf-8') as f: + total_lines = sum(1 for _ in f) +except UnicodeDecodeError: + with open(file_path, 'r', encoding='utf-8', errors='replace') as f: + total_lines = sum(1 for _ in f) + +start = max(1, start) +if head: + end = start + head - 1 +elif end is None: + end = start + MAX_LINES - 1 +else: + end = min(end, start + MAX_SEGMENT - 1) + +try: + with open(file_path, 'r', encoding='utf-8') as f: + for _ in islice(f, start - 1): + pass + segment_lines = list(islice(f, end - start + 1)) +except UnicodeDecodeError: + with open(file_path, 'r', encoding='utf-8', errors='replace') as f: + for _ in islice(f, start - 1): + pass + segment_lines = list(islice(f, end - start + 1)) + +if not segment_lines: + if start == 1: + print(f"STATUS=ok LINES=0 TOTAL_LINES={total_lines} TRUNCATED=false EMPTY=true") + print("(empty file)") + else: + print(f"STATUS=ok LINES=0 TOTAL_LINES={total_lines} TRUNCATED=false EMPTY=true") + print("(no lines in range)") + sys.exit(0) + +out = [] +for i, line in enumerate(segment_lines): + lineno = start + i + text = line.rstrip('\n') + if len(text) > MAX_LINE_LEN: + text = text[:MAX_LINE_LEN] + '...' + out.append(f"{lineno:>6}\t{text}") + +last_line_shown = start + len(out) - 1 +truncated = last_line_shown < total_lines +print(f"STATUS=ok LINES={len(out)} TOTAL_LINES={total_lines} TRUNCATED={str(truncated).lower()}") +print('\n'.join(out)) diff --git a/tools/file_write b/tools/file_write new file mode 100644 index 0000000..76c54e2 --- /dev/null +++ b/tools/file_write @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +# description: Write or overwrite a file +# Args: + +import sys +import os +import difflib + +def format_diff(path, old, new): + old_lines = old.splitlines(keepends=True) + new_lines = new.splitlines(keepends=True) + diff = ''.join(difflib.unified_diff(old_lines, new_lines, fromfile=path, tofile=path)) + if not diff: + return None + return f"```diff\n{diff}```" + +if len(sys.argv) < 3: + print("usage: file_write ") + sys.exit(1) + +file_path = sys.argv[1] +content = sys.argv[2] + +if not os.path.isabs(file_path): + print(f"STATUS=error STATE_CHANGED=false RETRYABLE=false") + print(f"error: path must be absolute, got: {file_path}") + sys.exit(1) + +parent = os.path.dirname(file_path) +if parent and not os.path.isdir(parent): + print(f"STATUS=error STATE_CHANGED=false RETRYABLE=false") + print(f"error: parent directory does not exist: {parent}") + sys.exit(1) + +old_content = "" +if os.path.exists(file_path): + try: + with open(file_path, 'r', encoding='utf-8') as f: + old_content = f.read() + except UnicodeDecodeError: + with open(file_path, 'r', encoding='utf-8', errors='replace') as f: + old_content = f.read() + +try: + with open(file_path, 'w', encoding='utf-8') as f: + f.write(content) +except OSError as e: + print(f"STATUS=error STATE_CHANGED=false RETRYABLE=false") + print(f"error: {e}") + sys.exit(1) + +diff = format_diff(file_path, old_content, content) +if diff is None: + print("STATUS=no_change STATE_CHANGED=false RETRYABLE=false") + print("(no changes)") +else: + print("STATUS=done STATE_CHANGED=true") + print(diff) diff --git a/tools/grep b/tools/grep new file mode 100755 index 0000000..60f72a2 --- /dev/null +++ b/tools/grep @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# ollie:parallel read +# description: Run grep. Reads from stdin if piped, otherwise searches files. Args are passed directly to grep. +# Args: [grep-options...] [file...] +set -uo pipefail + +exec grep "$@" 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_brightness b/tools/gui_brightness new file mode 100755 index 0000000..2bb4f1d --- /dev/null +++ b/tools/gui_brightness @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# description: Get/set screen brightness via PowerDevil D-Bus. Args: [get|set |up|down] +# NOTE: PowerDevil uses absolute values (0 to brightnessMax, typically 10000). +# This tool accepts PERCENTAGES (0-100) and converts internally. +set -euo pipefail + +SERVICE="org.kde.Solid.PowerManagement" +OBJ_PATH="/org/kde/Solid/PowerManagement/Actions/BrightnessControl" +IFACE="org.kde.Solid.PowerManagement.Actions.BrightnessControl" + +cmd="${1:-get}" + +# Get max brightness (needed for percentage conversion) +get_max() { + gdbus call --session --dest "$SERVICE" \ + --object-path "$OBJ_PATH" \ + --method "$IFACE.brightnessMax" | \ + sed 's/(//;s/,)//' +} + +# Get current brightness (absolute) +get_current() { + gdbus call --session --dest "$SERVICE" \ + --object-path "$OBJ_PATH" \ + --method "$IFACE.brightness" | \ + sed 's/(//;s/,)//' +} + +case "$cmd" in + get) + max=$(get_max) + current=$(get_current) + percent=$(( current * 100 / max )) + echo "${percent}% ($current/$max)" + ;; + set) + percent="${2:?usage: gui_brightness set }" + if [ "$percent" -lt 0 ] || [ "$percent" -gt 100 ]; then + echo "ERROR: percent must be 0-100" >&2 + exit 1 + fi + max=$(get_max) + value=$(( percent * max / 100 )) + gdbus call --session --dest "$SERVICE" \ + --object-path "$OBJ_PATH" \ + --method "$IFACE.setBrightness" "$value" >/dev/null + echo "brightness: ${percent}% ($value/$max)" + ;; + up) + step="${2:-10}" + max=$(get_max) + current=$(get_current) + current_pct=$(( current * 100 / max )) + new_pct=$(( current_pct + step )) + [ "$new_pct" -gt 100 ] && new_pct=100 + value=$(( new_pct * max / 100 )) + gdbus call --session --dest "$SERVICE" \ + --object-path "$OBJ_PATH" \ + --method "$IFACE.setBrightness" "$value" >/dev/null + echo "brightness: ${new_pct}% ($value/$max)" + ;; + down) + step="${2:-10}" + max=$(get_max) + current=$(get_current) + current_pct=$(( current * 100 / max )) + new_pct=$(( current_pct - step )) + [ "$new_pct" -lt 0 ] && new_pct=0 + value=$(( new_pct * max / 100 )) + gdbus call --session --dest "$SERVICE" \ + --object-path "$OBJ_PATH" \ + --method "$IFACE.setBrightness" "$value" >/dev/null + echo "brightness: ${new_pct}% ($value/$max)" + ;; + *) + echo "usage: gui_brightness [get|set |up [step]|down [step]]" >&2 + echo "" >&2 + echo " get - show current brightness as percentage" >&2 + echo " set <0-100> - set brightness to percentage" >&2 + echo " up [step] - increase by step% (default 10)" >&2 + echo " down [step] - decrease by step% (default 10)" >&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_media b/tools/gui_media new file mode 100755 index 0000000..923dbef --- /dev/null +++ b/tools/gui_media @@ -0,0 +1,284 @@ +#!/usr/bin/env python3 +# description: Control MPRIS players and build local playlists. Args: [target] [options] +"""Reliable MPRIS control, including Elisa's Application.Open requirement.""" +import argparse +import hashlib +import json +import pathlib +import random +import re +import subprocess +import sys +import time + +BUS = "org.freedesktop.DBus" +OBJ = "/org/mpris/MediaPlayer2" +PLAYER = "org.mpris.MediaPlayer2.Player" +PROPS = "org.freedesktop.DBus.Properties" + + +def call(dest, path, method, *args, check=True): + p = subprocess.run( + ["gdbus", "call", "--session", "--dest", dest, + "--object-path", path, "--method", method, *args], + text=True, capture_output=True, + ) + if check and p.returncode: + raise RuntimeError(p.stderr.strip() or p.stdout.strip()) + return p.stdout.strip() if p.returncode == 0 else "" + + +def players(): + out = call(BUS, "/org/freedesktop/DBus", BUS + ".ListNames") + return sorted(set(re.findall(r"org\.mpris\.MediaPlayer2[\w.-]+", out))) + + +def choose(hint=None, activate=False): + names = players() + if hint: + matches = [n for n in names if hint.lower() in n.lower()] + if matches: + return matches[0] + if activate and hint.lower() == "elisa": + call(BUS, "/org/freedesktop/DBus", BUS + ".StartServiceByName", + "org.kde.elisa", "0") + time.sleep(0.5) + matches = [n for n in players() if "elisa" in n.lower()] + if matches: + return matches[0] + raise RuntimeError(f"no MPRIS player matching {hint!r}") + if names: + playing = [n for n in names if prop(n, "PlaybackStatus") == "Playing"] + return (playing or names)[0] + if activate: + call(BUS, "/org/freedesktop/DBus", BUS + ".StartServiceByName", + "org.kde.elisa", "0") + time.sleep(0.5) + names = players() + if names: + return names[0] + raise RuntimeError("no MPRIS media player is available") + + +def prop(dest, name): + out = call(dest, OBJ, PROPS + ".Get", PLAYER, name, check=False) + m = re.search(r"<'([^']*)'>", out) + return m.group(1) if m else out + + +def metadata(dest): + return call(dest, OBJ, PROPS + ".Get", PLAYER, "Metadata", check=False) + + +def metadata_has_uri(dest, uri): + # Elisa reports file URLs with decoded spaces, while pathlib.as_uri() + # correctly sends percent-encoded spaces to Application.Open. + from urllib.parse import unquote + return uri in metadata(dest) or unquote(uri) in metadata(dest) + + +def uri_for(value): + if re.match(r"^[A-Za-z][A-Za-z0-9+.-]*://", value): + return value + path = pathlib.Path(value).expanduser().resolve() + if not path.is_file(): + raise RuntimeError(f"media file not found: {path}") + return path.as_uri() + + +def clear_playlist(dest): + call(dest, OBJ, PLAYER + ".Stop", check=False) + if "elisa" not in dest.lower(): + return + call(dest, "/org/kde/elisa", "org.freedesktop.Application.ActivateAction", + "clear-playlist", "[]", "{}") + # Elisa resets currentTrack to zero when the queue is empty. Metadata may + # remain stale briefly, so the index is the reliable completion signal. + for _ in range(20): + current = call(dest, OBJ, PROPS + ".Get", PLAYER, "currentTrack", check=False) + if re.search(r"<0>", current): + return + time.sleep(0.1) + raise RuntimeError("Elisa did not clear its playlist") + + +def open_uris(dest, uris, clear=False): + if clear: + clear_playlist(dest) + else: + call(dest, OBJ, PLAYER + ".Stop", check=False) + opened = False + if "elisa" in dest.lower(): + # Elisa advertises MPRIS OpenUri but may ignore it. Its actual file-open + # path is org.freedesktop.Application.Open. JSON array syntax is also + # valid GVariant text and safely handles quotes in paths. + opened = bool(call(dest, "/org/kde/elisa", + "org.freedesktop.Application.Open", + json.dumps(uris), "{}", check=False) == "()") + if not opened: + if len(uris) != 1: + raise RuntimeError("this player cannot load a playlist through D-Bus") + call(dest, OBJ, PLAYER + ".OpenUri", uris[0]) + + +def start_verified(dest, uri): + for _ in range(40): + if metadata_has_uri(dest, uri): + break + time.sleep(0.25) + else: + raise RuntimeError("player did not load the requested URI; playback left stopped") + + call(dest, OBJ, PLAYER + ".Play") + for attempt in range(20): + if prop(dest, "PlaybackStatus") == "Playing" and metadata_has_uri(dest, uri): + return + # Elisa can expose a newly opened track as Stopped while ignoring the + # first Play. Toggling once initializes its playback state; Play then + # reliably starts the already verified track. + if attempt == 3 and "elisa" in dest.lower(): + call(dest, OBJ, PLAYER + ".PlayPause", check=False) + call(dest, OBJ, PLAYER + ".Play", check=False) + time.sleep(0.25) + raise RuntimeError("requested URI loaded but did not start playing") + + +def open_media(dest, value): + uri = uri_for(value) + open_uris(dest, [uri]) + start_verified(dest, uri) + + print(f"player={dest}\nstatus=Playing\nuri={uri}") + + +AUDIO_EXTS = {".flac", ".mp3", ".ogg", ".opus", ".m4a", ".wav"} +MOODS = { + "chill": ({"ambient", "dream", "dreamy", "soft", "slow", "quiet", "calm", "mellow", "warm", "night", "moon", "ocean", "sea", "wave", "waves", "air", "shine", "memory", "sleep", "blue", "acoustic", "instrumental", "home"}, + {"metal", "death", "war", "fire", "burn", "rage", "angry", "hate", "kill", "blood", "violent", "blast", "bore", "rancorous"}), + "focus": ({"ambient", "instrumental", "minimal", "piano", "acoustic", "memory", "space", "air"}, + {"party", "dance", "rage", "chaos", "scream", "live"}), + "energetic": ({"dance", "fire", "fast", "power", "run", "alive", "electric", "party", "drive", "exalter"}, + {"sleep", "slow", "quiet", "ambient", "lament"}), + "melancholy": ({"blue", "rain", "memory", "alone", "lost", "goodbye", "dead", "dream", "night", "winter", "ghost", "undertow"}, + {"party", "dance", "happy", "sunshine"}), +} + + +def words(text): + return set(re.findall(r"[a-z0-9]+", text.lower())) + + +def library_tracks(root): + root = pathlib.Path(root).expanduser().resolve() + if not root.is_dir(): + raise RuntimeError(f"music library not found: {root}") + tracks = [] + for path in root.rglob("*"): + if path.is_file() and path.suffix.lower() in AUDIO_EXTS: + parent = path.parent.name + artist, _, album = parent.partition(" - ") + name = path.stem + title = re.sub(r"^.*? - \d+\s*", "", name) + tracks.append({"path": path, "artist": artist or parent, + "album": album, "title": title, + "text": " ".join((artist, album, title)).lower()}) + if not tracks: + raise RuntimeError(f"no audio files found under {root}") + return tracks + + +def rank_playlist(description, tracks, count): + query = words(description) + positive, negative = set(), set() + for mood, (good, bad) in MOODS.items(): + if mood in query or (mood == "chill" and query & {"relax", "relaxed", "mellow", "calm"}): + positive |= good + negative |= bad + positive |= query - {"make", "playlist", "music", "songs", "tracks", "some", "me", "a", "an", "the"} + seed = int(hashlib.sha256(description.lower().encode()).hexdigest()[:16], 16) + rng = random.Random(seed) + ranked = [] + for track in tracks: + tokens = words(track["text"]) + score = 4 * len(tokens & positive) - 5 * len(tokens & negative) + # Album/artist matches influence the whole selection; a tiny seeded + # jitter makes equally ranked results varied but reproducible. + score += 2 * len(words(track["album"]) & positive) + score += len(words(track["artist"]) & positive) + ranked.append((score + rng.random() * 0.2, track)) + ranked.sort(key=lambda item: item[0], reverse=True) + + selected, artists, albums = [], {}, {} + for _, track in ranked: + artist = track["artist"].lower() + album = (artist, track["album"].lower()) + if artists.get(artist, 0) >= 3 or albums.get(album, 0) >= 2: + continue + selected.append(track) + artists[artist] = artists.get(artist, 0) + 1 + albums[album] = albums.get(album, 0) + 1 + if len(selected) == count: + break + return selected + + +def build_playlist(dest, description, root, count, output=None): + if count < 1 or count > 100: + raise RuntimeError("playlist count must be between 1 and 100") + selected = rank_playlist(description, library_tracks(root), count) + if not selected: + raise RuntimeError("no tracks matched the playlist description") + if output: + playlist = pathlib.Path(output).expanduser().resolve() + else: + slug = re.sub(r"[^a-z0-9]+", "-", description.lower()).strip("-")[:50] or "playlist" + playlist = pathlib.Path.home() / ".local/share/ollie/playlists" / f"{slug}.m3u8" + playlist.parent.mkdir(parents=True, exist_ok=True) + playlist.write_text("#EXTM3U\n" + "\n".join(str(t["path"]) for t in selected) + "\n", + encoding="utf-8") + uris = [t["path"].as_uri() for t in selected] + open_uris(dest, uris, clear=True) + start_verified(dest, uris[0]) + print(f"player={dest}\nstatus=Playing\nplaylist={playlist}\ntracks={len(selected)}") + for index, track in enumerate(selected, 1): + print(f"{index:02d}. {track['artist']} — {track['title']}") + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("command", choices=["list", "status", "open", "playlist", + "play", "pause", "stop", "next", "previous"]) + ap.add_argument("target", nargs="?", help="path, URI, or playlist description") + ap.add_argument("--player") + ap.add_argument("--library", default=str(pathlib.Path.home() / "music")) + ap.add_argument("--count", type=int, default=15) + ap.add_argument("--output", help="M3U8 output path") + args = ap.parse_args() + + if args.command == "list": + for name in players(): + print(f"{name}\t{prop(name, 'PlaybackStatus')}") + return + + dest = choose(args.player, activate=args.command in {"open", "playlist"}) + if args.command == "open": + if not args.target: + ap.error("open requires PATH_OR_URI") + open_media(dest, args.target) + elif args.command == "playlist": + if not args.target: + ap.error("playlist requires a description") + build_playlist(dest, args.target, args.library, args.count, args.output) + elif args.command == "status": + print(f"player={dest}\nstatus={prop(dest, 'PlaybackStatus')}\nmetadata={metadata(dest)}") + else: + call(dest, OBJ, PLAYER + "." + args.command.capitalize()) + print(f"player={dest}\nstatus={prop(dest, 'PlaybackStatus')}") + + +if __name__ == "__main__": + try: + main() + except RuntimeError as e: + print(f"ERROR: {e}", file=sys.stderr) + sys.exit(1) 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() diff --git a/tools/image_read b/tools/image_read new file mode 100644 index 0000000..ff3af6b --- /dev/null +++ b/tools/image_read @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +# ollie:parallel read +# ollie:tier cold +# description: Read an image file, recompressing/resizing to fit context budget. Returns an image content block. +# args: [--region=x,y,w,h] [--scale=N] [--quality=N] + +import sys +import os +import base64 +import json +import mimetypes + +file_path = None +for a in sys.argv[1:]: + if not a.startswith('--'): + file_path = a + +if not file_path: + print("usage: image_read ") + sys.exit(1) + +if not os.path.isabs(file_path): + print("STATUS=error") + print("error: path must be absolute") + sys.exit(1) + +if not os.path.exists(file_path): + print("STATUS=not_found") + sys.exit(0) + +mime, _ = mimetypes.guess_type(file_path) +if not mime: + mime = 'application/octet-stream' + +with open(file_path, 'rb') as f: + data = f.read() + +MAX_SIZE = 128 * 1024 + +if mime == 'image/png' and len(data) > MAX_SIZE: + from PIL import Image + import io + img = Image.open(io.BytesIO(data)) + # progressively scale down until under budget + for scale in [1.0, 0.75, 0.5, 0.35, 0.25]: + buf = io.BytesIO() + if scale < 1.0: + new_size = (int(img.width * scale), int(img.height * scale)) + resized = img.resize(new_size, Image.LANCZOS) + else: + resized = img + resized.save(buf, format='PNG', optimize=True) + data = buf.getvalue() + if len(data) <= MAX_SIZE: + break + +b64 = base64.b64encode(data).decode('ascii') + +print(json.dumps({"isError": False, "content": [ + {"type": "image", "media_type": mime, "data": b64}, + {"type": "text", "text": f"[image: {os.path.basename(file_path)} {len(data)} bytes {mime}]"} +]})) diff --git a/tools/lsp_completion b/tools/lsp_completion new file mode 100755 index 0000000..b164461 --- /dev/null +++ b/tools/lsp_completion @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +# ollie:parallel read +# description: Get completions at cursor position from the language server. +# Args: + +import os +import sys +sys.path.insert(0, os.path.join(os.environ['OLLIE'], 's', os.environ['OLLIE_SESSION_ID'], 't')) + +from _lib.lsp.client import request + +if len(sys.argv) < 4: + print("usage: lsp_completion ") + sys.exit(1) + +filepath = os.path.abspath(sys.argv[1]) +line = int(sys.argv[2]) - 1 +col = int(sys.argv[3]) - 1 + +result = request("completion", file=filepath, line=line, col=col) + +if not result: + sys.exit(0) + +items = result.get("items", result) if isinstance(result, dict) else result +if not isinstance(items, list): + sys.exit(0) + +for item in items[:20]: + label = item.get("label", "") + detail = item.get("detail", "") + if detail: + print(f"{label}\t{detail}") + else: + print(label) diff --git a/tools/lsp_definition b/tools/lsp_definition new file mode 100755 index 0000000..4879b11 --- /dev/null +++ b/tools/lsp_definition @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +# ollie:parallel read +# description: Go to definition of symbol at position. Returns file:line:col. +# Args: + +import os +import sys +sys.path.insert(0, os.path.join(os.environ['OLLIE'], 's', os.environ['OLLIE_SESSION_ID'], 't')) + +from _lib.lsp.client import request + +if len(sys.argv) < 4: + print("usage: lsp_definition ") + sys.exit(1) + +filepath = os.path.abspath(sys.argv[1]) +line = int(sys.argv[2]) - 1 # convert 1-indexed to 0-indexed +col = int(sys.argv[3]) - 1 + +result = request("definition", file=filepath, line=line, col=col) + +if not result: + print("(no definition found)") + sys.exit(0) + +# Normalize: result can be a Location or list of Locations +locations = result if isinstance(result, list) else [result] +for loc in locations: + uri = loc.get("uri", loc.get("targetUri", "")) + path = uri[7:] if uri.startswith("file://") else uri + pos = loc.get("range", loc.get("targetRange", {})).get("start", {}) + ln = pos.get("line", 0) + 1 + ch = pos.get("character", 0) + 1 + print(f"{path}:{ln}:{ch}") diff --git a/tools/lsp_diagnostics b/tools/lsp_diagnostics new file mode 100755 index 0000000..c593c5d --- /dev/null +++ b/tools/lsp_diagnostics @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +# ollie:parallel read +# description: Get diagnostics (errors, warnings) for a file. +# Args: + +import os +import sys +sys.path.insert(0, os.path.join(os.environ['OLLIE'], 's', os.environ['OLLIE_SESSION_ID'], 't')) + +from _lib.lsp.client import request + +if len(sys.argv) < 2: + print("usage: lsp_diagnostics ") + sys.exit(1) + +filepath = os.path.abspath(sys.argv[1]) + +result = request("diagnostics", file=filepath) + +if not result: + print("(no diagnostics)") + sys.exit(0) + +SEVERITY = {1: "error", 2: "warning", 3: "info", 4: "hint"} + +for diag in result: + sev = SEVERITY.get(diag.get("severity", 3), "info") + ln = diag.get("range", {}).get("start", {}).get("line", 0) + 1 + col = diag.get("range", {}).get("start", {}).get("character", 0) + 1 + msg = diag.get("message", "") + src = diag.get("source", "") + prefix = f"{src}: " if src else "" + print(f"{ln}:{col} {sev}: {prefix}{msg}") + +print(f"\n({len(result)} diagnostics)") diff --git a/tools/lsp_hover b/tools/lsp_hover new file mode 100755 index 0000000..1c21c8b --- /dev/null +++ b/tools/lsp_hover @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +# ollie:parallel read +# description: Get type/documentation info for symbol at position. +# Args: + +import os +import sys +sys.path.insert(0, os.path.join(os.environ['OLLIE'], 's', os.environ['OLLIE_SESSION_ID'], 't')) + +from _lib.lsp.client import request + +if len(sys.argv) < 4: + print("usage: lsp_hover ") + sys.exit(1) + +filepath = os.path.abspath(sys.argv[1]) +line = int(sys.argv[2]) - 1 +col = int(sys.argv[3]) - 1 + +result = request("hover", file=filepath, line=line, col=col) + +if not result: + print("(no hover info)") + sys.exit(0) + +contents = result.get("contents", "") +if isinstance(contents, dict): + # MarkedString or MarkupContent + print(contents.get("value", str(contents))) +elif isinstance(contents, list): + for item in contents: + if isinstance(item, dict): + print(item.get("value", str(item))) + else: + print(item) +else: + print(contents) diff --git a/tools/lsp_references b/tools/lsp_references new file mode 100755 index 0000000..970a394 --- /dev/null +++ b/tools/lsp_references @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 +# ollie:parallel read +# description: Find all references to symbol at position. Returns file:line:col per reference. +# Args: + +import os +import sys +sys.path.insert(0, os.path.join(os.environ['OLLIE'], 's', os.environ['OLLIE_SESSION_ID'], 't')) + +from _lib.lsp.client import request + +if len(sys.argv) < 4: + print("usage: lsp_references ") + sys.exit(1) + +filepath = os.path.abspath(sys.argv[1]) +line = int(sys.argv[2]) - 1 +col = int(sys.argv[3]) - 1 + +result = request("references", file=filepath, line=line, col=col) + +if not result: + print("(no references found)") + sys.exit(0) + +total = len(result) +cap = 50 +refs = result[:cap] if total > cap else result + +for loc in refs: + uri = loc.get("uri", "") + path = uri[7:] if uri.startswith("file://") else uri + pos = loc.get("range", {}).get("start", {}) + ln = pos.get("line", 0) + 1 + ch = pos.get("character", 0) + 1 + print(f"{path}:{ln}:{ch}") + +if total > cap: + print(f"\n(showing {cap} of {total} references)") +else: + print(f"\n({total} references)") diff --git a/tools/lsp_rename b/tools/lsp_rename new file mode 100755 index 0000000..20f5dda --- /dev/null +++ b/tools/lsp_rename @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +# description: Rename symbol at position across the workspace. Applies edits directly to files. +# Args: + +import os +import sys +sys.path.insert(0, os.path.join(os.environ['OLLIE'], 's', os.environ['OLLIE_SESSION_ID'], 't')) + +from _lib.lsp.client import request + +if len(sys.argv) < 5: + print("usage: lsp_rename ") + sys.exit(1) + +filepath = os.path.abspath(sys.argv[1]) +line = int(sys.argv[2]) - 1 +col = int(sys.argv[3]) - 1 +new_name = sys.argv[4] + +result = request("rename", file=filepath, line=line, col=col, new_name=new_name) + +if not result: + print("(rename returned no edits)") + sys.exit(0) + +files_changed = result.get("files_changed", {}) +total = result.get("total_edits", 0) + +for path, count in files_changed.items(): + print(f" {path} ({count} edits)") + +print(f"\nRenamed to '{new_name}': {total} edits across {len(files_changed)} files.") diff --git a/tools/lsp_symbols b/tools/lsp_symbols new file mode 100755 index 0000000..8aa00b1 --- /dev/null +++ b/tools/lsp_symbols @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +# ollie:parallel read +# description: List symbols in a file or search workspace symbols. +# Args: [--workspace] + +import os +import sys +sys.path.insert(0, os.path.join(os.environ['OLLIE'], 's', os.environ['OLLIE_SESSION_ID'], 't')) + +from _lib.lsp.client import request + +if len(sys.argv) < 2: + print("usage: lsp_symbols [--workspace]") + sys.exit(1) + +workspace = "--workspace" in sys.argv +arg = sys.argv[1] + +SYMBOL_KINDS = { + 1: "File", 2: "Module", 3: "Namespace", 4: "Package", 5: "Class", + 6: "Method", 7: "Property", 8: "Field", 9: "Constructor", 10: "Enum", + 11: "Interface", 12: "Function", 13: "Variable", 14: "Constant", + 15: "String", 16: "Number", 17: "Boolean", 18: "Array", 19: "Object", + 20: "Key", 21: "Null", 22: "EnumMember", 23: "Struct", 24: "Event", + 25: "Operator", 26: "TypeParameter", +} + +if workspace: + result = request("symbols", file=arg, workspace=True, query=arg) +else: + filepath = os.path.abspath(arg) + result = request("symbols", file=filepath, workspace=False) + +if not result: + print("(no symbols found)") + sys.exit(0) + + +def print_symbols(symbols, indent=0): + for sym in symbols: + kind = SYMBOL_KINDS.get(sym.get("kind", 0), "?") + name = sym.get("name", "") + # DocumentSymbol has range; SymbolInformation has location + if "range" in sym: + ln = sym["range"]["start"]["line"] + 1 + print(f"{' ' * indent}{kind:12} {name} (line {ln})") + if "children" in sym: + print_symbols(sym["children"], indent + 1) + elif "location" in sym: + uri = sym["location"].get("uri", "") + path = uri[7:] if uri.startswith("file://") else uri + ln = sym["location"]["range"]["start"]["line"] + 1 + print(f"{kind:12} {name} {path}:{ln}") + + +print_symbols(result) diff --git a/tools/memory_recall b/tools/memory_recall new file mode 100755 index 0000000..535948a --- /dev/null +++ b/tools/memory_recall @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# ollie:parallel read +# ollie:tier cold +# description: Search memories by keyword, tag, or title +# Args: + +[ -z "$*" ] && echo "usage: memory_recall " && exit 1 + +mem_dir="${OLLIE_MEMORY_PATH:-$HOME/.config/ollie/memory}" + +[ ! -d "$mem_dir" ] && echo "error: memory directory does not exist: $mem_dir" && exit 1 + +query="$*" +max_files=10 +max_lines=50 +stale_days=14 +now=$(date +%s) + +# Search filenames and bodies; discard I/O errors on stale inodes +matches=$(grep -ril --include='*.md' "$query" "$mem_dir" 2>/dev/null) +[ -z "$matches" ] && echo "no matches for: $query" && exit 0 + +total=$(echo "$matches" | wc -l) +count=0 + +while IFS= read -r file; do + count=$((count + 1)) + [ $count -gt $max_files ] && echo "(showing $max_files of $total matched files)" && break + + fname=$(basename "$file") + # Extract date from filename instead of stat + ts=${fname%%__*} + if [[ $ts =~ ^([0-9]{4})([0-9]{2})([0-9]{2})T ]]; then + fdate=$(date -d "${BASH_REMATCH[1]}-${BASH_REMATCH[2]}-${BASH_REMATCH[3]}" +%s 2>/dev/null) + [ -n "$fdate" ] && age_days=$(( (now - fdate) / 86400 )) || age_days=0 + else + age_days=0 + fi + + [ $age_days -ge $stale_days ] && status="STALE (${age_days}d)" || status="fresh" + + echo "=== $fname [$status] ===" + awk -v max="$max_lines" 'NR<=max{print} NR>max{over++} END{if(over) printf "... (%d lines total, showing first %d)\n", NR, max}' "$file" 2>/dev/null || echo "error: could not read $file" + echo +done <<< "$matches" diff --git a/tools/memory_remember b/tools/memory_remember new file mode 100755 index 0000000..6562c7c --- /dev/null +++ b/tools/memory_remember @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +# description: Save a memory as a denote-formatted flat file +# Args: <tags_csv> <body> + +import sys +import os +import re +from datetime import datetime + +if len(sys.argv) < 4: + print("usage: memory_remember <title> <tags_csv> <body>") + sys.exit(1) + +title = sys.argv[1] +tags_csv = sys.argv[2] +body = sys.argv[3] + +mem_dir = os.environ.get("OLLIE_MEMORY_PATH", os.path.expanduser("~/.config/ollie/memory")) + +if not os.path.isdir(mem_dir): + os.makedirs(mem_dir, exist_ok=True) + +now = datetime.now() +ident = now.strftime("%Y%m%dT%H%M%S") +date = now.strftime("%Y-%m-%d %a %H:%M") + +tags = [t.strip() for t in tags_csv.split(",") if t.strip()] + +def slugify(s): + s = s.lower() + s = re.sub(r"[^a-z0-9]+", "-", s) + return s.strip("-") + +slug = slugify(title) +tag_slug = "-".join(slugify(t) for t in tags) +filename = f"{ident}__{slug}--{tag_slug}.md" if tag_slug else f"{ident}__{slug}.md" +path = os.path.join(mem_dir, filename) + +tags_yaml = "[" + ", ".join(f'"{t}"' for t in tags) + "]" + +content = f"""--- +title: {title} +date: {date} +tags: {tags_yaml} +identifier: {ident} +signature: +--- + +{body} +""" + +try: + with open(path, "w") as f: + f.write(content) +except OSError as e: + print(f"error: failed to write {path}: {e}") + sys.exit(1) + +print(f"saved: {filename}") diff --git a/tools/process_dismiss b/tools/process_dismiss new file mode 100755 index 0000000..96d50c7 --- /dev/null +++ b/tools/process_dismiss @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# description: Dismiss (clean up) a finished detached process, removing it from the list. +# Args: <pid> + +set -euo pipefail + +pid="${1:?usage: process_dismiss <pid>}" +session="${OLLIE_SESSION_ID:?OLLIE_SESSION_ID not set}" + +dbus-send --session --dest=org.ollie.SessionManager --type=method_call --print-reply \ + /org/ollie/SessionManager org.ollie.SessionManager.DismissDetached \ + "string:$session" "int32:$pid" 2>&1 | grep -q 'boolean true' && \ + echo "Dismissed PID $pid" || \ + { echo "Failed to dismiss PID $pid (still running or not found)" >&2; exit 1; } diff --git a/tools/process_list b/tools/process_list new file mode 100755 index 0000000..83b2a07 --- /dev/null +++ b/tools/process_list @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# ollie:parallel read +# description: List background (detached) processes for this session. Shows PID, command, start time, and status. +# Args: (none) + +set -euo pipefail + +session="${OLLIE_SESSION_ID:?OLLIE_SESSION_ID not set}" + +output=$(dbus-send --session --dest=org.ollie.SessionManager --type=method_call --print-reply \ + /org/ollie/SessionManager org.ollie.SessionManager.ListDetached \ + "string:$session" 2>&1) + +# Parse the D-Bus array response +entries=$(echo "$output" | grep 'string "' | sed 's/.*string "//;s/"$//' || true) + +if [ -z "$entries" ]; then + echo "No detached processes." + exit 0 +fi + +printf '%-8s %-10s %-40s %s\n' 'PID' 'STATUS' 'COMMAND' 'STARTED' +printf '%-8s %-10s %-40s %s\n' '---' '------' '-------' '-------' +while IFS=$'\t' read -r pid cmd started status; do + # Convert unix timestamp to human-readable + started_fmt=$(date -d "@$started" '+%H:%M:%S' 2>/dev/null || echo "$started") + printf '%-8s %-10s %-40s %s\n' "$pid" "$status" "${cmd:0:40}" "$started_fmt" +done <<< "$entries" diff --git a/tools/process_output b/tools/process_output new file mode 100755 index 0000000..826e802 --- /dev/null +++ b/tools/process_output @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# ollie:parallel read +# description: Read stdout/stderr output from a detached background process. Returns the last 64KB of combined output. +# Args: <pid> + +set -euo pipefail + +pid="${1:?usage: process_output <pid>}" +session="${OLLIE_SESSION_ID:?OLLIE_SESSION_ID not set}" + +# Use gdbus for proper parsing of string with newlines +result=$(gdbus call --session --dest org.ollie.SessionManager \ + --object-path /org/ollie/SessionManager \ + --method org.ollie.SessionManager.GetDetachedOutput "$session" "$pid" 2>&1) + +# gdbus wraps in ('...',) - strip the outer tuple +result=$(echo "$result" | sed "s/^('//;s/',)$//") + +if [ -z "$result" ]; then + echo "(no output captured yet)" +else + # Unescape gdbus string escaping + echo -e "$result" +fi diff --git a/tools/process_signal b/tools/process_signal new file mode 100755 index 0000000..3380c51 --- /dev/null +++ b/tools/process_signal @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# description: Send a signal to a detached background process. +# Args: <pid> [signal] +# signal: TERM (default), KILL + +set -euo pipefail + +pid="${1:?usage: process_signal <pid> [TERM|KILL]}" +signal="${2:-TERM}" +session="${OLLIE_SESSION_ID:?OLLIE_SESSION_ID not set}" + +dbus-send --session --dest=org.ollie.SessionManager --type=method_call --print-reply \ + /org/ollie/SessionManager org.ollie.SessionManager.SignalDetached \ + "string:$session" "int32:$pid" "string:$signal" 2>&1 | grep -q 'boolean true' && \ + echo "Signal $signal sent to PID $pid" || \ + { echo "Failed to signal PID $pid" >&2; exit 1; } diff --git a/tools/reasoning_think b/tools/reasoning_think new file mode 100755 index 0000000..4dd7e59 --- /dev/null +++ b/tools/reasoning_think @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# ollie:parallel read +# description: Internal reasoning scratchpad; think through complex problems before acting +# Args: <thought> + +[ -z "$*" ] && echo "usage: reasoning_think <thought>" && exit 1 +exit 0 diff --git a/tools/route b/tools/route new file mode 100755 index 0000000..89f5828 --- /dev/null +++ b/tools/route @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +# ollie:parallel read +# description: Route a subtask to an appropriate model tier. Returns immediately; the agent reports back to your prompt when done. +# Args: <instruction> [--tier=worker|expert] [--backend=<name>] [id] +set -euo pipefail + +SERVICE="org.ollie.SessionManager" +OBJ="/org/ollie/SessionManager" +IFACE="org.ollie.SessionManager" + +die() { printf 'route: %s\n' "$*" >&2; exit 1; } + +dbus_call() { + dbus-send --session --dest=$SERVICE --type=method_call --print-reply \ + $OBJ "$IFACE.$1" "${@:2}" 2>/dev/null +} + +dbus_string() { + dbus_call "$@" | awk -F'"' '/string "/{print $2; exit}' +} + +[ -z "${OLLIE_SESSION_ID:-}" ] && die "OLLIE_SESSION_ID not set (route must run inside a session)" + +# Parse args +tier="worker" +backend_override="" +instruction="" +id="" +for arg in "$@"; do + case "$arg" in + --tier=*) tier="${arg#--tier=}" ;; + --backend=*) backend_override="${arg#--backend=}" ;; + *) [ -z "$instruction" ] && instruction="$arg" || id="$arg" ;; + esac +done + +[ -z "$instruction" ] && die "instruction required" + +case "$tier" in + worker|expert) ;; + *) die "unknown tier: $tier (use worker or expert)" ;; +esac + +# Resolve backend: explicit flag > parent session's config > OLLIE_BACKEND +if [ -n "$backend_override" ]; then + BACKEND="$backend_override" +else + parent_config=$(dbus_string GetConfig string:"$OLLIE_SESSION_ID") + BACKEND=$(printf '%s\n' "$parent_config" | grep -m1 '^backend=' | cut -d= -f2-) + BACKEND="${BACKEND:-${OLLIE_BACKEND:-openrouter}}" +fi + +# Resolve model: OLLIE_TIER_{TIER}_{BACKEND} > legacy flat vars > hardcoded defaults +tier_upper=$(echo "$tier" | tr '[:lower:]' '[:upper:]') +backend_lower=$(echo "$BACKEND" | tr '[:upper:]' '[:lower:]') +tier_var="OLLIE_TIER_${tier_upper}_${backend_lower}" +MODEL="${!tier_var:-}" + +if [ -z "$MODEL" ]; then + case "$tier" in + worker) MODEL="${OLLIE_CASCADE_MODEL:-mistralai/devstral-small}" ;; + expert) MODEL="${OLLIE_ESCALATE_MODEL:-anthropic/claude-sonnet-4-20250514}" ;; + esac +fi + +# Agent config +case "$tier" in + worker) agent="worker" ;; + expert) agent="expert" ;; +esac + +id="${id:-route-${tier}-$(date +%s%N)-$$}" +[ -n "$OLLIE_SESSION_ID" ] && id="${OLLIE_SESSION_ID}__${id}" + +# Create session via D-Bus +session_id=$(dbus_string CreateSession \ + string:"$(pwd)" string:"$BACKEND" string:"$MODEL" string:"$agent") + +[ -z "$session_id" ] && die "failed to create session" + +# Rename to our chosen ID +dbus_call RenameSession string:"$session_id" string:"$id" > /dev/null 2>&1 + +prompt="[parent_session_id=${OLLIE_SESSION_ID}] +[session_id=${id}] + +Complete the following task. Read code, reason about implementation, and stay within scope. + +When finished, report your result back to the parent session using PeerSubmit. +Format: + +[from=${id}] +STATUS: done +SUMMARY: <what was accomplished> + +On failure: +[from=${id}] +STATUS: error +REASON: <why> + +After reporting the result, STOP. One report, then stop. + +TASK: +${instruction}" + +dbus_call Submit string:"$session_id" string:"$prompt" > /dev/null 2>&1 || die "failed to submit prompt" +printf 'session=%s\nbackend=%s\nmodel=%s\ntier=%s\n' "$id" "$BACKEND" "$MODEL" "$tier" diff --git a/tools/subagent_generate b/tools/subagent_generate new file mode 100755 index 0000000..2e7b7c0 --- /dev/null +++ b/tools/subagent_generate @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +# description: Generate a JIT subagent config. No args prints the JSON template. With JSON as first arg or on stdin, creates the config and prints the agent name. +# Args: [json] — JSON config as first arg, or piped via stdin +# Output: template (no input) or agent name + +import json +import os +import secrets +import sys + +AGENTS_DIR = os.path.expanduser("~/.config/ollie/agents") +BASE_CONFIG = os.path.join(AGENTS_DIR, "default.json") + +TEMPLATE = { + "name": "", + "role": "", + "roster": "", + "shared_task": "", + "protocol": "", + "constraints": [], + "prompt": "", +} + + +def die(msg): + print(f"subagent_generate: {msg}", file=sys.stderr) + sys.exit(1) + + +def main(): + # No input: print template. + if len(sys.argv) < 2 and sys.stdin.isatty(): + json.dump(TEMPLATE, sys.stdout, indent=2) + print() + return + + # Read config from arg or stdin. + if len(sys.argv) > 1: + text = sys.argv[1] + else: + text = sys.stdin.read() + if not text.strip(): + die("empty input") + + try: + raw = json.loads(text) + except json.JSONDecodeError as e: + die(f"invalid JSON: {e}") + + name = raw.get("name", "") or f"jit-{secrets.token_hex(8)}" + role = raw.get("role", "") + roster = raw.get("roster", "") + shared_task = raw.get("shared_task", "") + protocol = raw.get("protocol", "") + constraints = raw.get("constraints", []) + if not isinstance(constraints, list): + die("constraints must be an array of strings") + legacy = raw.get("prompt", "") + + if not role and not legacy: + die("role or prompt required") + + # Build identity text. + if role: + parts = ["## Role", role] + if roster: + parts += ["## Roster", roster] + if shared_task: + parts += ["## Shared Task", shared_task] + if protocol: + parts += ["## Coordination Protocol", protocol] + if constraints: + parts += ["## Constraints", "\n".join(f"- {c}" for c in constraints)] + identity = "\n\n".join(parts) + else: + identity = legacy + + # Load base config. + try: + with open(BASE_CONFIG) as f: + cfg = json.load(f) + except (OSError, json.JSONDecodeError) as e: + die(f"failed to read base config: {e}") + + cfg["prompt"] = ["cat ~/.config/ollie/prompts/SYSTEM_PROMPT.md", f"echo {json.dumps(identity)}"] + + # Write JIT config. + out_path = os.path.join(AGENTS_DIR, f"{name}.json") + try: + with open(out_path, "w") as f: + json.dump(cfg, f, indent=2) + except OSError as e: + die(f"failed to write config: {e}") + + print(name) + + +if __name__ == "__main__": + main() diff --git a/tools/subagent_spawn b/tools/subagent_spawn new file mode 100755 index 0000000..90a3d7c --- /dev/null +++ b/tools/subagent_spawn @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# description: Spawn a subagent session, submit a prompt, and return immediately. The subagent runs independently; use PeerSubmit for coordination. +# Args: [-name NAME] [-agent A] [-backend B] [-model M] [-cwd DIR] <prompt...> +# Output: structured KV (session, backend, model) +set -euo pipefail + +SERVICE="org.ollie.SessionManager" +OBJ="/org/ollie/SessionManager" +IFACE="org.ollie.SessionManager" + +die() { printf 'subagent_spawn: %s\n' "$*" >&2; exit 1; } + +dbus_call() { + dbus-send --session --dest=$SERVICE --type=method_call --print-reply \ + $OBJ "$IFACE.$1" "${@:2}" 2>/dev/null +} + +dbus_string() { + dbus_call "$@" | awk -F'"' '/string "/{print $2; exit}' +} + +name=; agent=; backend=; model=; cwd= + +while [ $# -gt 0 ]; do + case $1 in + -name) name=$2; shift ;; + -agent) agent=$2; shift ;; + -backend) backend=$2; shift ;; + -model) model=$2; shift ;; + -cwd) cwd=$2; shift ;; + --) shift; break ;; + -*) die "unknown flag: $1" ;; + *) break ;; + esac + shift +done + +[ $# -eq 0 ] && die "prompt required" + +# Read agent name from stdin if not provided via flag and stdin is piped. +if [ -z "$agent" ] && ! [ -t 0 ]; then + agent=$(head -1) +fi + +name=${name:-$(/bin/date +%s%N)-$$} +# Prefix with parent session ID for global uniqueness. +[ -n "${OLLIE_SESSION_ID:-}" ] && name="${OLLIE_SESSION_ID}__${name}" +cwd=${cwd:-$(pwd)} +agent=${agent:-default} + +prompt="[parent_session_id=${OLLIE_SESSION_ID:-}] +[session_id=${name}] +$*" + +# Create session via D-Bus +session_id=$(dbus_string CreateSession \ + string:"$cwd" string:"${backend:-}" string:"${model:-}" string:"$agent") + +[ -z "$session_id" ] && die "failed to create session" + +# Rename to our chosen name +dbus_call RenameSession string:"$session_id" string:"$name" > /dev/null 2>&1 + +# Submit prompt +dbus_call Submit string:"$session_id" string:"$prompt" > /dev/null 2>&1 || die "failed to submit prompt" + +# Clean up JIT agent config now that the server has loaded it. +[[ "${agent:-}" == jit-* ]] && rm -f "$HOME/.config/ollie/agents/${agent}.json" 2>/dev/null || true + +# Report resolved values from the live session. +config=$(dbus_string GetConfig string:"$session_id") +resolved_backend=$(printf '%s\n' "$config" | grep -m1 '^backend=' | cut -d= -f2- || echo "${backend:-default}") +resolved_model=$(printf '%s\n' "$config" | grep -m1 '^model=' | cut -d= -f2- || echo "${model:-default}") +printf 'session=%s\nbackend=%s\nmodel=%s\n' "$name" "$resolved_backend" "$resolved_model"