pctr 0.1.0__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pctr
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Element-based Windows UI Automation CLI for AI agents. Find controls by name/type/id and click, type, hold, drag, and screenshot - no pixel coordinates.
5
5
  Author: Space-lab515
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pctr"
7
- version = "0.1.0"
7
+ version = "0.2.0"
8
8
  description = "Element-based Windows UI Automation CLI for AI agents. Find controls by name/type/id and click, type, hold, drag, and screenshot - no pixel coordinates."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -36,3 +36,6 @@ pctr = "pctr.cli:main"
36
36
 
37
37
  [tool.setuptools.packages.find]
38
38
  where = ["src"]
39
+
40
+ [tool.setuptools.package-data]
41
+ pctr = ["data/*.md"]
@@ -314,6 +314,223 @@ def cmd_drag(args):
314
314
  print("dragged %d,%d -> %d,%d" % (x1, y1, x2, y2))
315
315
 
316
316
 
317
+ _OCR_PS = r'''
318
+ param([string]$Path)
319
+ Add-Type -AssemblyName System.Runtime.WindowsRuntime
320
+ $asTaskGeneric = ([System.WindowsRuntimeSystemExtensions].GetMethods() | Where-Object { $_.Name -eq 'AsTask' -and $_.GetParameters().Count -eq 1 -and $_.GetParameters()[0].ParameterType.Name -eq 'IAsyncOperation`1' })[0]
321
+ function Await($op, $type) {
322
+ $m = $asTaskGeneric.MakeGenericMethod($type)
323
+ $t = $m.Invoke($null, @($op))
324
+ $t.Wait(-1) | Out-Null
325
+ $t.Result
326
+ }
327
+ $null = [Windows.Storage.StorageFile,Windows.Storage,ContentType=WindowsRuntime]
328
+ $null = [Windows.Media.Ocr.OcrEngine,Windows.Foundation,ContentType=WindowsRuntime]
329
+ $null = [Windows.Graphics.Imaging.BitmapDecoder,Windows.Foundation,ContentType=WindowsRuntime]
330
+ $file = Await ([Windows.Storage.StorageFile]::GetFileFromPathAsync($Path)) ([Windows.Storage.StorageFile])
331
+ $stream = Await ($file.OpenAsync([Windows.Storage.FileAccessMode]::Read)) ([Windows.Storage.Streams.IRandomAccessStream])
332
+ $decoder = Await ([Windows.Graphics.Imaging.BitmapDecoder]::CreateAsync($stream)) ([Windows.Graphics.Imaging.BitmapDecoder])
333
+ $bitmap = Await ($decoder.GetSoftwareBitmapAsync()) ([Windows.Graphics.Imaging.SoftwareBitmap])
334
+ $engine = [Windows.Media.Ocr.OcrEngine]::TryCreateFromUserProfileLanguages()
335
+ if (-not $engine) {
336
+ $null = [Windows.Globalization.Language,Windows.Foundation,ContentType=WindowsRuntime]
337
+ $engine = [Windows.Media.Ocr.OcrEngine]::TryCreateFromLanguage((New-Object Windows.Globalization.Language 'en-US'))
338
+ }
339
+ if (-not $engine) { Write-Error 'no OCR engine available'; exit 2 }
340
+ $res = Await ($engine.RecognizeAsync($bitmap)) ([Windows.Media.Ocr.OcrResult])
341
+ $out = foreach ($line in $res.Lines) { foreach ($w in $line.Words) { [pscustomobject]@{ text = $w.Text; x = [int]$w.BoundingRect.X; y = [int]$w.BoundingRect.Y; w = [int]$w.BoundingRect.Width; h = [int]$w.BoundingRect.Height } } }
342
+ ConvertTo-Json -Compress -InputObject @($out)
343
+ '''
344
+
345
+
346
+ def _windows_ocr(image_path):
347
+ import json
348
+ import os
349
+ import subprocess
350
+ import tempfile
351
+ script = os.path.join(tempfile.gettempdir(), "pctr_ocr.ps1")
352
+ with open(script, "w", encoding="utf-8") as f:
353
+ f.write(_OCR_PS)
354
+ out = subprocess.run(
355
+ ["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", script, "-Path", image_path],
356
+ capture_output=True, text=True,
357
+ )
358
+ txt = (out.stdout or "").strip()
359
+ if not txt:
360
+ raise SystemExit("OCR failed: " + (out.stderr or "").strip()[:400])
361
+ data = json.loads(txt, strict=False)
362
+ if isinstance(data, dict):
363
+ data = [data]
364
+ return [w for w in data if w and w.get("text")]
365
+
366
+
367
+ def _ocr_capture(args):
368
+ import os
369
+ import tempfile
370
+ import pyautogui
371
+ path = os.path.join(tempfile.gettempdir(), "pctr_ocr.png")
372
+ ox = oy = 0
373
+ title = getattr(args, "title", None)
374
+ if title:
375
+ win = _find_window(title, getattr(args, "process", None), getattr(args, "timeout", 5.0))
376
+ r = win.rectangle()
377
+ img = pyautogui.screenshot(region=(r.left, r.top, r.width(), r.height()))
378
+ ox, oy = r.left, r.top
379
+ else:
380
+ img = pyautogui.screenshot()
381
+ img.save(path)
382
+ return _windows_ocr(path), ox, oy
383
+
384
+
385
+ def cmd_ocr(args):
386
+ words, ox, oy = _ocr_capture(args)
387
+ for w in words:
388
+ print("%s | %d,%d %dx%d" % (w["text"], ox + w["x"], oy + w["y"], w["w"], w["h"]))
389
+ print("(%d words)" % len(words))
390
+
391
+
392
+ def cmd_ocrfind(args):
393
+ import re
394
+ words, ox, oy = _ocr_capture(args)
395
+ rx = re.compile(args.text, re.I)
396
+ hits = [w for w in words if rx.search(w["text"])]
397
+ for i, w in enumerate(hits):
398
+ print("[%d] %s | center=%d,%d" % (i, w["text"], ox + w["x"] + w["w"] // 2, oy + w["y"] + w["h"] // 2))
399
+ if not hits:
400
+ print("no OCR match for %r" % args.text)
401
+
402
+
403
+ def cmd_ocrclick(args):
404
+ import re
405
+ import pyautogui
406
+ words, ox, oy = _ocr_capture(args)
407
+ rx = re.compile(args.text, re.I)
408
+ hits = [w for w in words if rx.search(w["text"])]
409
+ if not hits:
410
+ raise SystemExit("no OCR match for %r" % args.text)
411
+ idx = getattr(args, "nth", 0)
412
+ if idx >= len(hits):
413
+ raise SystemExit("matched %d word(s); nth=%d out of range" % (len(hits), idx))
414
+ w = hits[idx]
415
+ cx = ox + w["x"] + w["w"] // 2
416
+ cy = oy + w["y"] + w["h"] // 2
417
+ pyautogui.FAILSAFE = False
418
+ pyautogui.moveTo(cx, cy, duration=0.0)
419
+ pyautogui.click()
420
+ print("ocr-clicked %r at %d,%d" % (w["text"], cx, cy))
421
+
422
+
423
+ def _look_detect(args):
424
+ import os
425
+ import tempfile
426
+ import pyautogui
427
+ try:
428
+ from ultralytics.models import YOLOWorld
429
+ except Exception as e:
430
+ raise SystemExit("YOLO-World needs ultralytics: pip install ultralytics (%s)" % e)
431
+ path = os.path.join(tempfile.gettempdir(), "pctr_look.png")
432
+ ox = oy = 0
433
+ title = getattr(args, "title", None)
434
+ if title:
435
+ win = _find_window(title, getattr(args, "process", None), getattr(args, "timeout", 5.0))
436
+ r = win.rectangle()
437
+ img = pyautogui.screenshot(region=(r.left, r.top, r.width(), r.height()))
438
+ ox, oy = r.left, r.top
439
+ else:
440
+ img = pyautogui.screenshot()
441
+ img.save(path)
442
+ model = YOLOWorld(getattr(args, "model", None) or "yolov8s-worldv2.pt")
443
+ model.set_classes([args.for_])
444
+ conf = args.conf if getattr(args, "conf", None) is not None else 0.35
445
+ results = model.predict(path, conf=conf, agnostic_nms=True, verbose=False)
446
+ r0 = results[0]
447
+ names = r0.names
448
+ boxes = r0.boxes
449
+ dets = []
450
+ if boxes is None:
451
+ return dets
452
+ for i in range(len(boxes)):
453
+ x1, y1, x2, y2 = [float(v) for v in boxes.xyxy[i].cpu().numpy()]
454
+ dets.append({
455
+ "label": names.get(int(boxes.cls[i]), str(int(boxes.cls[i]))) if isinstance(names, dict) else str(int(boxes.cls[i])),
456
+ "conf": float(boxes.conf[i].cpu().numpy()),
457
+ "x": int(ox + (x1 + x2) / 2),
458
+ "y": int(oy + (y1 + y2) / 2),
459
+ "box": (int(ox + x1), int(oy + y1), int(ox + x2), int(oy + y2)),
460
+ })
461
+ dets.sort(key=lambda d: -d["conf"])
462
+ return dets
463
+
464
+
465
+ def cmd_look(args):
466
+ dets = _look_detect(args)
467
+ for i, d in enumerate(dets):
468
+ print("[%d] %s %.2f | center=%d,%d | box=%d,%d,%d,%d" % (i, d["label"], d["conf"], d["x"], d["y"], *d["box"]))
469
+ if not dets:
470
+ print("nothing found for %r" % args.for_)
471
+
472
+
473
+ def cmd_lookclick(args):
474
+ import pyautogui
475
+ dets = _look_detect(args)
476
+ if not dets:
477
+ raise SystemExit("nothing found for %r" % args.for_)
478
+ idx = getattr(args, "nth", 0)
479
+ if idx >= len(dets):
480
+ raise SystemExit("found %d; nth=%d out of range" % (len(dets), idx))
481
+ d = dets[idx]
482
+ pyautogui.FAILSAFE = False
483
+ pyautogui.moveTo(d["x"], d["y"], duration=0.0)
484
+ pyautogui.click()
485
+ print("look-clicked %r (%.2f) at %d,%d" % (d["label"], d["conf"], d["x"], d["y"]))
486
+
487
+
488
+ SETUP_TARGETS = {
489
+ "opencode": (".config/opencode/skills/pctr", "SKILL.md"),
490
+ "claude": (".claude/skills/pctr", "SKILL.md"),
491
+ "agents": (".agents/skills/pctr", "SKILL.md"),
492
+ }
493
+
494
+
495
+ def _data_text(name):
496
+ from importlib import resources
497
+ return resources.files("pctr").joinpath("data", name).read_text(encoding="utf-8")
498
+
499
+
500
+ def cmd_skill(args):
501
+ if getattr(args, "install", False):
502
+ return cmd_setup(args)
503
+ print(_data_text("SKILL.md"))
504
+
505
+
506
+ def cmd_setup(args):
507
+ import os
508
+ from pathlib import Path
509
+ skill = _data_text("SKILL.md")
510
+ frag = _data_text("AGENTS.md")
511
+ home = Path.home()
512
+ targets = list(SETUP_TARGETS) if getattr(args, "target", "all") in (None, "all") else [args.target]
513
+ for t in targets:
514
+ rel, fname = SETUP_TARGETS[t]
515
+ d = home / rel
516
+ d.mkdir(parents=True, exist_ok=True)
517
+ (d / fname).write_text(skill, encoding="utf-8")
518
+ print("installed skill: %s" % (d / fname))
519
+ proj = Path(getattr(args, "project", None) or os.getcwd())
520
+ ag = proj / "AGENTS.md"
521
+ existing = ag.read_text(encoding="utf-8", errors="replace") if ag.exists() else ""
522
+ marker = "<!-- pctr -->"
523
+ if marker in existing:
524
+ print("AGENTS.md already has a pctr section: %s" % ag)
525
+ else:
526
+ with open(ag, "a", encoding="utf-8") as f:
527
+ if existing and not existing.endswith("\n"):
528
+ f.write("\n")
529
+ f.write("\n" + frag + "\n")
530
+ print("appended pctr section to: %s" % ag)
531
+ print("done. restart your agent so it loads the skill.")
532
+
533
+
317
534
  def add_target(p, require_name=False):
318
535
  p.add_argument("--title", required=require_name, help="Window title regex")
319
536
  p.add_argument("--process", type=int, default=None, help="Filter by process id")
@@ -445,6 +662,57 @@ def build_parser():
445
662
  dr.add_argument("--direct", action="store_true")
446
663
  dr.set_defaults(func=cmd_drag)
447
664
 
665
+ oc = sub.add_parser("ocr", help="OCR the screen (or a window) and list words with boxes")
666
+ oc.add_argument("--title", default=None)
667
+ oc.add_argument("--process", type=int, default=None)
668
+ oc.add_argument("--timeout", type=float, default=5.0)
669
+ oc.set_defaults(func=cmd_ocr)
670
+
671
+ ocf = sub.add_parser("ocrfind", help="OCR then list words matching --text (with click centers)")
672
+ ocf.add_argument("--title", default=None)
673
+ ocf.add_argument("--process", type=int, default=None)
674
+ ocf.add_argument("--timeout", type=float, default=5.0)
675
+ ocf.add_argument("--text", required=True)
676
+ ocf.set_defaults(func=cmd_ocrfind)
677
+
678
+ occ = sub.add_parser("ocrclick", help="OCR then click the word matching --text")
679
+ occ.add_argument("--title", default=None)
680
+ occ.add_argument("--process", type=int, default=None)
681
+ occ.add_argument("--timeout", type=float, default=5.0)
682
+ occ.add_argument("--text", required=True)
683
+ occ.add_argument("--nth", type=int, default=0)
684
+ occ.set_defaults(func=cmd_ocrclick)
685
+
686
+ lk = sub.add_parser("look", help="Open-vocab detect objects by text prompt (YOLO-World)")
687
+ lk.add_argument("--for", dest="for_", required=True, help="object to look for, e.g. 'save button'")
688
+ lk.add_argument("--title", default=None)
689
+ lk.add_argument("--process", type=int, default=None)
690
+ lk.add_argument("--timeout", type=float, default=5.0)
691
+ lk.add_argument("--conf", type=float, default=None)
692
+ lk.add_argument("--model", default=None)
693
+ lk.set_defaults(func=cmd_look)
694
+
695
+ lkc = sub.add_parser("lookclick", help="Open-vocab detect then click the top match")
696
+ lkc.add_argument("--for", dest="for_", required=True, help="object to look for")
697
+ lkc.add_argument("--title", default=None)
698
+ lkc.add_argument("--process", type=int, default=None)
699
+ lkc.add_argument("--timeout", type=float, default=5.0)
700
+ lkc.add_argument("--conf", type=float, default=None)
701
+ lkc.add_argument("--model", default=None)
702
+ lkc.add_argument("--nth", type=int, default=0)
703
+ lkc.set_defaults(func=cmd_lookclick)
704
+
705
+ sk = sub.add_parser("skill", help="Print the agent skill (SKILL.md), or --install it")
706
+ sk.add_argument("--install", action="store_true")
707
+ sk.add_argument("--target", default="all", choices=["all", "opencode", "claude", "agents"])
708
+ sk.add_argument("--project", default=None)
709
+ sk.set_defaults(func=cmd_skill)
710
+
711
+ st = sub.add_parser("setup", help="Install the pctr skill for your agents + append to AGENTS.md")
712
+ st.add_argument("--target", default="all", choices=["all", "opencode", "claude", "agents"])
713
+ st.add_argument("--project", default=None)
714
+ st.set_defaults(func=cmd_setup)
715
+
448
716
  return ap
449
717
 
450
718
 
@@ -0,0 +1,17 @@
1
+ <!-- pctr -->
2
+ ## Desktop automation (pctr)
3
+
4
+ `pctr` is installed. Use it to drive Windows GUI apps by **element name**, not
5
+ pixel coordinates:
6
+
7
+ ```bash
8
+ pctr windows # list windows
9
+ pctr tree --title "App" --limit 100 # inspect controls
10
+ pctr click --title "App" --name "Save"
11
+ pctr set --title "App" --name "Search" --text "hello"
12
+ pctr ocrfind --text "Sign in" # fallback: read text off the screen
13
+ pctr look --for "a red error icon" # fallback: open-vocab object detection
14
+ ```
15
+
16
+ Prefer `set` over `type` for fields. Global `type`/`keys` need `pctr focus` first.
17
+ Run `pctr -h` for all commands. Full skill: `pctr skill`.
@@ -0,0 +1,81 @@
1
+ ---
2
+ name: pctr
3
+ description: Drive native Windows desktop apps (not browsers) with the pctr CLI powered by pywinauto/UIA, with OCR and YOLO-World fallbacks. Use whenever you must control or inspect a desktop GUI - list windows, dump a control tree, click buttons, type into fields, read on-screen text by OCR, find objects by description, send hotkeys, hold keys, drag, or screenshot an app. Element-based, no pixel hunting. Triggers: pctr, UIA, UI automation, pywinauto, desktop automation, click a button in an app, type into an app, drive a GUI, control a Windows application, usecomputer.
4
+ ---
5
+
6
+ # pctr - desktop control
7
+
8
+ Element-based Windows automation. Find a control by its name / control type /
9
+ automation id and act on it, so window moves, DPI, and layout changes don't break
10
+ you. Windows only (UI Automation). Built on `pywinauto` + `uiautomation`, with
11
+ `pyautogui` / `pydirectinput` for raw mouse and keyboard.
12
+
13
+ ## Three ways to find a target
14
+
15
+ 1. **UIA elements** (best) - `tree` / `find` / `click` by control name/id.
16
+ 2. **OCR** - `ocr` / `ocrfind` / `ocrclick` (Windows built-in `Windows.Media.Ocr`, no deps) reads on-screen text that isn't exposed as a control.
17
+ 3. **YOLO-World** - `look --for "..."` / `lookclick --for "..."` open-vocabulary object detection by natural-language prompt (canvas, games, custom-drawn UI).
18
+
19
+ ## The loop
20
+
21
+ ```bash
22
+ pctr windows # top-level windows + pids
23
+ pctr tree --title "Notepad" --limit 120 # dump the control tree
24
+ pctr find --title "Notepad" --name "Submit"
25
+ pctr click --title "Notepad" --name "^File$" --control-type MenuItem
26
+ pctr shot --title "Notepad" --out "C:\temp\np.png"
27
+ ```
28
+
29
+ Look at `tree`/`find` output first - the name/type/id it prints is what you pass
30
+ back as `--name`, `--control-type`, `--auto-id`.
31
+
32
+ ## Commands
33
+
34
+ | Command | What it does |
35
+ |---------|--------------|
36
+ | `windows [--filter RE]` | List top-level windows. |
37
+ | `tree --title RE [--limit N]` | Dump the UIA control tree. |
38
+ | `find --title RE --name RE [--control-type T] [--auto-id ID]` | List matching elements. |
39
+ | `click --title RE --name RE [--method auto\|invoke\|mouse] [--dbl]` | Click an element. |
40
+ | `set --title RE --name RE --text S` | Set a field via the UIA ValuePattern (exact, instant). |
41
+ | `type [--title RE --name RE] --text S [--delay 0.03] [--chunk 1]` | Type literal text. |
42
+ | `keys --keys "{ENTER}"` | Global key combo (pywinauto send_keys syntax). |
43
+ | `hotkey --keys win+shift+s` | Modifier combo. |
44
+ | `focus --title RE` | Bring a window to the foreground. |
45
+ | `wait --title RE --name RE [--timeout S]` | Wait for an element. |
46
+ | `shot [--title RE] --out PATH` | Screenshot the screen or a window. |
47
+ | `size` | Print primary screen size as WxH. |
48
+ | `move --x N --y N` / `down` / `up` / `hold --ms N` | Raw mouse control. |
49
+ | `drag --start x,y --end x,y [--duration S]` | Drag between points. |
50
+ | `keydown / keyup --key a` / `keyhold --key w --ms 1500` | Hold keyboard keys. |
51
+ | `ocr [--title RE]` | OCR the screen (or window); list words with boxes. |
52
+ | `ocrfind --text RE` | OCR then list matches with click centers. |
53
+ | `ocrclick --text RE [--nth N]` | OCR then click a word. |
54
+ | `look --for "a dog" [--title RE] [--conf X]` | YOLO-World detect objects by text prompt. |
55
+ | `lookclick --for "a dog" [--nth N]` | Detect then click the top match. |
56
+ | `skill [--install]` / `setup` | Print/install this skill + an AGENTS.md section. |
57
+
58
+ Filters: `--title` (regex), `--name` (regex), `--control-type`, `--auto-id`,
59
+ `--nth`, `--process`, `--timeout`.
60
+
61
+ ## Patterns
62
+
63
+ - **Text fields -> use `set`, not `type`.** `set` uses the ValuePattern: atomic, can't drop chars.
64
+ - **`type` is char-by-char** (`--chunk 1 --delay 0.03`); raise `--delay` for Qt/Electron.
65
+ - **Global `type`/`keys` go to the OS-focused window** - `pctr focus --title ...` first.
66
+ - Click falls back to a real mouse click when a control has no invoke pattern.
67
+ - Disambiguate with `--control-type` / `--auto-id` / `--nth`.
68
+
69
+ ## App notes
70
+
71
+ - **Qt apps** expose a rich tree including embedded webviews.
72
+ - **Electron apps** (Discord, VS Code, OpenCode) hide web content - use `focus` +
73
+ global `type`/`keys`, verify with `shot` (and fall back to `ocr`/`look`).
74
+ - **Canvas / games / custom UI**: no tree - use `ocr` for text and `look` for objects.
75
+
76
+ ## Setup
77
+
78
+ ```bash
79
+ pip install pctr
80
+ pctr setup # installs this skill for opencode / claude / agents + appends to ./AGENTS.md
81
+ ```
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pctr
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Element-based Windows UI Automation CLI for AI agents. Find controls by name/type/id and click, type, hold, drag, and screenshot - no pixel coordinates.
5
5
  Author: Space-lab515
6
6
  License-Expression: MIT
@@ -9,4 +9,6 @@ src/pctr.egg-info/SOURCES.txt
9
9
  src/pctr.egg-info/dependency_links.txt
10
10
  src/pctr.egg-info/entry_points.txt
11
11
  src/pctr.egg-info/requires.txt
12
- src/pctr.egg-info/top_level.txt
12
+ src/pctr.egg-info/top_level.txt
13
+ src/pctr/data/AGENTS.md
14
+ src/pctr/data/SKILL.md
File without changes
File without changes
File without changes
File without changes
File without changes