diff --git a/engineering/desktop-manager/SKILL.md b/engineering/desktop-manager/SKILL.md new file mode 100644 index 00000000..66cba716 --- /dev/null +++ b/engineering/desktop-manager/SKILL.md @@ -0,0 +1,243 @@ +--- +name: "desktop-manager" +description: "Windows desktop automation skill for Claude Code. Manages windows, processes, and layouts via PowerShell and Win32 API. Use when: user wants to tile or snap windows, arrange apps side by side, launch or kill processes, list what's open, save a desktop layout, or restore a named workspace. Trigger phrases: 'tile my windows', 'snap VS Code to the left', 'launch notepad', 'kill Chrome', 'list open windows', 'save my coding layout', 'restore my layout', 'arrange windows 2-column', 'what's running', 'close all instances of X'." +license: MIT +metadata: + version: 1.0.0 + author: VSD Communications + category: engineering + updated: 2026-05-19 + platform: windows +--- + +# Desktop Manager + +> Tile. Snap. Launch. Restore. Full Windows desktop control from Claude Code. + +PowerShell + Win32 API automation for Windows 10/11. No third-party tools. No admin rights required for standard window operations. + +--- + +## Slash Commands + +| Command | What it does | +|---------|-------------| +| `/desktop:windows` | List all open windows with HWND, PID, and geometry | +| `/desktop:tile` | Tile foreground windows in 2-column, 3-column, or 2×2 grid | +| `/desktop:snap` | Snap a specific window to a screen position | +| `/desktop:processes` | List, launch, or kill processes | +| `/desktop:snapshot` | Save or restore a named window layout | + +--- + +## When This Skill Activates + +Recognize these patterns: + +- "Tile my windows" / "arrange side by side" / "split screen" +- "Snap [app] to the left / right / corner" +- "Launch [app]" / "open [app]" / "start [app]" +- "Kill [process]" / "close all [app] windows" +- "What's running?" / "list open windows" +- "Save my layout as [name]" / "restore [name] layout" +- Any request involving window positions, screen arrangements, or process control + +--- + +## Workflow + +### `/desktop:windows` — List & Inspect Windows + +1. Run the window lister to get current state: + ```bash + python scripts/window_manager.py list + ``` +2. Output shows: HWND (handle), PID, W×H dimensions, process name, window title. +3. Use HWND values from this output for `snap`, `move`, and `show` commands. +4. To get JSON for further processing: + ```bash + python scripts/window_manager.py list --json + ``` + +### `/desktop:tile` — Tile Windows + +Identify which layout the user wants, then apply it. Tiling acts on the first N visible +windows in the order returned by `list`. + +```bash +# Side-by-side (first 2 windows) +python scripts/window_manager.py tile --layout 2col + +# Three columns (first 3 windows) +python scripts/window_manager.py tile --layout 3col + +# 2×2 grid (first 4 windows) +python scripts/window_manager.py tile --layout grid4 +``` + +If the user wants a specific app in a specific position, use `snap` instead. + +### `/desktop:snap` — Snap a Single Window + +1. Run `list` to get the HWND for the target window. +2. Apply snap position: + ```bash + python scripts/window_manager.py snap --hwnd --pos left-half + python scripts/window_manager.py snap --hwnd --pos top-right + ``` +3. For exact pixel control: + ```bash + python scripts/window_manager.py move --hwnd --x 0 --y 0 --w 960 --h 1080 + ``` +4. To change window state: + ```bash + python scripts/window_manager.py show --hwnd --state maximize + python scripts/window_manager.py show --hwnd --state minimize + python scripts/window_manager.py show --hwnd --state focus + ``` + +**Available snap positions:** `left-half`, `right-half`, `top-half`, `bot-half`, +`top-left`, `top-right`, `bot-left`, `bot-right`, `col-1-of-3`, `col-2-of-3`, +`col-3-of-3`, `maximize` + +### `/desktop:processes` — Process Management + +**List windowed processes (default):** +```bash +python scripts/process_manager.py list +python scripts/process_manager.py list --filter chrome +python scripts/process_manager.py list --all # include background processes +``` + +**Launch an application:** +```bash +python scripts/process_manager.py launch --app notepad +python scripts/process_manager.py launch --app code --args "--new-window" +python scripts/process_manager.py launch --path "C:/Program Files/app/app.exe" +``` + +**Terminate a process:** +```bash +python scripts/process_manager.py kill --name chrome # kills all chrome.exe +python scripts/process_manager.py kill --pid 12345 +``` + +### `/desktop:snapshot` — Save & Restore Layouts + +Save the current window layout before switching contexts: +```bash +python scripts/desktop_snapshot.py save --name coding +python scripts/desktop_snapshot.py save --name meetings +``` + +Restore when returning to a context: +```bash +python scripts/desktop_snapshot.py restore --name coding +``` + +List and manage snapshots: +```bash +python scripts/desktop_snapshot.py list +python scripts/desktop_snapshot.py delete --name old-layout +``` + +Snapshots are stored as JSON in `~/.desktop-snapshots/`. Restore matches windows +by title (exact, then prefix), then by process name as fallback. + +--- + +## Tooling + +### `scripts/window_manager.py` + +Controls window positions, sizes, states, and layout presets via Win32 API. + +| Subcommand | Purpose | +|-----------|---------| +| `list` | Enumerate visible windows with HWND and geometry | +| `move` | Set exact x, y, width, height for a window | +| `snap` | Snap to a named position (left-half, top-right, etc.) | +| `tile` | Tile first N windows: 2col, 3col, grid4 | +| `show` | Minimize, maximize, restore, or focus a window | + +All subcommands support `--json` for structured output. + +### `scripts/process_manager.py` + +Lists, launches, and terminates Windows processes. + +| Subcommand | Purpose | +|-----------|---------| +| `list` | Windowed processes by default; `--all` for background; `--filter NAME` to search | +| `launch` | Start an app by name (`--app`) or full path (`--path`); optional `--args` | +| `kill` | Stop by `--name` (all matching) or `--pid` | + +Process names are sanitized to prevent PowerShell injection. + +### `scripts/desktop_snapshot.py` + +Saves and restores full window layout snapshots as named JSON files in `~/.desktop-snapshots/`. + +| Subcommand | Purpose | +|-----------|---------| +| `save --name X` | Capture current layout | +| `restore --name X` | Re-apply saved positions (matches by title, then process name) | +| `list` | Show all saved snapshots with creation date and window count | +| `delete --name X` | Remove a snapshot | + +--- + +## Snap Position Quick Reference + +| Position | Description | +|----------|-------------| +| `left-half` | Left 50% of screen | +| `right-half` | Right 50% of screen | +| `top-half` / `bot-half` | Top or bottom 50% | +| `top-left` / `top-right` | Top quadrant | +| `bot-left` / `bot-right` | Bottom quadrant | +| `col-1-of-3` / `col-2-of-3` / `col-3-of-3` | Three equal columns | +| `maximize` | Full working area | + +Screen dimensions are read from `System.Windows.Forms.Screen.PrimaryScreen.WorkingArea` +(excludes taskbar). All measurements are in pixels. + +--- + +## Proactive Triggers + +Flag these without being asked: + +- **Multiple windows open** when user mentions switching context → suggest saving a snapshot first. +- **User references "left / right side"** for windows → `snap` is the right tool, not `tile`. +- **User wants to "reopen my setup"** → check if a matching snapshot exists with `list`. +- **User asks "what PID is X"** → `process_manager.py list --filter X` gives both PID and HWND. +- **Tile produces wrong results** → remind user that `tile` uses window order from `list`; run `list` first to confirm ordering. + +--- + +## Requirements + +- Windows 10 or Windows 11 +- PowerShell 5.1+ (default on both; no install needed) +- Python 3.9+ (standard library only — no pip install required) +- No admin rights required for window positioning and process listing + +--- + +## Additional Resources + +### Reference Files + +- **`references/powershell-window-api.md`** — Win32 API functions used, `Add-Type` patterns, + and how to extend scripts with additional Win32 calls (full-screen enumeration, multi-monitor, + DPI awareness, virtual desktops) + +--- + +## Related Skills + +- **browser-automation** — Automate browser-specific interactions beyond window positioning. +- **env-secrets-manager** — Manage environment variables set before launching apps. +- **git-worktree-manager** — Pairs well: open each worktree in a snapped window. +- **docker-development** — Launch and monitor Docker Desktop alongside dev windows. diff --git a/engineering/desktop-manager/references/powershell-window-api.md b/engineering/desktop-manager/references/powershell-window-api.md new file mode 100644 index 00000000..d5c60136 --- /dev/null +++ b/engineering/desktop-manager/references/powershell-window-api.md @@ -0,0 +1,294 @@ +# PowerShell Window API Reference + +Reference for the Win32 API functions used in desktop-manager scripts and how to extend +them for advanced use cases. + +--- + +## Core Pattern: `Add-Type` for Win32 + +PowerShell has no native window management cmdlets. All window control goes through +`Add-Type` to compile and expose C# P/Invoke signatures at runtime: + +```powershell +Add-Type @" +using System; +using System.Runtime.InteropServices; +public class WinAPI { + [DllImport("user32.dll")] + public static extern bool GetWindowRect(IntPtr hWnd, out RECT lpRect); + [StructLayout(LayoutKind.Sequential)] + public struct RECT { public int left, top, right, bottom; } +} +"@ -ErrorAction SilentlyContinue # SilentlyContinue skips error if type already loaded +``` + +Use `-ErrorAction SilentlyContinue` on every `Add-Type` call — if the type was already +compiled in the same PS session it will throw; this suppresses that error harmlessly. + +--- + +## Win32 Functions Used + +### GetWindowRect + +```csharp +[DllImport("user32.dll")] +public static extern bool GetWindowRect(IntPtr hWnd, out RECT lpRect); +``` + +Returns the bounding rectangle of the window **including borders and title bar** in +screen coordinates. For borderless/maximized windows, `left` and `top` may be negative +(Windows 10+ extends invisible borders off-screen for shadow rendering). + +**Usage:** +```powershell +$rect = New-Object WinAPI+RECT +[WinAPI]::GetWindowRect($hwnd, [ref]$rect) +$width = $rect.right - $rect.left +$height = $rect.bottom - $rect.top +``` + +--- + +### SetWindowPos + +```csharp +[DllImport("user32.dll")] +public static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, + int X, int Y, int cx, int cy, uint uFlags); +``` + +Moves and/or resizes a window. The scripts use `uFlags = 0x14`: +- `0x0004` — `SWP_NOZORDER`: keep current Z-order (don't bring to front) +- `0x0010` — `SWP_NOACTIVATE`: don't steal focus + +**Common flag combinations:** + +| uFlags | Effect | +|--------|--------| +| `0x0014` | Move/resize without changing Z-order or focus (recommended) | +| `0x0001` | `SWP_NOSIZE` — move only, keep current size | +| `0x0002` | `SWP_NOMOVE` — resize only, keep current position | +| `0x0040` | `SWP_SHOWWINDOW` — make visible if hidden | + +**hWndInsertAfter special values:** + +| Value | Meaning | +|-------|---------| +| `[IntPtr]::Zero` | No Z-order change | +| `[IntPtr](-1)` | `HWND_TOPMOST` — always on top | +| `[IntPtr](-2)` | `HWND_NOTOPMOST` — remove always-on-top | + +--- + +### ShowWindow + +```csharp +[DllImport("user32.dll")] +public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); +``` + +**nCmdShow values:** + +| Value | Constant | Effect | +|-------|----------|--------| +| `0` | `SW_HIDE` | Hide window | +| `1` | `SW_SHOWNORMAL` | Show/restore normal | +| `2` | `SW_MINIMIZE` | Minimize to taskbar | +| `3` | `SW_MAXIMIZE` | Maximize | +| `5` | `SW_SHOW` | Show at current size/position | +| `6` | `SW_MINIMIZE` (alt) | Minimize without activating | +| `9` | `SW_RESTORE` | Restore from min/max | + +--- + +### SetForegroundWindow / GetForegroundWindow + +```csharp +[DllImport("user32.dll")] +public static extern bool SetForegroundWindow(IntPtr hWnd); + +[DllImport("user32.dll")] +public static extern IntPtr GetForegroundWindow(); +``` + +`SetForegroundWindow` brings a window to the front and gives it keyboard focus. +Windows may silently deny this if the calling process is not the foreground process — +use `AttachThreadInput` if reliable focusing is required (see advanced section). + +**Get the currently focused window:** +```powershell +$hwnd = [WinAPI]::GetForegroundWindow() +``` + +--- + +### GetWindowThreadProcessId + +```csharp +[DllImport("user32.dll")] +public static extern int GetWindowThreadProcessId(IntPtr hWnd, out int lpdwProcessId); +``` + +Maps HWND → PID. Useful when you have an HWND from enumeration and need the process: +```powershell +$pid = 0 +[WinAPI]::GetWindowThreadProcessId($hwnd, [ref]$pid) +``` + +--- + +## Getting Windows: Three Approaches + +### 1. `Get-Process` (used in scripts — simplest) + +```powershell +Get-Process | Where-Object { + $_.MainWindowHandle -ne [IntPtr]::Zero -and $_.MainWindowTitle -ne '' +} +``` + +Returns **one window per process** (the `MainWindow`). Covers 99% of desktop management +needs. Does not enumerate secondary windows (popup dialogs, tool windows). + +### 2. `EnumWindows` (all top-level windows) + +```powershell +Add-Type @" +using System; +using System.Runtime.InteropServices; +using System.Collections.Generic; +public class WinEnum { + public delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam); + [DllImport("user32.dll")] public static extern bool EnumWindows(EnumWindowsProc p, IntPtr l); + [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr h); + [DllImport("user32.dll")] public static extern int GetWindowText(IntPtr h, + System.Text.StringBuilder s, int n); + public static List GetWindows() { + var list = new List(); + EnumWindows((h, l) => { if (IsWindowVisible(h)) list.Add(h.ToInt64()); return true; }, IntPtr.Zero); + return list; + } +} +"@ -ErrorAction SilentlyContinue + +$handles = [WinEnum]::GetWindows() +``` + +Use this when you need child windows, tool windows, or multiple windows per process. + +### 3. `FindWindow` (by class or title) + +```csharp +[DllImport("user32.dll", CharSet=CharSet.Unicode)] +public static extern IntPtr FindWindow(string lpClassName, string lpWindowName); +``` + +Finds a specific window directly without enumeration: +```powershell +# Find Notepad by class name +$hwnd = [WinAPI]::FindWindow("Notepad", $null) + +# Find by exact title +$hwnd = [WinAPI]::FindWindow($null, "Untitled - Notepad") +``` + +--- + +## Multi-Monitor Support + +Get all monitors (not just primary): +```powershell +Add-Type -AssemblyName System.Windows.Forms +$screens = [System.Windows.Forms.Screen]::AllScreens +foreach ($s in $screens) { + $wa = $s.WorkingArea + Write-Host "$($s.DeviceName): $($wa.Width)x$($wa.Height) at ($($wa.X),$($wa.Y))" +} +``` + +To position a window on a secondary monitor, use its `WorkingArea.X` as the X offset: +```powershell +$monitor2 = [System.Windows.Forms.Screen]::AllScreens | Where-Object { -not $_.Primary } | Select-Object -First 1 +$x = $monitor2.WorkingArea.X # e.g. 1920 for a monitor to the right +$y = $monitor2.WorkingArea.Y +``` + +--- + +## DPI Awareness + +On high-DPI displays (125%, 150%, 200% scaling), `GetWindowRect` returns physical pixels +but the coordinates passed to `SetWindowPos` must also be physical pixels — so they match. +No conversion is needed unless mixing with `System.Windows.Forms` which returns logical pixels. + +To get the screen DPI: +```powershell +Add-Type -AssemblyName System.Drawing +$g = [System.Drawing.Graphics]::FromHwnd([IntPtr]::Zero) +$dpi = $g.DpiX # typically 96 (100%), 120 (125%), 144 (150%), 192 (200%) +$g.Dispose() +``` + +--- + +## Virtual Desktops (Windows 10/11) + +No native PowerShell cmdlets exist. Options: + +**Option 1: VirtualDesktop PowerShell module (third-party)** +```powershell +Install-Module -Name VirtualDesktop -Scope CurrentUser +Get-DesktopList +Switch-Desktop -Desktop 1 +``` + +**Option 2: COM interface (no install, complex)** +```powershell +$shell = New-Object -ComObject "Shell.Application" +# Limited API — Switch-to only, not create/query +``` + +**Option 3: Keyboard shortcuts via SendKeys** +```powershell +Add-Type -AssemblyName System.Windows.Forms +[System.Windows.Forms.SendKeys]::SendWait("^#{RIGHT}") # Win+Ctrl+Right +``` + +For production virtual desktop management, the `VirtualDesktop` module is the most +reliable option. Requires one-time install per machine. + +--- + +## Common Issues + +### Window positions are wrong / off by a few pixels + +Windows 10/11 adds an invisible 8px border to windows for shadow rendering. `GetWindowRect` +includes this border, so `left` may be `-8` for a maximized window. This is expected — the +scripts account for it by using the values as-is, which places windows correctly. + +### SetWindowPos has no effect on maximized windows + +Call `ShowWindow(hwnd, 9)` (SW_RESTORE) first to un-maximize the window before repositioning. + +### Focus stealing prevention + +Windows prevents background processes from calling `SetForegroundWindow` in some cases. +If reliable focus is needed, use `AttachThreadInput` to attach to the foreground thread first: + +```csharp +[DllImport("user32.dll")] +public static extern bool AttachThreadInput(uint idAttach, uint idAttachTo, bool fAttach); +[DllImport("kernel32.dll")] +public static extern uint GetCurrentThreadId(); +[DllImport("user32.dll")] +public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint pid); +``` + +### Admin processes + +`SetWindowPos` will fail silently on windows owned by processes running as Administrator +if the calling process is not elevated. Common example: Task Manager. This is a Windows +security restriction — no workaround without matching elevation. diff --git a/engineering/desktop-manager/scripts/desktop_snapshot.py b/engineering/desktop-manager/scripts/desktop_snapshot.py new file mode 100644 index 00000000..8a209ac9 --- /dev/null +++ b/engineering/desktop-manager/scripts/desktop_snapshot.py @@ -0,0 +1,298 @@ +#!/usr/bin/env python3 +""" +desktop-manager: Desktop Snapshot + +Save and restore named window layout snapshots. Captures the HWND, title, +process name, and geometry of every visible window, keyed by snapshot name. + +Snapshots are stored as JSON files in ~/.desktop-snapshots/ and restored by +matching saved window titles to currently open windows. + +Usage: + python scripts/desktop_snapshot.py save --name coding + python scripts/desktop_snapshot.py restore --name coding + python scripts/desktop_snapshot.py list + python scripts/desktop_snapshot.py list --json + python scripts/desktop_snapshot.py delete --name coding +""" + +import argparse +import json +import re +import subprocess +import sys +from datetime import datetime +from pathlib import Path + +SNAPSHOT_DIR = Path.home() / ".desktop-snapshots" + +# Win32 type definition reused from window_manager pattern +WIN32_TYPE = r""" +Add-Type @" +using System; +using System.Runtime.InteropServices; +public class WinAPI { + [DllImport("user32.dll")] + public static extern bool GetWindowRect(IntPtr hWnd, out RECT lpRect); + [DllImport("user32.dll")] + public static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, + int X, int Y, int cx, int cy, uint uFlags); + [StructLayout(LayoutKind.Sequential)] + public struct RECT { public int left, top, right, bottom; } +} +"@ -ErrorAction SilentlyContinue +""" + +SWP_FLAGS = "0x14" # SWP_NOZORDER | SWP_NOACTIVATE + + +def run_ps(script: str) -> str: + result = subprocess.run( + ["powershell", "-NoProfile", "-NonInteractive", "-Command", script], + capture_output=True, + text=True, + ) + if result.returncode not in (0, 1) and result.stderr.strip(): + raise RuntimeError(result.stderr.strip()) + return result.stdout.strip() + + +def _sanitize_name(name: str) -> str: + if not re.match(r"^[\w\-]+$", name): + raise ValueError( + f"Snapshot name '{name}': use alphanumeric, hyphen, or underscore only" + ) + return name + + +def _snapshot_path(name: str) -> Path: + return SNAPSHOT_DIR / f"{name}.json" + + +def capture_windows() -> list[dict]: + """Return current visible window geometries via PowerShell.""" + ps = ( + WIN32_TYPE + + r""" +$wins = Get-Process | Where-Object { + $_.MainWindowHandle -ne [IntPtr]::Zero -and $_.MainWindowTitle -ne '' +} | ForEach-Object { + $hwnd = $_.MainWindowHandle + $rect = New-Object WinAPI+RECT + [void][WinAPI]::GetWindowRect($hwnd, [ref]$rect) + [PSCustomObject]@{ + hwnd = $hwnd.ToInt64() + title = $_.MainWindowTitle + name = $_.ProcessName + pid = $_.Id + x = $rect.left + y = $rect.top + w = $rect.right - $rect.left + h = $rect.bottom - $rect.top + } +} +if ($wins) { $wins | ConvertTo-Json -Compress } else { "[]" } +""" + ) + out = run_ps(ps) + if not out or out == "[]": + return [] + data = json.loads(out) + return data if isinstance(data, list) else [data] + + +def set_window_pos(hwnd: int, x: int, y: int, w: int, h: int) -> None: + ps = WIN32_TYPE + ( + f"[WinAPI]::SetWindowPos([IntPtr]{hwnd}, [IntPtr]::Zero," + f" {x}, {y}, {w}, {h}, {SWP_FLAGS})" + ) + run_ps(ps) + + +# ── Snapshot operations ────────────────────────────────────────────────────── + + +def save_snapshot(name: str) -> dict: + """Capture current window layout and write to ~/.desktop-snapshots/{name}.json.""" + SNAPSHOT_DIR.mkdir(parents=True, exist_ok=True) + windows = capture_windows() + if not windows: + raise RuntimeError("No visible windows found to snapshot.") + payload = { + "name": name, + "created": datetime.now().isoformat(timespec="seconds"), + "count": len(windows), + "windows": windows, + } + _snapshot_path(name).write_text(json.dumps(payload, indent=2)) + return payload + + +def restore_snapshot(name: str) -> dict: + """ + Restore a saved layout. Matches saved windows to current windows by title + (exact match first, then substring). Unmatched entries are skipped. + """ + path = _snapshot_path(name) + if not path.exists(): + raise FileNotFoundError( + f"No snapshot named '{name}'. Run 'list' to see available snapshots." + ) + + saved = json.loads(path.read_text()) + current = capture_windows() + + # Build lookup: title -> hwnd for current windows + exact: dict[str, int] = {w["title"]: w["hwnd"] for w in current} + partial: list[dict] = current # for fallback substring match + + restored, skipped = [], [] + for sw in saved["windows"]: + hwnd = exact.get(sw["title"]) + if hwnd is None: + # Fallback: find first current window whose title contains saved title prefix + match = next( + ( + c + for c in partial + if sw["title"][:30] in c["title"] or c["name"] == sw["name"] + ), + None, + ) + hwnd = match["hwnd"] if match else None + + if hwnd: + set_window_pos(hwnd, sw["x"], sw["y"], sw["w"], sw["h"]) + restored.append({"title": sw["title"], "hwnd": hwnd}) + else: + skipped.append(sw["title"]) + + return { + "snapshot": name, + "restored": len(restored), + "skipped": len(skipped), + "details": {"restored": restored, "skipped": skipped}, + } + + +def list_snapshots() -> list[dict]: + """Return metadata for all saved snapshots.""" + SNAPSHOT_DIR.mkdir(parents=True, exist_ok=True) + snapshots = [] + for p in sorted(SNAPSHOT_DIR.glob("*.json")): + try: + data = json.loads(p.read_text()) + snapshots.append( + { + "name": data.get("name", p.stem), + "created": data.get("created", "unknown"), + "count": data.get("count", 0), + } + ) + except (json.JSONDecodeError, KeyError): + snapshots.append({"name": p.stem, "created": "?", "count": "?"}) + return snapshots + + +def delete_snapshot(name: str) -> dict: + path = _snapshot_path(name) + if not path.exists(): + raise FileNotFoundError(f"No snapshot named '{name}'.") + path.unlink() + return {"deleted": name} + + +# ── CLI ───────────────────────────────────────────────────────────────────── +def main() -> None: + parser = argparse.ArgumentParser( + description="Desktop snapshot — save and restore window layout presets" + ) + sub = parser.add_subparsers(dest="cmd") + + p_save = sub.add_parser("save", help="Save current window layout") + p_save.add_argument( + "--name", required=True, help="Snapshot name (alphanumeric/hyphen)" + ) + p_save.add_argument("--json", action="store_true") + + p_restore = sub.add_parser("restore", help="Restore a saved layout") + p_restore.add_argument("--name", required=True) + p_restore.add_argument("--json", action="store_true") + + p_list = sub.add_parser("list", help="List saved snapshots") + p_list.add_argument("--json", action="store_true") + + p_delete = sub.add_parser("delete", help="Delete a saved snapshot") + p_delete.add_argument("--name", required=True) + p_delete.add_argument("--json", action="store_true") + + args = parser.parse_args() + if not args.cmd: + parser.print_help() + sys.exit(2) + + use_json = getattr(args, "json", False) + name = _sanitize_name(getattr(args, "name", "")) if hasattr(args, "name") else "" + + try: + if args.cmd == "save": + result = save_snapshot(name) + if use_json: + print(json.dumps(result, indent=2)) + else: + print( + f"Saved '{name}': {result['count']} windows → {_snapshot_path(name)}" + ) + + elif args.cmd == "restore": + result = restore_snapshot(name) + if use_json: + print(json.dumps(result, indent=2)) + else: + print( + f"Restored '{name}': {result['restored']} windows placed" + f", {result['skipped']} skipped" + ) + if result["details"]["skipped"]: + for t in result["details"]["skipped"]: + print(f" skipped: {t[:70]}") + + elif args.cmd == "list": + snaps = list_snapshots() + if use_json: + print(json.dumps(snaps, indent=2)) + else: + if not snaps: + print(f"No snapshots found in {SNAPSHOT_DIR}") + else: + print(f"{'Name':<25} {'Created':<22} Windows") + print("─" * 55) + for s in snaps: + print(f"{s['name']:<25} {s['created']:<22} {s['count']}") + + elif args.cmd == "delete": + result = delete_snapshot(name) + if use_json: + print(json.dumps(result)) + else: + print(f"Deleted snapshot: {name}") + + except ValueError as exc: + print(f"Input error: {exc}", file=sys.stderr) + sys.exit(2) + except FileNotFoundError as exc: + if use_json: + print(json.dumps({"error": str(exc)})) + else: + print(f"Not found: {exc}", file=sys.stderr) + sys.exit(1) + except Exception as exc: + if use_json: + print(json.dumps({"error": str(exc)})) + else: + print(f"Error: {exc}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/engineering/desktop-manager/scripts/process_manager.py b/engineering/desktop-manager/scripts/process_manager.py new file mode 100644 index 00000000..bfddc799 --- /dev/null +++ b/engineering/desktop-manager/scripts/process_manager.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python3 +""" +desktop-manager: Process Manager + +List running processes, launch applications, and terminate processes on Windows +via PowerShell. Filters to windowed applications by default. + +Usage: + python scripts/process_manager.py list + python scripts/process_manager.py list --all + python scripts/process_manager.py list --filter chrome --json + python scripts/process_manager.py launch --app notepad + python scripts/process_manager.py launch --path "C:/Windows/System32/notepad.exe" + python scripts/process_manager.py launch --app code --args "--new-window C:/project" + python scripts/process_manager.py kill --name notepad + python scripts/process_manager.py kill --pid 1234 + python scripts/process_manager.py kill --name notepad --json +""" + +import argparse +import json +import re +import subprocess +import sys + + +def run_ps(script: str) -> str: + """Run a PowerShell script and return stdout. Raises RuntimeError on failure.""" + result = subprocess.run( + ["powershell", "-NoProfile", "-NonInteractive", "-Command", script], + capture_output=True, + text=True, + ) + if result.returncode not in (0, 1) and result.stderr.strip(): + raise RuntimeError(result.stderr.strip()) + return result.stdout.strip() + + +def _sanitize_name(name: str) -> str: + """Allow only safe characters in process names to prevent PS injection.""" + if not re.match(r"^[\w.\-]+$", name): + raise ValueError( + f"Invalid process name '{name}': use alphanumeric, dot, or hyphen only" + ) + return name + + +def list_processes(filter_name: str = "", all_procs: bool = False) -> list[dict]: + """ + Return running processes. Without --all, returns only windowed processes. + Optionally filter by name substring. + """ + where = "" + if not all_procs: + where = "| Where-Object { $_.MainWindowTitle -ne '' }" + + if filter_name: + safe = _sanitize_name(filter_name) + where += f" | Where-Object {{ $_.ProcessName -like '*{safe}*' }}" + + ps = f""" +$procs = Get-Process {where} | Select-Object -Property ` + Id, ProcessName, MainWindowTitle, + @{{n='CpuSec'; e={{[math]::Round($_.CPU, 1)}}}}, + @{{n='MemMB'; e={{[math]::Round($_.WorkingSet64 / 1MB, 1)}}}} +if ($procs) {{ $procs | ConvertTo-Json -Compress }} else {{ "[]" }} +""" + out = run_ps(ps) + if not out or out == "[]": + return [] + data = json.loads(out) + return data if isinstance(data, list) else [data] + + +def launch_app(app: str = "", path: str = "", args: str = "") -> dict: + """ + Launch an application by name (resolved via PATH / Windows shell) or full path. + Optional args string is passed as argument list. + """ + if path: + target = path.replace("'", "\\'") + cmd = f"Start-Process '{target}'" + else: + safe = _sanitize_name(app) + cmd = f"Start-Process '{safe}'" + + if args: + safe_args = args.replace("'", "\\'") + cmd += f" -ArgumentList '{safe_args}'" + + run_ps(cmd) + return {"launched": path or app, "args": args} + + +def kill_process(name: str = "", pid: int = 0) -> dict: + """ + Terminate one or more processes by name or PID. + Name supports wildcard (e.g. 'chrome' kills all chrome.exe instances). + """ + if pid: + ps = f"Stop-Process -Id {int(pid)} -Force -ErrorAction Stop" + run_ps(ps) + return {"killed": {"pid": pid}} + else: + safe = _sanitize_name(name) + ps = f"Stop-Process -Name '{safe}' -Force -ErrorAction Stop" + run_ps(ps) + return {"killed": {"name": name}} + + +# ── CLI ───────────────────────────────────────────────────────────────────── +def main() -> None: + parser = argparse.ArgumentParser( + description="Process manager — list, launch, and kill Windows processes" + ) + sub = parser.add_subparsers(dest="cmd") + + p_list = sub.add_parser("list", help="List running processes") + p_list.add_argument( + "--filter", default="", metavar="NAME", help="Filter by process name substring" + ) + p_list.add_argument( + "--all", action="store_true", help="Include background processes (no window)" + ) + p_list.add_argument("--json", action="store_true") + + p_launch = sub.add_parser("launch", help="Launch an application") + grp = p_launch.add_mutually_exclusive_group(required=True) + grp.add_argument( + "--app", help="App name (must be in PATH or a known Windows command)" + ) + grp.add_argument("--path", help="Full path to executable") + p_launch.add_argument( + "--args", default="", help="Arguments to pass to the application" + ) + p_launch.add_argument("--json", action="store_true") + + p_kill = sub.add_parser("kill", help="Terminate a process") + grp2 = p_kill.add_mutually_exclusive_group(required=True) + grp2.add_argument("--name", help="Process name (all matching instances)") + grp2.add_argument("--pid", type=int, help="Process ID") + p_kill.add_argument("--json", action="store_true") + + args = parser.parse_args() + if not args.cmd: + parser.print_help() + sys.exit(2) + + use_json = getattr(args, "json", False) + + try: + if args.cmd == "list": + procs = list_processes(args.filter, getattr(args, "all", False)) + if use_json: + print(json.dumps(procs, indent=2)) + else: + print( + f"{'PID':<8} {'CPU(s)':<9} {'Mem(MB)':<10} {'Name':<24} Window Title" + ) + print("─" * 95) + for p in procs: + title = (p.get("MainWindowTitle") or "")[:42] + print( + f"{p.get('Id', ''):<8} {p.get('CpuSec', ''):<9}" + f" {p.get('MemMB', ''):<10} {p.get('ProcessName', '')[:23]:<24} {title}" + ) + if not procs: + print("No matching processes found.") + + elif args.cmd == "launch": + result = launch_app(args.app or "", args.path or "", args.args) + if use_json: + print(json.dumps(result)) + else: + msg = f"Launched: {result['launched']}" + if result["args"]: + msg += f" {result['args']}" + print(msg) + + elif args.cmd == "kill": + result = kill_process(args.name or "", args.pid or 0) + if use_json: + print(json.dumps(result)) + else: + target = args.name or str(args.pid) + print(f"Killed: {target}") + + except ValueError as exc: + print(f"Input error: {exc}", file=sys.stderr) + sys.exit(2) + except Exception as exc: + if use_json: + print(json.dumps({"error": str(exc)})) + else: + print(f"Error: {exc}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/engineering/desktop-manager/scripts/window_manager.py b/engineering/desktop-manager/scripts/window_manager.py new file mode 100644 index 00000000..65fb67a6 --- /dev/null +++ b/engineering/desktop-manager/scripts/window_manager.py @@ -0,0 +1,302 @@ +#!/usr/bin/env python3 +""" +desktop-manager: Window Manager + +List, move, resize, snap, and tile windows on Windows 10/11 using PowerShell +and Win32 API. Requires PowerShell 5.1+ (default on Windows 10/11). + +Usage: + python scripts/window_manager.py list + python scripts/window_manager.py list --json + python scripts/window_manager.py snap --hwnd 1234567 --pos left-half + python scripts/window_manager.py tile --layout 2col + python scripts/window_manager.py tile --layout 3col + python scripts/window_manager.py tile --layout grid4 + python scripts/window_manager.py move --hwnd 1234567 --x 0 --y 0 --w 960 --h 1080 + python scripts/window_manager.py show --hwnd 1234567 --state maximize + python scripts/window_manager.py show --hwnd 1234567 --state focus +""" + +import argparse +import json +import subprocess +import sys + +# ── Win32 type definition (loaded once per PS invocation) ─────────────────── +WIN32_TYPE = r""" +Add-Type @" +using System; +using System.Runtime.InteropServices; +public class WinAPI { + [DllImport("user32.dll")] + public static extern bool GetWindowRect(IntPtr hWnd, out RECT lpRect); + [DllImport("user32.dll")] + public static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, + int X, int Y, int cx, int cy, uint uFlags); + [DllImport("user32.dll")] + public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); + [DllImport("user32.dll")] + public static extern bool SetForegroundWindow(IntPtr hWnd); + [StructLayout(LayoutKind.Sequential)] + public struct RECT { public int left, top, right, bottom; } +} +"@ -ErrorAction SilentlyContinue +""" + +# uFlags: SWP_NOZORDER (0x4) | SWP_NOACTIVATE (0x10) = 0x14 +SWP_FLAGS = "0x14" + + +def run_ps(script: str) -> str: + """Run PowerShell script and return stdout. Raises RuntimeError on failure.""" + result = subprocess.run( + ["powershell", "-NoProfile", "-NonInteractive", "-Command", script], + capture_output=True, + text=True, + ) + if result.returncode != 0 and result.stderr.strip(): + raise RuntimeError(result.stderr.strip()) + return result.stdout.strip() + + +def get_screen_size() -> tuple[int, int]: + """Return (width, height) of primary monitor working area.""" + ps = """ +Add-Type -AssemblyName System.Windows.Forms +$s = [System.Windows.Forms.Screen]::PrimaryScreen.WorkingArea +Write-Output "$($s.Width) $($s.Height)" +""" + out = run_ps(ps) + w, h = out.split() + return int(w), int(h) + + +def list_windows() -> list[dict]: + """Return visible windows (main window per process) with position and size.""" + ps = ( + WIN32_TYPE + + r""" +$wins = Get-Process | Where-Object { + $_.MainWindowHandle -ne [IntPtr]::Zero -and $_.MainWindowTitle -ne '' +} | ForEach-Object { + $hwnd = $_.MainWindowHandle + $rect = New-Object WinAPI+RECT + [void][WinAPI]::GetWindowRect($hwnd, [ref]$rect) + [PSCustomObject]@{ + hwnd = $hwnd.ToInt64() + title = $_.MainWindowTitle + pid = $_.Id + name = $_.ProcessName + x = $rect.left + y = $rect.top + w = $rect.right - $rect.left + h = $rect.bottom - $rect.top + } +} +if ($wins) { $wins | ConvertTo-Json -Compress } else { "[]" } +""" + ) + out = run_ps(ps) + if not out or out == "[]": + return [] + data = json.loads(out) + return data if isinstance(data, list) else [data] + + +def set_window_pos(hwnd: int, x: int, y: int, w: int, h: int) -> None: + """Move and resize a window without changing its Z-order.""" + ps = ( + WIN32_TYPE + + f""" +[WinAPI]::SetWindowPos([IntPtr]{hwnd}, [IntPtr]::Zero, {x}, {y}, {w}, {h}, {SWP_FLAGS}) +""" + ) + run_ps(ps) + + +def show_window(hwnd: int, state: int) -> None: + """Change window visibility state. SW_MINIMIZE=2, SW_MAXIMIZE=3, SW_RESTORE=9.""" + ps = WIN32_TYPE + f"[WinAPI]::ShowWindow([IntPtr]{hwnd}, {state})" + run_ps(ps) + + +def focus_window(hwnd: int) -> None: + """Bring window to foreground.""" + ps = WIN32_TYPE + f"[WinAPI]::SetForegroundWindow([IntPtr]{hwnd})" + run_ps(ps) + + +# ── Layout presets (returns x, y, w, h given screen w, h) ────────────────── +SNAP_POSITIONS: dict[str, callable] = { + "left-half": lambda sw, sh: (0, 0, sw // 2, sh), + "right-half": lambda sw, sh: (sw // 2, 0, sw // 2, sh), + "top-half": lambda sw, sh: (0, 0, sw, sh // 2), + "bot-half": lambda sw, sh: (0, sh // 2, sw, sh // 2), + "top-left": lambda sw, sh: (0, 0, sw // 2, sh // 2), + "top-right": lambda sw, sh: (sw // 2, 0, sw // 2, sh // 2), + "bot-left": lambda sw, sh: (0, sh // 2, sw // 2, sh // 2), + "bot-right": lambda sw, sh: (sw // 2, sh // 2, sw // 2, sh // 2), + "col-1-of-3": lambda sw, sh: (0, 0, sw // 3, sh), + "col-2-of-3": lambda sw, sh: (sw // 3, 0, sw // 3, sh), + "col-3-of-3": lambda sw, sh: (sw * 2 // 3, 0, sw // 3, sh), + "maximize": lambda sw, sh: (0, 0, sw, sh), +} + + +def tile_windows(layout: str) -> dict: + """Tile the first N visible windows using a named layout.""" + sw, sh = get_screen_size() + wins = [w for w in list_windows() if w["w"] > 100 and w["h"] > 100] + + if layout == "2col": + slots = [ + SNAP_POSITIONS["left-half"](sw, sh), + SNAP_POSITIONS["right-half"](sw, sh), + ] + targets = wins[:2] + elif layout == "3col": + slots = [SNAP_POSITIONS[f"col-{i}-of-3"](sw, sh) for i in [1, 2, 3]] + targets = wins[:3] + elif layout == "grid4": + slots = [ + SNAP_POSITIONS["top-left"](sw, sh), + SNAP_POSITIONS["top-right"](sw, sh), + SNAP_POSITIONS["bot-left"](sw, sh), + SNAP_POSITIONS["bot-right"](sw, sh), + ] + targets = wins[:4] + else: + raise ValueError(f"Unknown layout '{layout}'. Use: 2col, 3col, grid4") + + placed = [] + for win, (x, y, w, h) in zip(targets, slots): + set_window_pos(win["hwnd"], x, y, w, h) + placed.append( + {"hwnd": win["hwnd"], "title": win["title"], "x": x, "y": y, "w": w, "h": h} + ) + + return {"layout": layout, "tiled": len(placed), "windows": placed} + + +# ── CLI ───────────────────────────────────────────────────────────────────── +def main() -> None: + parser = argparse.ArgumentParser( + description="Window manager — list, snap, tile, and control Windows windows" + ) + sub = parser.add_subparsers(dest="cmd") + + p_list = sub.add_parser("list", help="List visible windows") + p_list.add_argument("--json", action="store_true", help="JSON output") + + p_move = sub.add_parser("move", help="Move and resize a window by HWND") + p_move.add_argument( + "--hwnd", type=int, required=True, help="Window handle (from list)" + ) + p_move.add_argument("--x", type=int, required=True) + p_move.add_argument("--y", type=int, required=True) + p_move.add_argument("--w", type=int, required=True) + p_move.add_argument("--h", type=int, required=True) + p_move.add_argument("--json", action="store_true") + + p_snap = sub.add_parser("snap", help="Snap a window to a preset screen position") + p_snap.add_argument("--hwnd", type=int, required=True) + p_snap.add_argument("--pos", choices=list(SNAP_POSITIONS.keys()), required=True) + p_snap.add_argument("--json", action="store_true") + + p_tile = sub.add_parser("tile", help="Tile windows in a layout") + p_tile.add_argument("--layout", choices=["2col", "3col", "grid4"], required=True) + p_tile.add_argument("--json", action="store_true") + + p_show = sub.add_parser("show", help="Change window state or bring to focus") + p_show.add_argument("--hwnd", type=int, required=True) + p_show.add_argument( + "--state", choices=["minimize", "maximize", "restore", "focus"], required=True + ) + p_show.add_argument("--json", action="store_true") + + args = parser.parse_args() + if not args.cmd: + parser.print_help() + sys.exit(2) + + use_json = getattr(args, "json", False) + + try: + if args.cmd == "list": + wins = list_windows() + if use_json: + print(json.dumps(wins, indent=2)) + else: + print(f"{'HWND':<14} {'PID':<8} {'W×H':<14} {'Name':<20} Title") + print("─" * 90) + for w in wins: + size = f"{w['w']}×{w['h']}" + print( + f"{w['hwnd']:<14} {w['pid']:<8} {size:<14}" + f" {w['name'][:19]:<20} {w['title'][:45]}" + ) + + elif args.cmd == "move": + set_window_pos(args.hwnd, args.x, args.y, args.w, args.h) + result = { + "hwnd": args.hwnd, + "x": args.x, + "y": args.y, + "w": args.w, + "h": args.h, + } + if use_json: + print(json.dumps(result)) + else: + print(f"Moved {args.hwnd} → ({args.x},{args.y}) {args.w}×{args.h}") + + elif args.cmd == "snap": + sw, sh = get_screen_size() + x, y, w, h = SNAP_POSITIONS[args.pos](sw, sh) + set_window_pos(args.hwnd, x, y, w, h) + result = { + "hwnd": args.hwnd, + "pos": args.pos, + "x": x, + "y": y, + "w": w, + "h": h, + } + if use_json: + print(json.dumps(result)) + else: + print(f"Snapped {args.hwnd} to {args.pos}: ({x},{y}) {w}×{h}") + + elif args.cmd == "tile": + result = tile_windows(args.layout) + if use_json: + print(json.dumps(result, indent=2)) + else: + print(f"Tiled {result['tiled']} windows [{args.layout}]:") + for w in result["windows"]: + print( + f" {w['hwnd']}: ({w['x']},{w['y']}) {w['w']}×{w['h']} {w['title'][:50]}" + ) + + elif args.cmd == "show": + state_map = {"minimize": 2, "maximize": 3, "restore": 9} + if args.state == "focus": + focus_window(args.hwnd) + else: + show_window(args.hwnd, state_map[args.state]) + result = {"hwnd": args.hwnd, "state": args.state} + if use_json: + print(json.dumps(result)) + else: + print(f"Window {args.hwnd}: {args.state}") + + except Exception as exc: + if use_json: + print(json.dumps({"error": str(exc)})) + else: + print(f"Error: {exc}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main()