use-computer-cli 0.2.2__tar.gz → 0.3.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.
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/PKG-INFO +1 -1
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/pyproject.toml +1 -1
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/src/use_computer/accessibility/atspi.py +36 -3
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/src/use_computer/accessibility/ax.py +11 -0
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/src/use_computer/accessibility/base.py +9 -0
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/src/use_computer/accessibility/uia.py +11 -0
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/src/use_computer/actions.py +106 -3
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/src/use_computer/cli.py +141 -22
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/src/use_computer/compare.py +40 -14
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/src/use_computer/errors.py +5 -5
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/src/use_computer/render.py +39 -2
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/src/use_computer/runner.py +169 -10
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/src/use_computer/selectors.py +154 -2
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/src/use_computer/skill/SKILL.md +112 -21
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/src/use_computer/tree.py +13 -0
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/tests/fake_provider.py +45 -2
- use_computer_cli-0.3.0/tests/test_blind_spots.py +157 -0
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/tests/test_cli.py +35 -3
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/tests/test_compare.py +28 -0
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/tests/test_selectors.py +53 -0
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/tests/test_skill_is_true.py +41 -0
- use_computer_cli-0.3.0/tests/test_trusting_a_result.py +216 -0
- use_computer_cli-0.3.0/tests/test_which_window.py +59 -0
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/.gitignore +0 -0
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/LICENSE +0 -0
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/README.md +0 -0
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/src/use_computer/__init__.py +0 -0
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/src/use_computer/accessibility/__init__.py +0 -0
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/src/use_computer/accessibility/roles.py +0 -0
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/src/use_computer/backends/__init__.py +0 -0
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/src/use_computer/backends/base.py +0 -0
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/src/use_computer/backends/local.py +0 -0
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/src/use_computer/backends/vnc.py +0 -0
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/src/use_computer/config.py +0 -0
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/src/use_computer/coordinates.py +0 -0
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/src/use_computer/keys.py +0 -0
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/src/use_computer/prune.py +0 -0
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/src/use_computer/skill/__init__.py +0 -0
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/tests/__init__.py +0 -0
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/tests/conftest.py +0 -0
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/tests/fake_backend.py +0 -0
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/tests/test_atspi_hint.py +0 -0
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/tests/test_backends.py +0 -0
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/tests/test_config.py +0 -0
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/tests/test_config_init.py +0 -0
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/tests/test_coordinates.py +0 -0
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/tests/test_elements.py +0 -0
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/tests/test_keys.py +0 -0
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/tests/test_prune.py +0 -0
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/tests/test_render.py +0 -0
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/tests/test_runner.py +0 -0
- {use_computer_cli-0.2.2 → use_computer_cli-0.3.0}/tests/test_skill.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: use-computer-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Execute input on a screen for computer-use agents: move, click, drag, scroll, type, key, screenshot.
|
|
5
5
|
Project-URL: Homepage, https://github.com/applica-software-guru/use-computer
|
|
6
6
|
Author: Bruno Fortunato
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "use-computer-cli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
8
8
|
description = "Execute input on a screen for computer-use agents: move, click, drag, scroll, type, key, screenshot."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -126,7 +126,31 @@ class AtspiProvider:
|
|
|
126
126
|
# --- reading -----------------------------------------------------------------------------
|
|
127
127
|
|
|
128
128
|
def windows(self) -> list[WindowInfo]:
|
|
129
|
-
|
|
129
|
+
found = [info for info, _ in self._window_pairs()]
|
|
130
|
+
if any(info.active for info in found):
|
|
131
|
+
return found
|
|
132
|
+
# Nothing claims `active`. Some desktops only ever report `focused`, so fall back to it
|
|
133
|
+
# rather than returning a list with no mark at all -- but never mix the two: `focused` on
|
|
134
|
+
# this desktop marks the shell, which is not a window anybody wants to act in.
|
|
135
|
+
return [
|
|
136
|
+
info.model_copy(update={"active": info.id in self._focused_ids()})
|
|
137
|
+
for info in found
|
|
138
|
+
]
|
|
139
|
+
|
|
140
|
+
def _focused_ids(self) -> frozenset[str]:
|
|
141
|
+
ids: set[str] = set()
|
|
142
|
+
try:
|
|
143
|
+
desktop = self._desktop()
|
|
144
|
+
for app_index, app in enumerate(self._children(desktop)):
|
|
145
|
+
try:
|
|
146
|
+
for index, window in enumerate(self._children(app)):
|
|
147
|
+
if "focused" in set(self._states(window)):
|
|
148
|
+
ids.add(f"0/{app_index}/{index}")
|
|
149
|
+
except Exception:
|
|
150
|
+
continue
|
|
151
|
+
except Exception:
|
|
152
|
+
return frozenset()
|
|
153
|
+
return frozenset(ids)
|
|
130
154
|
|
|
131
155
|
def _window_pairs(self) -> list[tuple[WindowInfo, Any]]:
|
|
132
156
|
"""Every window, with the accessible it describes, so a match can be acted on."""
|
|
@@ -147,7 +171,7 @@ class AtspiProvider:
|
|
|
147
171
|
app=app_name,
|
|
148
172
|
pid=pid if pid > 0 else None,
|
|
149
173
|
box=self._box(window),
|
|
150
|
-
active="active" in states
|
|
174
|
+
active="active" in states,
|
|
151
175
|
),
|
|
152
176
|
window,
|
|
153
177
|
)
|
|
@@ -262,7 +286,7 @@ class AtspiProvider:
|
|
|
262
286
|
|
|
263
287
|
def _is_active(self, window: Any) -> bool:
|
|
264
288
|
states = set(self._states(window))
|
|
265
|
-
return "active" in states
|
|
289
|
+
return "active" in states
|
|
266
290
|
|
|
267
291
|
#: AT-SPI reports an element that is not currently rendered at INT_MIN with a 1x1 size --
|
|
268
292
|
#: the items of a closed menu, for instance. That is a sentinel, not a position, and letting
|
|
@@ -378,6 +402,15 @@ class AtspiProvider:
|
|
|
378
402
|
return bool(interface.do_action(index))
|
|
379
403
|
return False
|
|
380
404
|
|
|
405
|
+
def activate(self, window_id: str) -> bool:
|
|
406
|
+
"""AT-SPI offers no raise for a window.
|
|
407
|
+
|
|
408
|
+
A window accessible reports no actions at all -- verified: `It supports: none` -- so there
|
|
409
|
+
is nothing native to call here. False tells the caller to focus a descendant instead,
|
|
410
|
+
which is what actually moves `_NET_ACTIVE_WINDOW`.
|
|
411
|
+
"""
|
|
412
|
+
return False
|
|
413
|
+
|
|
381
414
|
def close(self) -> None:
|
|
382
415
|
self._index = {}
|
|
383
416
|
|
|
@@ -197,6 +197,17 @@ class AxProvider:
|
|
|
197
197
|
roles.COLLAPSE: "AXPress",
|
|
198
198
|
}
|
|
199
199
|
|
|
200
|
+
def activate(self, window_id: str) -> bool:
|
|
201
|
+
"""macOS windows do accept a raise of their own: AXRaise."""
|
|
202
|
+
element = self._index.get(window_id)
|
|
203
|
+
if element is None:
|
|
204
|
+
return False
|
|
205
|
+
try:
|
|
206
|
+
status = self._api.AXUIElementPerformAction(element, "AXRaise")
|
|
207
|
+
except Exception:
|
|
208
|
+
return False
|
|
209
|
+
return bool(status == 0)
|
|
210
|
+
|
|
200
211
|
def perform(self, node_id: str, action: str, value: str | None) -> bool:
|
|
201
212
|
element = self._index.get(node_id)
|
|
202
213
|
if element is None:
|
|
@@ -50,6 +50,15 @@ class AccessibilityProvider(Protocol):
|
|
|
50
50
|
nothing at all. ``False`` is what makes ``--via auto`` fall back to a coordinate click.
|
|
51
51
|
"""
|
|
52
52
|
|
|
53
|
+
def activate(self, window_id: str) -> bool:
|
|
54
|
+
"""Bring a window forward, natively, if this platform has a way.
|
|
55
|
+
|
|
56
|
+
``True`` means the window was raised. ``False`` is not a failure: it means this platform
|
|
57
|
+
offers no raise for a window object, and the caller should focus a descendant instead --
|
|
58
|
+
which raises the top-level window on all three. AT-SPI is the ``False`` case; a window
|
|
59
|
+
accessible there reports no actions at all.
|
|
60
|
+
"""
|
|
61
|
+
|
|
53
62
|
def close(self) -> None:
|
|
54
63
|
"""Release whatever the provider holds."""
|
|
55
64
|
|
|
@@ -167,6 +167,17 @@ class UiaProvider:
|
|
|
167
167
|
|
|
168
168
|
# --- acting ------------------------------------------------------------------------------
|
|
169
169
|
|
|
170
|
+
def activate(self, window_id: str) -> bool:
|
|
171
|
+
"""Windows has a native raise: `SetActive` on the top-level control."""
|
|
172
|
+
control = self._index.get(window_id)
|
|
173
|
+
if control is None:
|
|
174
|
+
return False
|
|
175
|
+
try:
|
|
176
|
+
return bool(control.SetActive())
|
|
177
|
+
except Exception:
|
|
178
|
+
# Not every control exposes it, and the caller has a working fallback.
|
|
179
|
+
return False
|
|
180
|
+
|
|
170
181
|
def perform(self, node_id: str, action: str, value: str | None) -> bool:
|
|
171
182
|
control = self._index.get(node_id)
|
|
172
183
|
if control is None:
|
|
@@ -7,6 +7,7 @@ in actuation units and canonical key names.
|
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
+
from difflib import get_close_matches
|
|
10
11
|
from enum import Enum
|
|
11
12
|
from pathlib import Path
|
|
12
13
|
from typing import Annotated, Any, Literal
|
|
@@ -48,7 +49,25 @@ class BaseAction(BaseModel):
|
|
|
48
49
|
return None
|
|
49
50
|
|
|
50
51
|
|
|
51
|
-
class
|
|
52
|
+
class _InAWindow(BaseAction):
|
|
53
|
+
"""An action whose coordinate belongs to a particular window.
|
|
54
|
+
|
|
55
|
+
A coordinate lands on whatever window is in front. That is what a bare coordinate has always
|
|
56
|
+
meant, and it is fine until the agent meant a particular window -- which is most of the time.
|
|
57
|
+
Naming it here is how the caller says so, and the window is brought forward before the
|
|
58
|
+
coordinate is sent. Measured: two drags aimed at a canvas selected text in a terminal instead,
|
|
59
|
+
and `--verify` then confirmed the wrong action with the wrong evidence.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
window: TreeScope | None = None
|
|
63
|
+
|
|
64
|
+
@field_validator("window", mode="before")
|
|
65
|
+
@classmethod
|
|
66
|
+
def _parse_scope(cls, value: Any) -> Any:
|
|
67
|
+
return TreeScope.parse(value) if isinstance(value, str) else value
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class _Positioned(_InAWindow):
|
|
52
71
|
"""An action that may carry a coordinate. Omitting it acts where the pointer already is."""
|
|
53
72
|
|
|
54
73
|
x: int | None = None
|
|
@@ -130,7 +149,7 @@ class RightClickAction(_PositionedOrSelected):
|
|
|
130
149
|
action: Literal["right_click"] = "right_click"
|
|
131
150
|
|
|
132
151
|
|
|
133
|
-
class DragAction(
|
|
152
|
+
class DragAction(_InAWindow):
|
|
134
153
|
action: Literal["drag"] = "drag"
|
|
135
154
|
from_x: int
|
|
136
155
|
from_y: int
|
|
@@ -297,6 +316,23 @@ class ShowMenuAction(_ElementAction):
|
|
|
297
316
|
action: Literal["show_menu"] = "show_menu"
|
|
298
317
|
|
|
299
318
|
|
|
319
|
+
class ActivateAction(BaseAction):
|
|
320
|
+
"""Bring a window forward and give it keyboard focus.
|
|
321
|
+
|
|
322
|
+
The only action whose target is a window rather than an element or a point. A window object
|
|
323
|
+
exposes no actions of its own on any of the three platforms -- AT-SPI answers `It supports:
|
|
324
|
+
none` -- so it is done by focusing a descendant, which raises the top-level window everywhere.
|
|
325
|
+
"""
|
|
326
|
+
|
|
327
|
+
action: Literal["activate"] = "activate"
|
|
328
|
+
window: TreeScope
|
|
329
|
+
|
|
330
|
+
@field_validator("window", mode="before")
|
|
331
|
+
@classmethod
|
|
332
|
+
def _parse_scope(cls, value: Any) -> Any:
|
|
333
|
+
return TreeScope.parse(value) if isinstance(value, str) else value
|
|
334
|
+
|
|
335
|
+
|
|
300
336
|
Action = Annotated[
|
|
301
337
|
MoveAction
|
|
302
338
|
| ClickAction
|
|
@@ -315,7 +351,8 @@ Action = Annotated[
|
|
|
315
351
|
| CollapseAction
|
|
316
352
|
| SelectAction
|
|
317
353
|
| SetValueAction
|
|
318
|
-
| ShowMenuAction
|
|
354
|
+
| ShowMenuAction
|
|
355
|
+
| ActivateAction,
|
|
319
356
|
Field(discriminator="action"),
|
|
320
357
|
]
|
|
321
358
|
|
|
@@ -339,6 +376,72 @@ def selector_of(action: Action) -> NodeSelector | None:
|
|
|
339
376
|
#: Parses a batch file: a JSON array of action objects, discriminated on `action`.
|
|
340
377
|
ActionListAdapter: TypeAdapter[list[Action]] = TypeAdapter(list[Action])
|
|
341
378
|
|
|
379
|
+
#: Every action name, in the spelling the discriminator uses.
|
|
380
|
+
ACTION_NAMES: tuple[str, ...] = (
|
|
381
|
+
"move",
|
|
382
|
+
"click",
|
|
383
|
+
"double_click",
|
|
384
|
+
"right_click",
|
|
385
|
+
"drag",
|
|
386
|
+
"scroll",
|
|
387
|
+
"type",
|
|
388
|
+
"key",
|
|
389
|
+
"screenshot",
|
|
390
|
+
"tree",
|
|
391
|
+
"windows",
|
|
392
|
+
"focus",
|
|
393
|
+
"toggle",
|
|
394
|
+
"expand",
|
|
395
|
+
"collapse",
|
|
396
|
+
"select",
|
|
397
|
+
"set_value",
|
|
398
|
+
"show_menu",
|
|
399
|
+
"activate",
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _cli_spelling(name: str) -> str:
|
|
404
|
+
"""How this action is spelled as a command: `set_value` is typed `set-value`."""
|
|
405
|
+
return name.replace("_", "-")
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def normalise_action_names(data: Any) -> Any:
|
|
409
|
+
"""Accept the CLI's own spelling of an action name inside a batch.
|
|
410
|
+
|
|
411
|
+
The command is `use-computer set-value`; the batch wanted `set_value`. Same action, two
|
|
412
|
+
spellings, and nothing said so -- an agent that has just read `set-value --help` has no reason
|
|
413
|
+
to expect a different name three lines later.
|
|
414
|
+
"""
|
|
415
|
+
if not isinstance(data, list):
|
|
416
|
+
return data
|
|
417
|
+
out = []
|
|
418
|
+
for item in data:
|
|
419
|
+
if isinstance(item, dict) and isinstance(item.get("action"), str):
|
|
420
|
+
item = {**item, "action": item["action"].replace("-", "_")}
|
|
421
|
+
out.append(item)
|
|
422
|
+
return out
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def check_action_names(data: Any) -> None:
|
|
426
|
+
"""Fail on an unknown action with a sentence, before pydantic offers its union.
|
|
427
|
+
|
|
428
|
+
The union's own error is four hundred characters naming every variant except the one the
|
|
429
|
+
caller should have written. This is a message to somebody mid-task who cannot see the code.
|
|
430
|
+
"""
|
|
431
|
+
if not isinstance(data, list):
|
|
432
|
+
return
|
|
433
|
+
for index, item in enumerate(data):
|
|
434
|
+
if not isinstance(item, dict):
|
|
435
|
+
continue
|
|
436
|
+
name = item.get("action")
|
|
437
|
+
if not isinstance(name, str) or name in ACTION_NAMES:
|
|
438
|
+
continue
|
|
439
|
+
close = get_close_matches(name, ACTION_NAMES, n=1, cutoff=0.6)
|
|
440
|
+
suggestion = f" Did you mean {_cli_spelling(close[0])!r}?" if close else ""
|
|
441
|
+
raise ValueError(
|
|
442
|
+
f"unknown action {_cli_spelling(name)!r} at index {index}.{suggestion}"
|
|
443
|
+
)
|
|
444
|
+
|
|
342
445
|
#: Parses a single action object.
|
|
343
446
|
ActionAdapter: TypeAdapter[Action] = TypeAdapter(Action)
|
|
344
447
|
|
|
@@ -27,6 +27,7 @@ from use_computer import render
|
|
|
27
27
|
from use_computer.actions import (
|
|
28
28
|
Action,
|
|
29
29
|
ActionListAdapter,
|
|
30
|
+
ActivateAction,
|
|
30
31
|
ClickAction,
|
|
31
32
|
CollapseAction,
|
|
32
33
|
DoubleClickAction,
|
|
@@ -47,6 +48,8 @@ from use_computer.actions import (
|
|
|
47
48
|
TreeAction,
|
|
48
49
|
TypeAction,
|
|
49
50
|
WindowsAction,
|
|
51
|
+
check_action_names,
|
|
52
|
+
normalise_action_names,
|
|
50
53
|
)
|
|
51
54
|
from use_computer.config import (
|
|
52
55
|
ResolvedConfig,
|
|
@@ -81,6 +84,9 @@ EXIT_USAGE = 2
|
|
|
81
84
|
#: The command a bare invocation means. `use-computer actions.json` and `use-computer -` work.
|
|
82
85
|
DEFAULT_COMMAND = "batch"
|
|
83
86
|
|
|
87
|
+
#: How much typed text a result line carries. Enough to recognise it, not enough to become a log.
|
|
88
|
+
TYPED_TEXT_LIMIT = 48
|
|
89
|
+
|
|
84
90
|
app = typer.Typer(
|
|
85
91
|
add_completion=False,
|
|
86
92
|
no_args_is_help=True,
|
|
@@ -98,7 +104,9 @@ def _version_callback(value: bool) -> None:
|
|
|
98
104
|
if value:
|
|
99
105
|
from use_computer import __version__
|
|
100
106
|
|
|
101
|
-
|
|
107
|
+
# Text, like everything else. A contract is worth what its least consistent command is
|
|
108
|
+
# worth, and `--version` answering in JSON was the cheapest possible way to break it.
|
|
109
|
+
_write(f"use-computer {__version__}")
|
|
102
110
|
raise typer.Exit(EXIT_OK)
|
|
103
111
|
|
|
104
112
|
|
|
@@ -177,14 +185,24 @@ def _required(value: Any, flag: str) -> Any:
|
|
|
177
185
|
|
|
178
186
|
|
|
179
187
|
def _build(
|
|
180
|
-
kind: Any,
|
|
188
|
+
kind: Any,
|
|
189
|
+
selector: NodeSelector | None,
|
|
190
|
+
via: Via,
|
|
191
|
+
*,
|
|
192
|
+
window: str | None = None,
|
|
193
|
+
**fields: Any,
|
|
181
194
|
) -> Action:
|
|
182
195
|
"""Construct an action from either a coordinate or an element -- never both.
|
|
183
196
|
|
|
184
197
|
Choosing between them would be exactly the kind of silent reinterpretation this tool refuses
|
|
185
198
|
to do with coordinate spaces, so a caller that gives both is told to pick one.
|
|
199
|
+
|
|
200
|
+
`--window` is not part of that choice: on a coordinate it names the window the coordinate
|
|
201
|
+
belongs to, which is brought forward before the coordinate is sent.
|
|
186
202
|
"""
|
|
187
203
|
if selector is None:
|
|
204
|
+
if window is not None:
|
|
205
|
+
fields["window"] = window
|
|
188
206
|
return kind(**fields) # type: ignore[no-any-return]
|
|
189
207
|
if fields.get("x") is not None or fields.get("y") is not None:
|
|
190
208
|
_err.print(
|
|
@@ -196,6 +214,11 @@ def _build(
|
|
|
196
214
|
return kind(selector=selector, via=via, **fields) # type: ignore[no-any-return]
|
|
197
215
|
|
|
198
216
|
|
|
217
|
+
def _scope(window: str | None) -> TreeScope | None:
|
|
218
|
+
"""`--window` as a scope. `None` keeps a bare coordinate meaning what it always meant."""
|
|
219
|
+
return TreeScope.parse(window) if window is not None else None
|
|
220
|
+
|
|
221
|
+
|
|
199
222
|
def _selector(
|
|
200
223
|
node_id: str | None,
|
|
201
224
|
role: str | None,
|
|
@@ -248,7 +271,12 @@ def _say(template: str, **values: Any) -> None:
|
|
|
248
271
|
|
|
249
272
|
def _emit(payload: Any) -> None:
|
|
250
273
|
"""stdout is JSON and nothing else."""
|
|
251
|
-
|
|
274
|
+
_write(json.dumps(payload, ensure_ascii=False))
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _write(text: str) -> None:
|
|
278
|
+
"""stdout, unstyled. Never through rich: it soft-wraps and eats square brackets."""
|
|
279
|
+
sys.stdout.write(text + "\n")
|
|
252
280
|
sys.stdout.flush()
|
|
253
281
|
|
|
254
282
|
|
|
@@ -311,6 +339,13 @@ def _text_lines(result: Any) -> str:
|
|
|
311
339
|
if item.tree is not None:
|
|
312
340
|
if item.tree.text:
|
|
313
341
|
chunks.append(item.tree.text)
|
|
342
|
+
if item.tree.limited_by:
|
|
343
|
+
# Said, not implied: a tree with nothing under its root looks exactly like an
|
|
344
|
+
# application that exposes nothing, and the two call for opposite moves.
|
|
345
|
+
chunks.append(
|
|
346
|
+
f"# nothing below the root: {item.tree.limited_by} emptied it, "
|
|
347
|
+
"not the application"
|
|
348
|
+
)
|
|
314
349
|
elif item.tree.reason is not None:
|
|
315
350
|
shot = item.tree.screenshot
|
|
316
351
|
where = f"; screenshot at {shot.path}" if shot is not None else ""
|
|
@@ -327,10 +362,20 @@ def _text_lines(result: Any) -> str:
|
|
|
327
362
|
if item.action.action == "screenshot" and item.screenshot is not None:
|
|
328
363
|
# The path is the answer, and the only part of it worth any tokens.
|
|
329
364
|
shot = item.screenshot
|
|
330
|
-
where =
|
|
365
|
+
where = (
|
|
366
|
+
f" {shot.box[2]}x{shot.box[3]} of {shot.of}"
|
|
367
|
+
if shot.box
|
|
368
|
+
else f" {shot.width}x{shot.height}"
|
|
369
|
+
)
|
|
331
370
|
chunks.append(f"{_shorten(shot.path, folder)}{where}")
|
|
332
371
|
continue
|
|
333
|
-
|
|
372
|
+
if item.action.action == "activate" and item.activated:
|
|
373
|
+
chunks.append(
|
|
374
|
+
f"{'would activate ' if not item.performed else 'activated '}{item.activated}"
|
|
375
|
+
f" \u2014 {item.duration_ms:.0f} ms"
|
|
376
|
+
)
|
|
377
|
+
continue
|
|
378
|
+
what = item.action.action + _payload(item)
|
|
334
379
|
if item.matched is not None:
|
|
335
380
|
what += f" {item.matched.role}"
|
|
336
381
|
if item.matched.name:
|
|
@@ -339,7 +384,9 @@ def _text_lines(result: Any) -> str:
|
|
|
339
384
|
how = ""
|
|
340
385
|
if item.via is not None:
|
|
341
386
|
how = " via the platform API" if item.via.value == "action" else " via a coordinate"
|
|
342
|
-
|
|
387
|
+
if item.via.value != "action" and item.resolved is not None:
|
|
388
|
+
how += f" ({item.resolved.x}, {item.resolved.y})"
|
|
389
|
+
elif item.resolved is not None and item.resolved_from is None:
|
|
343
390
|
how = f" at ({item.resolved.x}, {item.resolved.y})"
|
|
344
391
|
done = "would " if not item.performed else ""
|
|
345
392
|
# --verify exists to give feedback, so its answer belongs on the line. `unchanged` is the
|
|
@@ -347,11 +394,7 @@ def _text_lines(result: Any) -> str:
|
|
|
347
394
|
# clicking the same wrong pixel forever.
|
|
348
395
|
verified = ""
|
|
349
396
|
if item.change is not None:
|
|
350
|
-
verified = (
|
|
351
|
-
f" \u2014 changed {item.change.magnitude:.0%}"
|
|
352
|
-
if item.change.changed
|
|
353
|
-
else " \u2014 unchanged"
|
|
354
|
-
)
|
|
397
|
+
verified = f" \u2014 {_changed(item.change)}"
|
|
355
398
|
chunks.append(f"{done}{what}{how}{verified} \u2014 {item.duration_ms:.0f} ms")
|
|
356
399
|
if item.screenshot is not None and item.screenshot.path is not None:
|
|
357
400
|
# Already captured and already paid for. Saying where saves the agent asking again,
|
|
@@ -362,6 +405,44 @@ def _text_lines(result: Any) -> str:
|
|
|
362
405
|
return "\n".join(chunks)
|
|
363
406
|
|
|
364
407
|
|
|
408
|
+
def _payload(item: Any) -> str:
|
|
409
|
+
"""What this action actually did, on the line that is its only record.
|
|
410
|
+
|
|
411
|
+
`key` without its combination, `type` without its text, a `drag` with one of its two points:
|
|
412
|
+
each is blank in exactly the place an agent looks when the screen does not match its model.
|
|
413
|
+
Eleven such lines could not be reconstructed into what a batch had done.
|
|
414
|
+
"""
|
|
415
|
+
action = item.action
|
|
416
|
+
name = action.action
|
|
417
|
+
if name == "key":
|
|
418
|
+
return f" {action.combo}"
|
|
419
|
+
if name == "type":
|
|
420
|
+
text = action.text
|
|
421
|
+
shown = text if len(text) <= TYPED_TEXT_LIMIT else text[: TYPED_TEXT_LIMIT - 1] + "\u2026"
|
|
422
|
+
# The count is what catches a truncated or a doubled paste; the text is what identifies it.
|
|
423
|
+
return f" {len(text)} chars {shown!r}"
|
|
424
|
+
if name == "drag" and item.resolved is not None and item.resolved_from is not None:
|
|
425
|
+
start, end = item.resolved_from, item.resolved
|
|
426
|
+
return f" ({start.x}, {start.y}) \u2192 ({end.x}, {end.y})"
|
|
427
|
+
if name == "move" and item.resolved is not None:
|
|
428
|
+
return f" ({item.resolved.x}, {item.resolved.y})"
|
|
429
|
+
return ""
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _changed(change: Any) -> str:
|
|
433
|
+
"""Where the screen changed, which is what an agent can act on.
|
|
434
|
+
|
|
435
|
+
A percentage cannot be compared against what was expected to happen, and rounds a real change
|
|
436
|
+
of a few thousand pixels to `changed 0%`, which reads as a denial.
|
|
437
|
+
"""
|
|
438
|
+
if not change.changed:
|
|
439
|
+
return "unchanged"
|
|
440
|
+
if change.bbox is None:
|
|
441
|
+
return "changed"
|
|
442
|
+
left, top, right, bottom = change.bbox
|
|
443
|
+
return f"changed {right - left}x{bottom - top} at {left},{top}"
|
|
444
|
+
|
|
445
|
+
|
|
365
446
|
def _shorten(path: Path | None, folder: Path | None) -> str:
|
|
366
447
|
"""The filename when the directory has already been named, the whole path otherwise."""
|
|
367
448
|
if path is None:
|
|
@@ -457,6 +538,7 @@ def _run(
|
|
|
457
538
|
def move(
|
|
458
539
|
x: Annotated[int, typer.Option("--x", help="X coordinate.")],
|
|
459
540
|
y: Annotated[int, typer.Option("--y", help="Y coordinate.")],
|
|
541
|
+
window: WindowOption = None,
|
|
460
542
|
use: UseOption = None,
|
|
461
543
|
space: SpaceOption = None,
|
|
462
544
|
delay: DelayOption = None,
|
|
@@ -467,7 +549,9 @@ def move(
|
|
|
467
549
|
) -> None:
|
|
468
550
|
"""Move the pointer."""
|
|
469
551
|
config = _config(use, space=space, delay=delay, dry_run=dry_run, verify=verify, verbose=verbose)
|
|
470
|
-
_run(
|
|
552
|
+
_run(
|
|
553
|
+
[MoveAction(x=x, y=y, space=space, window=_scope(window))], config, verbose, fmt=format
|
|
554
|
+
)
|
|
471
555
|
|
|
472
556
|
|
|
473
557
|
@app.command()
|
|
@@ -494,7 +578,7 @@ def click(
|
|
|
494
578
|
config = _config(use, space=space, delay=delay, dry_run=dry_run, verify=verify, verbose=verbose)
|
|
495
579
|
selector = _selector(id, role, name, exact, nth, window)
|
|
496
580
|
_run(
|
|
497
|
-
[_build(ClickAction, selector, via, x=x, y=y, space=space, button=button)],
|
|
581
|
+
[_build(ClickAction, selector, via, window=window, x=x, y=y, space=space, button=button)],
|
|
498
582
|
config,
|
|
499
583
|
verbose,
|
|
500
584
|
fmt=format,
|
|
@@ -523,7 +607,7 @@ def double_click(
|
|
|
523
607
|
"""Double-click an element or a coordinate."""
|
|
524
608
|
config = _config(use, space=space, delay=delay, dry_run=dry_run, verify=verify, verbose=verbose)
|
|
525
609
|
selector = _selector(id, role, name, exact, nth, window)
|
|
526
|
-
action = _build(DoubleClickAction, selector, via, x=x, y=y, space=space)
|
|
610
|
+
action = _build(DoubleClickAction, selector, via, window=window, x=x, y=y, space=space)
|
|
527
611
|
_run([action], config, verbose, fmt=format)
|
|
528
612
|
|
|
529
613
|
|
|
@@ -549,7 +633,7 @@ def right_click(
|
|
|
549
633
|
"""Click an element or a coordinate with the secondary button."""
|
|
550
634
|
config = _config(use, space=space, delay=delay, dry_run=dry_run, verify=verify, verbose=verbose)
|
|
551
635
|
selector = _selector(id, role, name, exact, nth, window)
|
|
552
|
-
action = _build(RightClickAction, selector, via, x=x, y=y, space=space)
|
|
636
|
+
action = _build(RightClickAction, selector, via, window=window, x=x, y=y, space=space)
|
|
553
637
|
_run([action], config, verbose, fmt=format)
|
|
554
638
|
|
|
555
639
|
|
|
@@ -560,6 +644,7 @@ def drag(
|
|
|
560
644
|
to_x: Annotated[int | None, typer.Option("--to-x")] = None,
|
|
561
645
|
to_y: Annotated[int | None, typer.Option("--to-y")] = None,
|
|
562
646
|
button: Annotated[MouseButton, typer.Option("--button")] = MouseButton.LEFT,
|
|
647
|
+
window: WindowOption = None,
|
|
563
648
|
use: UseOption = None,
|
|
564
649
|
space: SpaceOption = None,
|
|
565
650
|
delay: DelayOption = None,
|
|
@@ -575,7 +660,8 @@ def drag(
|
|
|
575
660
|
to_y = _required(to_y, "--to-y")
|
|
576
661
|
config = _config(use, space=space, delay=delay, dry_run=dry_run, verify=verify, verbose=verbose)
|
|
577
662
|
action = DragAction(
|
|
578
|
-
from_x=from_x, from_y=from_y, to_x=to_x, to_y=to_y, space=space, button=button
|
|
663
|
+
from_x=from_x, from_y=from_y, to_x=to_x, to_y=to_y, space=space, button=button,
|
|
664
|
+
window=_scope(window),
|
|
579
665
|
)
|
|
580
666
|
_run([action], config, verbose, fmt=format)
|
|
581
667
|
|
|
@@ -608,8 +694,8 @@ def scroll(
|
|
|
608
694
|
_run(
|
|
609
695
|
[
|
|
610
696
|
_build(
|
|
611
|
-
ScrollAction, selector, via,
|
|
612
|
-
space=space,
|
|
697
|
+
ScrollAction, selector, via, window=window, amount=amount,
|
|
698
|
+
direction=direction, x=x, y=y, space=space,
|
|
613
699
|
)
|
|
614
700
|
],
|
|
615
701
|
config,
|
|
@@ -679,6 +765,26 @@ def screenshot(
|
|
|
679
765
|
_run([action], config, verbose, fmt=format)
|
|
680
766
|
|
|
681
767
|
|
|
768
|
+
@app.command()
|
|
769
|
+
def activate(
|
|
770
|
+
window: WindowOption = None,
|
|
771
|
+
use: UseOption = None,
|
|
772
|
+
delay: DelayOption = None,
|
|
773
|
+
dry_run: DryRunOption = False,
|
|
774
|
+
verify: VerifyOption = False,
|
|
775
|
+
format: FormatOption = OutputFormat.TEXT,
|
|
776
|
+
verbose: VerboseOption = 0,
|
|
777
|
+
) -> None:
|
|
778
|
+
"""Bring a window forward and give it keyboard focus."""
|
|
779
|
+
config = _config(use, delay=delay, dry_run=dry_run, verify=verify, verbose=verbose)
|
|
780
|
+
_run(
|
|
781
|
+
[ActivateAction(window=_required(window, "--window"))],
|
|
782
|
+
config,
|
|
783
|
+
verbose,
|
|
784
|
+
fmt=format,
|
|
785
|
+
)
|
|
786
|
+
|
|
787
|
+
|
|
682
788
|
# --- element commands ---------------------------------------------------------------------------
|
|
683
789
|
|
|
684
790
|
|
|
@@ -845,8 +951,12 @@ def batch(
|
|
|
845
951
|
"""Run a batch of actions over one connection."""
|
|
846
952
|
raw = sys.stdin.read() if source == "-" else _read_file(source)
|
|
847
953
|
try:
|
|
848
|
-
|
|
849
|
-
|
|
954
|
+
payload = normalise_action_names(json.loads(raw))
|
|
955
|
+
# Named before the union gets a chance: its own error is four hundred characters listing
|
|
956
|
+
# every variant except the one the caller should have written.
|
|
957
|
+
check_action_names(payload)
|
|
958
|
+
actions = ActionListAdapter.validate_python(payload)
|
|
959
|
+
except ValueError as exc:
|
|
850
960
|
_say(
|
|
851
961
|
"[red]error:[/red] {source} is not a valid action list: {exc}",
|
|
852
962
|
source=source,
|
|
@@ -1070,13 +1180,21 @@ def _report_next_steps(kind: BackendKind, profile: str, needs_password: bool) ->
|
|
|
1070
1180
|
|
|
1071
1181
|
|
|
1072
1182
|
@config_app.command("show")
|
|
1073
|
-
def config_show(
|
|
1183
|
+
def config_show(
|
|
1184
|
+
use: UseOption = None, format: FormatOption = OutputFormat.TEXT
|
|
1185
|
+
) -> None:
|
|
1074
1186
|
"""Print every resolved value, the layer it came from, and the variable that overrides it."""
|
|
1075
1187
|
try:
|
|
1076
1188
|
resolved = _config(use)
|
|
1077
1189
|
except UseComputerError as exc:
|
|
1078
1190
|
_fail(exc)
|
|
1079
|
-
|
|
1191
|
+
payload = resolved.show()
|
|
1192
|
+
if format is OutputFormat.JSON:
|
|
1193
|
+
_emit(payload)
|
|
1194
|
+
return
|
|
1195
|
+
# This is the command named first when a profile misbehaves, so it is the worst one to answer
|
|
1196
|
+
# with two kilobytes on one line.
|
|
1197
|
+
_write(render.config(payload))
|
|
1080
1198
|
|
|
1081
1199
|
|
|
1082
1200
|
# --- skill -------------------------------------------------------------------------------------
|
|
@@ -1162,6 +1280,7 @@ _COMMANDS = frozenset(
|
|
|
1162
1280
|
"select",
|
|
1163
1281
|
"set-value",
|
|
1164
1282
|
"show-menu",
|
|
1283
|
+
"activate",
|
|
1165
1284
|
"batch",
|
|
1166
1285
|
"config",
|
|
1167
1286
|
"skill",
|