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.
Files changed (52) hide show
  1. {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/PKG-INFO +1 -1
  2. {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/pyproject.toml +1 -1
  3. {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/src/use_computer/accessibility/atspi.py +36 -3
  4. {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/src/use_computer/accessibility/ax.py +11 -0
  5. {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/src/use_computer/accessibility/base.py +9 -0
  6. {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/src/use_computer/accessibility/uia.py +11 -0
  7. {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/src/use_computer/actions.py +106 -3
  8. {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/src/use_computer/cli.py +219 -20
  9. {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/src/use_computer/compare.py +40 -14
  10. {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/src/use_computer/config.py +37 -2
  11. {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/src/use_computer/errors.py +5 -5
  12. use_computer_cli-0.3.0/src/use_computer/prune.py +95 -0
  13. {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/src/use_computer/render.py +39 -2
  14. {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/src/use_computer/runner.py +174 -11
  15. {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/src/use_computer/selectors.py +154 -2
  16. {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/src/use_computer/skill/SKILL.md +175 -21
  17. {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/src/use_computer/tree.py +13 -0
  18. {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/tests/fake_provider.py +45 -2
  19. use_computer_cli-0.3.0/tests/test_blind_spots.py +157 -0
  20. {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/tests/test_cli.py +133 -12
  21. {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/tests/test_compare.py +28 -0
  22. {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/tests/test_config.py +9 -2
  23. use_computer_cli-0.3.0/tests/test_prune.py +76 -0
  24. {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/tests/test_selectors.py +53 -0
  25. use_computer_cli-0.3.0/tests/test_skill_is_true.py +131 -0
  26. use_computer_cli-0.3.0/tests/test_trusting_a_result.py +216 -0
  27. use_computer_cli-0.3.0/tests/test_which_window.py +59 -0
  28. {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/.gitignore +0 -0
  29. {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/LICENSE +0 -0
  30. {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/README.md +0 -0
  31. {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/src/use_computer/__init__.py +0 -0
  32. {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/src/use_computer/accessibility/__init__.py +0 -0
  33. {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/src/use_computer/accessibility/roles.py +0 -0
  34. {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/src/use_computer/backends/__init__.py +0 -0
  35. {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/src/use_computer/backends/base.py +0 -0
  36. {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/src/use_computer/backends/local.py +0 -0
  37. {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/src/use_computer/backends/vnc.py +0 -0
  38. {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/src/use_computer/coordinates.py +0 -0
  39. {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/src/use_computer/keys.py +0 -0
  40. {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/src/use_computer/skill/__init__.py +0 -0
  41. {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/tests/__init__.py +0 -0
  42. {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/tests/conftest.py +0 -0
  43. {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/tests/fake_backend.py +0 -0
  44. {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/tests/test_atspi_hint.py +0 -0
  45. {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/tests/test_backends.py +0 -0
  46. {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/tests/test_config_init.py +0 -0
  47. {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/tests/test_coordinates.py +0 -0
  48. {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/tests/test_elements.py +0 -0
  49. {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/tests/test_keys.py +0 -0
  50. {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/tests/test_render.py +0 -0
  51. {use_computer_cli-0.2.1 → use_computer_cli-0.3.0}/tests/test_runner.py +0 -0
  52. {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.2.1
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.2.1"
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
- return [info for info, _ in self._window_pairs()]
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 or "focused" 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 or "focused" 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 _Positioned(BaseAction):
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(BaseAction):
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