use-computer-cli 0.2.1__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.1 → use_computer_cli-0.3.0}/PKG-INFO +1 -1
- {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/pyproject.toml +1 -1
- {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/src/use_computer/accessibility/atspi.py +36 -3
- {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/src/use_computer/accessibility/ax.py +11 -0
- {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/src/use_computer/accessibility/base.py +9 -0
- {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/src/use_computer/accessibility/uia.py +11 -0
- {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/src/use_computer/actions.py +106 -3
- {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/src/use_computer/cli.py +219 -20
- {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/src/use_computer/compare.py +40 -14
- {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/src/use_computer/config.py +37 -2
- {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/src/use_computer/errors.py +5 -5
- use_computer_cli-0.3.0/src/use_computer/prune.py +95 -0
- {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/src/use_computer/render.py +39 -2
- {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/src/use_computer/runner.py +174 -11
- {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/src/use_computer/selectors.py +154 -2
- {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/src/use_computer/skill/SKILL.md +175 -21
- {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/src/use_computer/tree.py +13 -0
- {use_computer_cli-0.2.1 → 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.1 → use_computer_cli-0.3.0}/tests/test_cli.py +133 -12
- {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/tests/test_compare.py +28 -0
- {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/tests/test_config.py +9 -2
- use_computer_cli-0.3.0/tests/test_prune.py +76 -0
- {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/tests/test_selectors.py +53 -0
- use_computer_cli-0.3.0/tests/test_skill_is_true.py +131 -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.1 → use_computer_cli-0.3.0}/.gitignore +0 -0
- {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/LICENSE +0 -0
- {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/README.md +0 -0
- {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/src/use_computer/__init__.py +0 -0
- {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/src/use_computer/accessibility/__init__.py +0 -0
- {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/src/use_computer/accessibility/roles.py +0 -0
- {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/src/use_computer/backends/__init__.py +0 -0
- {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/src/use_computer/backends/base.py +0 -0
- {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/src/use_computer/backends/local.py +0 -0
- {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/src/use_computer/backends/vnc.py +0 -0
- {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/src/use_computer/coordinates.py +0 -0
- {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/src/use_computer/keys.py +0 -0
- {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/src/use_computer/skill/__init__.py +0 -0
- {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/tests/__init__.py +0 -0
- {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/tests/conftest.py +0 -0
- {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/tests/fake_backend.py +0 -0
- {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/tests/test_atspi_hint.py +0 -0
- {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/tests/test_backends.py +0 -0
- {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/tests/test_config_init.py +0 -0
- {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/tests/test_coordinates.py +0 -0
- {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/tests/test_elements.py +0 -0
- {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/tests/test_keys.py +0 -0
- {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/tests/test_render.py +0 -0
- {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/tests/test_runner.py +0 -0
- {use_computer_cli-0.2.1 → 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
|
|