use-computer-cli 0.1.0__py3-none-any.whl
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/__init__.py +93 -0
- use_computer/actions.py +184 -0
- use_computer/backends/__init__.py +31 -0
- use_computer/backends/base.py +66 -0
- use_computer/backends/local.py +214 -0
- use_computer/backends/vnc.py +186 -0
- use_computer/cli.py +676 -0
- use_computer/compare.py +121 -0
- use_computer/config.py +532 -0
- use_computer/coordinates.py +104 -0
- use_computer/errors.py +53 -0
- use_computer/keys.py +209 -0
- use_computer/runner.py +304 -0
- use_computer/skill/SKILL.md +118 -0
- use_computer/skill/__init__.py +185 -0
- use_computer_cli-0.1.0.dist-info/METADATA +150 -0
- use_computer_cli-0.1.0.dist-info/RECORD +20 -0
- use_computer_cli-0.1.0.dist-info/WHEEL +4 -0
- use_computer_cli-0.1.0.dist-info/entry_points.txt +2 -0
- use_computer_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
use_computer/cli.py
ADDED
|
@@ -0,0 +1,676 @@
|
|
|
1
|
+
"""The command line the calling agent actually uses.
|
|
2
|
+
|
|
3
|
+
Two constraints here are not style choices:
|
|
4
|
+
|
|
5
|
+
* The default command is implemented by rewriting ``argv`` in the console-script entry point.
|
|
6
|
+
It is never implemented by subclassing ``TyperGroup``: typer 0.27 stopped being click-based
|
|
7
|
+
and that approach breaks silently.
|
|
8
|
+
* JSON is printed with plain ``json.dumps``. rich soft-wraps long strings and can emit a
|
|
9
|
+
newline inside a JSON string, which corrupts the output an agent parses. rich is used only
|
|
10
|
+
for human-facing text, and only on stderr.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import sys
|
|
17
|
+
from collections.abc import Sequence
|
|
18
|
+
from enum import Enum
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Annotated, Any, NoReturn
|
|
21
|
+
|
|
22
|
+
import typer
|
|
23
|
+
from rich.console import Console
|
|
24
|
+
|
|
25
|
+
from use_computer.actions import (
|
|
26
|
+
Action,
|
|
27
|
+
ActionListAdapter,
|
|
28
|
+
ClickAction,
|
|
29
|
+
DoubleClickAction,
|
|
30
|
+
DragAction,
|
|
31
|
+
KeyAction,
|
|
32
|
+
MouseButton,
|
|
33
|
+
MoveAction,
|
|
34
|
+
RightClickAction,
|
|
35
|
+
ScreenshotAction,
|
|
36
|
+
ScrollAction,
|
|
37
|
+
ScrollDirection,
|
|
38
|
+
TypeAction,
|
|
39
|
+
)
|
|
40
|
+
from use_computer.config import ResolvedConfig, profile_env_var, write_initial_config
|
|
41
|
+
from use_computer.config import load as load_config
|
|
42
|
+
from use_computer.coordinates import CoordinateSpace
|
|
43
|
+
from use_computer.errors import UseComputerError
|
|
44
|
+
from use_computer.runner import Session, as_json
|
|
45
|
+
from use_computer.skill import Scope
|
|
46
|
+
from use_computer.skill import install as skill_install
|
|
47
|
+
from use_computer.skill import remove as skill_remove
|
|
48
|
+
from use_computer.skill import status as skill_status
|
|
49
|
+
from use_computer.skill import update as skill_update
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class BackendKind(str, Enum):
|
|
53
|
+
"""The backends `config init` can write a profile for."""
|
|
54
|
+
|
|
55
|
+
LOCAL = "local"
|
|
56
|
+
VNC = "vnc"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
EXIT_OK = 0
|
|
60
|
+
EXIT_FAILURE = 1
|
|
61
|
+
EXIT_USAGE = 2
|
|
62
|
+
|
|
63
|
+
#: The command a bare invocation means. `use-computer actions.json` and `use-computer -` work.
|
|
64
|
+
DEFAULT_COMMAND = "batch"
|
|
65
|
+
|
|
66
|
+
app = typer.Typer(
|
|
67
|
+
add_completion=False,
|
|
68
|
+
no_args_is_help=True,
|
|
69
|
+
help="Execute input on a screen: move, click, drag, scroll, type, key, screenshot.",
|
|
70
|
+
)
|
|
71
|
+
skill_app = typer.Typer(no_args_is_help=True, help="Manage the bundled agent skill.")
|
|
72
|
+
config_app = typer.Typer(no_args_is_help=True, help="Inspect configuration.")
|
|
73
|
+
app.add_typer(skill_app, name="skill")
|
|
74
|
+
app.add_typer(config_app, name="config")
|
|
75
|
+
|
|
76
|
+
_err = Console(stderr=True, highlight=False, soft_wrap=True)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _version_callback(value: bool) -> None:
|
|
80
|
+
if value:
|
|
81
|
+
from use_computer import __version__
|
|
82
|
+
|
|
83
|
+
_emit({"version": __version__})
|
|
84
|
+
raise typer.Exit(EXIT_OK)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@app.callback()
|
|
88
|
+
def _main(
|
|
89
|
+
version: Annotated[
|
|
90
|
+
bool,
|
|
91
|
+
typer.Option(
|
|
92
|
+
"--version", callback=_version_callback, is_eager=True, help="Print the version."
|
|
93
|
+
),
|
|
94
|
+
] = False,
|
|
95
|
+
) -> None:
|
|
96
|
+
"""Execute input on a screen: move, click, drag, scroll, type, key, screenshot."""
|
|
97
|
+
|
|
98
|
+
# --- shared options ----------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
UseOption = Annotated[
|
|
101
|
+
str | None, typer.Option("--use", "-u", help="Named profile to use.", metavar="PROFILE")
|
|
102
|
+
]
|
|
103
|
+
XOption = Annotated[int | None, typer.Option("--x", help="X coordinate.")]
|
|
104
|
+
YOption = Annotated[int | None, typer.Option("--y", help="Y coordinate.")]
|
|
105
|
+
SpaceOption = Annotated[
|
|
106
|
+
CoordinateSpace | None,
|
|
107
|
+
typer.Option("--space", help="Coordinate space of the coordinates given."),
|
|
108
|
+
]
|
|
109
|
+
DelayOption = Annotated[
|
|
110
|
+
float | None, typer.Option("--delay", help="Seconds to wait after each action.")
|
|
111
|
+
]
|
|
112
|
+
DryRunOption = Annotated[
|
|
113
|
+
bool, typer.Option("--dry-run", help="Resolve and log without performing.")
|
|
114
|
+
]
|
|
115
|
+
VerifyOption = Annotated[
|
|
116
|
+
bool, typer.Option("--verify", help="Compare the screen before and after each action.")
|
|
117
|
+
]
|
|
118
|
+
VerboseOption = Annotated[int, typer.Option("-v", count=True, help="Diagnostics on stderr.")]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _emit(payload: Any) -> None:
|
|
122
|
+
"""stdout is JSON and nothing else."""
|
|
123
|
+
sys.stdout.write(json.dumps(payload, ensure_ascii=False) + "\n")
|
|
124
|
+
sys.stdout.flush()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _fail(exc: BaseException) -> NoReturn:
|
|
128
|
+
_err.print(f"[red]error:[/red] {exc}")
|
|
129
|
+
raise typer.Exit(EXIT_FAILURE)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _config(
|
|
133
|
+
profile: str | None,
|
|
134
|
+
*,
|
|
135
|
+
space: CoordinateSpace | None = None,
|
|
136
|
+
delay: float | None = None,
|
|
137
|
+
dry_run: bool = False,
|
|
138
|
+
verify: bool = False,
|
|
139
|
+
continue_on_error: bool = False,
|
|
140
|
+
verbose: int = 0,
|
|
141
|
+
) -> ResolvedConfig:
|
|
142
|
+
overrides: dict[str, Any] = {
|
|
143
|
+
"space": space,
|
|
144
|
+
"delay": delay,
|
|
145
|
+
"dry_run": dry_run or None,
|
|
146
|
+
"verify": verify or None,
|
|
147
|
+
"continue_on_error": continue_on_error or None,
|
|
148
|
+
}
|
|
149
|
+
resolved = load_config(overrides, profile=profile)
|
|
150
|
+
for warning in resolved.warnings:
|
|
151
|
+
_err.print(f"[yellow]warning:[/yellow] {warning}")
|
|
152
|
+
if verbose:
|
|
153
|
+
_err.print(f"[dim]profile: {resolved.profile_name}[/dim]")
|
|
154
|
+
return resolved
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _run(actions: Sequence[Action], config: ResolvedConfig, verbose: int = 0) -> NoReturn:
|
|
158
|
+
try:
|
|
159
|
+
session = Session.from_profile(config=config)
|
|
160
|
+
except UseComputerError as exc:
|
|
161
|
+
_fail(exc)
|
|
162
|
+
except Exception as exc: # a backend can fail to connect in its own vocabulary
|
|
163
|
+
_fail(exc)
|
|
164
|
+
try:
|
|
165
|
+
result = session.run(actions)
|
|
166
|
+
finally:
|
|
167
|
+
session.close()
|
|
168
|
+
|
|
169
|
+
_emit(as_json(result))
|
|
170
|
+
if not result.ok:
|
|
171
|
+
for item in result.results:
|
|
172
|
+
if item.error is not None:
|
|
173
|
+
_err.print(f"[red]{item.error.type}:[/red] {item.error.message}")
|
|
174
|
+
raise typer.Exit(EXIT_FAILURE)
|
|
175
|
+
if verbose:
|
|
176
|
+
_err.print(f"[green]ok[/green] {len(result.results)} action(s)")
|
|
177
|
+
raise typer.Exit(EXIT_OK)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# --- action commands ---------------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@app.command()
|
|
184
|
+
def move(
|
|
185
|
+
x: Annotated[int, typer.Option("--x", help="X coordinate.")],
|
|
186
|
+
y: Annotated[int, typer.Option("--y", help="Y coordinate.")],
|
|
187
|
+
use: UseOption = None,
|
|
188
|
+
space: SpaceOption = None,
|
|
189
|
+
delay: DelayOption = None,
|
|
190
|
+
dry_run: DryRunOption = False,
|
|
191
|
+
verify: VerifyOption = False,
|
|
192
|
+
verbose: VerboseOption = 0,
|
|
193
|
+
) -> None:
|
|
194
|
+
"""Move the pointer."""
|
|
195
|
+
config = _config(use, space=space, delay=delay, dry_run=dry_run, verify=verify, verbose=verbose)
|
|
196
|
+
_run([MoveAction(x=x, y=y, space=space)], config, verbose)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@app.command()
|
|
200
|
+
def click(
|
|
201
|
+
x: XOption = None,
|
|
202
|
+
y: YOption = None,
|
|
203
|
+
button: Annotated[MouseButton, typer.Option("--button")] = MouseButton.LEFT,
|
|
204
|
+
use: UseOption = None,
|
|
205
|
+
space: SpaceOption = None,
|
|
206
|
+
delay: DelayOption = None,
|
|
207
|
+
dry_run: DryRunOption = False,
|
|
208
|
+
verify: VerifyOption = False,
|
|
209
|
+
verbose: VerboseOption = 0,
|
|
210
|
+
) -> None:
|
|
211
|
+
"""Click, at a coordinate or where the pointer already is."""
|
|
212
|
+
config = _config(use, space=space, delay=delay, dry_run=dry_run, verify=verify, verbose=verbose)
|
|
213
|
+
_run([ClickAction(x=x, y=y, space=space, button=button)], config, verbose)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@app.command("double-click")
|
|
217
|
+
def double_click(
|
|
218
|
+
x: XOption = None,
|
|
219
|
+
y: YOption = None,
|
|
220
|
+
use: UseOption = None,
|
|
221
|
+
space: SpaceOption = None,
|
|
222
|
+
delay: DelayOption = None,
|
|
223
|
+
dry_run: DryRunOption = False,
|
|
224
|
+
verify: VerifyOption = False,
|
|
225
|
+
verbose: VerboseOption = 0,
|
|
226
|
+
) -> None:
|
|
227
|
+
"""Double-click."""
|
|
228
|
+
config = _config(use, space=space, delay=delay, dry_run=dry_run, verify=verify, verbose=verbose)
|
|
229
|
+
_run([DoubleClickAction(x=x, y=y, space=space)], config, verbose)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@app.command("right-click")
|
|
233
|
+
def right_click(
|
|
234
|
+
x: XOption = None,
|
|
235
|
+
y: YOption = None,
|
|
236
|
+
use: UseOption = None,
|
|
237
|
+
space: SpaceOption = None,
|
|
238
|
+
delay: DelayOption = None,
|
|
239
|
+
dry_run: DryRunOption = False,
|
|
240
|
+
verify: VerifyOption = False,
|
|
241
|
+
verbose: VerboseOption = 0,
|
|
242
|
+
) -> None:
|
|
243
|
+
"""Click with the secondary button."""
|
|
244
|
+
config = _config(use, space=space, delay=delay, dry_run=dry_run, verify=verify, verbose=verbose)
|
|
245
|
+
_run([RightClickAction(x=x, y=y, space=space)], config, verbose)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
@app.command()
|
|
249
|
+
def drag(
|
|
250
|
+
from_x: Annotated[int, typer.Option("--from-x")],
|
|
251
|
+
from_y: Annotated[int, typer.Option("--from-y")],
|
|
252
|
+
to_x: Annotated[int, typer.Option("--to-x")],
|
|
253
|
+
to_y: Annotated[int, typer.Option("--to-y")],
|
|
254
|
+
button: Annotated[MouseButton, typer.Option("--button")] = MouseButton.LEFT,
|
|
255
|
+
use: UseOption = None,
|
|
256
|
+
space: SpaceOption = None,
|
|
257
|
+
delay: DelayOption = None,
|
|
258
|
+
dry_run: DryRunOption = False,
|
|
259
|
+
verify: VerifyOption = False,
|
|
260
|
+
verbose: VerboseOption = 0,
|
|
261
|
+
) -> None:
|
|
262
|
+
"""Press, move, release."""
|
|
263
|
+
config = _config(use, space=space, delay=delay, dry_run=dry_run, verify=verify, verbose=verbose)
|
|
264
|
+
action = DragAction(
|
|
265
|
+
from_x=from_x, from_y=from_y, to_x=to_x, to_y=to_y, space=space, button=button
|
|
266
|
+
)
|
|
267
|
+
_run([action], config, verbose)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
@app.command()
|
|
271
|
+
def scroll(
|
|
272
|
+
amount: Annotated[int, typer.Option("--amount")],
|
|
273
|
+
direction: Annotated[ScrollDirection, typer.Option("--direction")] = ScrollDirection.DOWN,
|
|
274
|
+
x: XOption = None,
|
|
275
|
+
y: YOption = None,
|
|
276
|
+
use: UseOption = None,
|
|
277
|
+
space: SpaceOption = None,
|
|
278
|
+
delay: DelayOption = None,
|
|
279
|
+
dry_run: DryRunOption = False,
|
|
280
|
+
verify: VerifyOption = False,
|
|
281
|
+
verbose: VerboseOption = 0,
|
|
282
|
+
) -> None:
|
|
283
|
+
"""Scroll."""
|
|
284
|
+
config = _config(use, space=space, delay=delay, dry_run=dry_run, verify=verify, verbose=verbose)
|
|
285
|
+
_run([ScrollAction(amount=amount, direction=direction, x=x, y=y, space=space)], config, verbose)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
@app.command("type")
|
|
289
|
+
def type_text(
|
|
290
|
+
text: Annotated[str, typer.Option("--text")],
|
|
291
|
+
rate: Annotated[
|
|
292
|
+
float | None, typer.Option("--rate", help="Seconds between keystrokes.")
|
|
293
|
+
] = None,
|
|
294
|
+
use: UseOption = None,
|
|
295
|
+
delay: DelayOption = None,
|
|
296
|
+
dry_run: DryRunOption = False,
|
|
297
|
+
verify: VerifyOption = False,
|
|
298
|
+
verbose: VerboseOption = 0,
|
|
299
|
+
) -> None:
|
|
300
|
+
"""Type literal text. For shortcuts use `key`."""
|
|
301
|
+
config = _config(use, delay=delay, dry_run=dry_run, verify=verify, verbose=verbose)
|
|
302
|
+
_run([TypeAction(text=text, rate=rate)], config, verbose)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
@app.command()
|
|
306
|
+
def key(
|
|
307
|
+
combo: Annotated[str, typer.Argument(help="A key combination, e.g. ctrl+shift+t.")],
|
|
308
|
+
use: UseOption = None,
|
|
309
|
+
delay: DelayOption = None,
|
|
310
|
+
dry_run: DryRunOption = False,
|
|
311
|
+
verify: VerifyOption = False,
|
|
312
|
+
verbose: VerboseOption = 0,
|
|
313
|
+
) -> None:
|
|
314
|
+
"""Press a key combination."""
|
|
315
|
+
config = _config(use, delay=delay, dry_run=dry_run, verify=verify, verbose=verbose)
|
|
316
|
+
try:
|
|
317
|
+
action = KeyAction(combo=combo)
|
|
318
|
+
except Exception as exc:
|
|
319
|
+
_err.print(f"[red]error:[/red] {exc}")
|
|
320
|
+
raise typer.Exit(EXIT_USAGE) from exc
|
|
321
|
+
_run([action], config, verbose)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
@app.command()
|
|
325
|
+
def screenshot(
|
|
326
|
+
out: Annotated[Path | None, typer.Option("--out", help="Write the PNG here.")] = None,
|
|
327
|
+
base64: Annotated[bool, typer.Option("--base64", help="Include the PNG in the JSON.")] = False,
|
|
328
|
+
use: UseOption = None,
|
|
329
|
+
verbose: VerboseOption = 0,
|
|
330
|
+
) -> None:
|
|
331
|
+
"""Capture the current screen."""
|
|
332
|
+
config = _config(use, verbose=verbose)
|
|
333
|
+
_run([ScreenshotAction(out=out, base64=base64)], config, verbose)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
@app.command()
|
|
337
|
+
def batch(
|
|
338
|
+
source: Annotated[str, typer.Argument(metavar="PATH|-", help="JSON array of actions, or -.")],
|
|
339
|
+
continue_on_error: Annotated[
|
|
340
|
+
bool, typer.Option("--continue-on-error", help="Run the rest after a failure.")
|
|
341
|
+
] = False,
|
|
342
|
+
use: UseOption = None,
|
|
343
|
+
space: SpaceOption = None,
|
|
344
|
+
delay: DelayOption = None,
|
|
345
|
+
dry_run: DryRunOption = False,
|
|
346
|
+
verify: VerifyOption = False,
|
|
347
|
+
verbose: VerboseOption = 0,
|
|
348
|
+
) -> None:
|
|
349
|
+
"""Run a batch of actions over one connection."""
|
|
350
|
+
raw = sys.stdin.read() if source == "-" else _read_file(source)
|
|
351
|
+
try:
|
|
352
|
+
actions = ActionListAdapter.validate_json(raw)
|
|
353
|
+
except Exception as exc:
|
|
354
|
+
_err.print(f"[red]error:[/red] {source} is not a valid action list: {exc}")
|
|
355
|
+
raise typer.Exit(EXIT_USAGE) from exc
|
|
356
|
+
config = _config(
|
|
357
|
+
use,
|
|
358
|
+
space=space,
|
|
359
|
+
delay=delay,
|
|
360
|
+
dry_run=dry_run,
|
|
361
|
+
verify=verify,
|
|
362
|
+
continue_on_error=continue_on_error,
|
|
363
|
+
verbose=verbose,
|
|
364
|
+
)
|
|
365
|
+
_run(actions, config, verbose)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _read_file(source: str) -> str:
|
|
369
|
+
path = Path(source)
|
|
370
|
+
try:
|
|
371
|
+
return path.read_text(encoding="utf-8")
|
|
372
|
+
except OSError as exc:
|
|
373
|
+
_err.print(f"[red]error:[/red] cannot read {source}: {exc}")
|
|
374
|
+
raise typer.Exit(EXIT_USAGE) from exc
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
# --- config ------------------------------------------------------------------------------------
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
@config_app.command("init")
|
|
381
|
+
def config_init(
|
|
382
|
+
backend: Annotated[
|
|
383
|
+
BackendKind | None,
|
|
384
|
+
typer.Option("--backend", help="Backend to configure. Given, nothing is asked."),
|
|
385
|
+
] = None,
|
|
386
|
+
profile: Annotated[
|
|
387
|
+
str | None, typer.Option("--profile", help="Profile name. Defaults to the backend name.")
|
|
388
|
+
] = None,
|
|
389
|
+
host: Annotated[str | None, typer.Option("--host", help="VNC host.")] = None,
|
|
390
|
+
port: Annotated[int, typer.Option("--port", help="VNC port.")] = 5900,
|
|
391
|
+
allow_local: Annotated[
|
|
392
|
+
bool, typer.Option("--allow-local", help="Opt in to driving this machine.")
|
|
393
|
+
] = False,
|
|
394
|
+
dir: DirOption = None,
|
|
395
|
+
no_probe: Annotated[
|
|
396
|
+
bool, typer.Option("--no-probe", help="Skip opening the backend afterwards.")
|
|
397
|
+
] = False,
|
|
398
|
+
force: Annotated[bool, typer.Option("--force", help="Replace an existing config.")] = False,
|
|
399
|
+
) -> None:
|
|
400
|
+
"""Write a config, then prove it works."""
|
|
401
|
+
root = dir or Path.cwd()
|
|
402
|
+
interactive = backend is None and _stdin_is_a_tty()
|
|
403
|
+
|
|
404
|
+
if interactive:
|
|
405
|
+
kind, profile_name, host, port, allow_local, password = _ask(profile, port)
|
|
406
|
+
else:
|
|
407
|
+
if backend is None:
|
|
408
|
+
_err.print(
|
|
409
|
+
"[red]error:[/red] no backend given and stdin is not a terminal. Pass "
|
|
410
|
+
"--backend local|vnc (and --host for vnc, or --allow-local for local)."
|
|
411
|
+
)
|
|
412
|
+
raise typer.Exit(EXIT_USAGE)
|
|
413
|
+
kind = backend
|
|
414
|
+
profile_name = profile or kind.value
|
|
415
|
+
password = None
|
|
416
|
+
if kind is BackendKind.VNC and not host:
|
|
417
|
+
_err.print("[red]error:[/red] --backend vnc needs --host.")
|
|
418
|
+
raise typer.Exit(EXIT_USAGE)
|
|
419
|
+
if kind is BackendKind.LOCAL and not allow_local:
|
|
420
|
+
_err.print(
|
|
421
|
+
"[red]error:[/red] the local backend moves this machine's pointer and types on "
|
|
422
|
+
"its keyboard. Pass --allow-local to opt in."
|
|
423
|
+
)
|
|
424
|
+
raise typer.Exit(EXIT_USAGE)
|
|
425
|
+
|
|
426
|
+
try:
|
|
427
|
+
config_path, env_path = write_initial_config(
|
|
428
|
+
root,
|
|
429
|
+
profile_name,
|
|
430
|
+
kind.value,
|
|
431
|
+
host=host,
|
|
432
|
+
port=port,
|
|
433
|
+
allow_local=allow_local,
|
|
434
|
+
password=password,
|
|
435
|
+
force=force,
|
|
436
|
+
)
|
|
437
|
+
except UseComputerError as exc:
|
|
438
|
+
_fail(exc)
|
|
439
|
+
|
|
440
|
+
payload: dict[str, Any] = {
|
|
441
|
+
"action": "init",
|
|
442
|
+
"config-file": str(config_path),
|
|
443
|
+
"env-file": str(env_path) if env_path else None,
|
|
444
|
+
"profile": profile_name,
|
|
445
|
+
"backend": kind.value,
|
|
446
|
+
"probe": None,
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
if no_probe:
|
|
450
|
+
_emit(payload)
|
|
451
|
+
_report_next_steps(kind, profile_name, env_path is None)
|
|
452
|
+
raise typer.Exit(EXIT_OK)
|
|
453
|
+
|
|
454
|
+
probe = _probe(root, profile_name)
|
|
455
|
+
payload["probe"] = probe
|
|
456
|
+
_emit(payload)
|
|
457
|
+
if not probe["ok"]:
|
|
458
|
+
_err.print(f"[red]probe failed:[/red] {probe['error']}")
|
|
459
|
+
_err.print(f"[dim]{config_path} was written; correct it and try again.[/dim]")
|
|
460
|
+
raise typer.Exit(EXIT_FAILURE)
|
|
461
|
+
_report_next_steps(kind, profile_name, env_path is None)
|
|
462
|
+
raise typer.Exit(EXIT_OK)
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _ask(
|
|
466
|
+
profile: str | None, port: int
|
|
467
|
+
) -> tuple[BackendKind, str, str | None, int, bool, str | None]:
|
|
468
|
+
"""The guided half. Every question goes to stderr; stdout stays JSON."""
|
|
469
|
+
_err.print("[bold]use-computer setup[/bold]")
|
|
470
|
+
kind = _prompt_backend()
|
|
471
|
+
host: str | None = None
|
|
472
|
+
password: str | None = None
|
|
473
|
+
allow_local = False
|
|
474
|
+
|
|
475
|
+
if kind is BackendKind.VNC:
|
|
476
|
+
host = typer.prompt("VNC host", err=True)
|
|
477
|
+
port = int(typer.prompt("VNC port", default=port, err=True))
|
|
478
|
+
password = (
|
|
479
|
+
typer.prompt(
|
|
480
|
+
"VNC password (leave empty for none; it is written to .use-computer/.env)",
|
|
481
|
+
default="",
|
|
482
|
+
hide_input=True,
|
|
483
|
+
show_default=False,
|
|
484
|
+
err=True,
|
|
485
|
+
)
|
|
486
|
+
or None
|
|
487
|
+
)
|
|
488
|
+
else:
|
|
489
|
+
# Asked out loud. An opt-in nobody was asked for is not an opt-in.
|
|
490
|
+
_err.print(
|
|
491
|
+
"[yellow]The local backend moves this machine's pointer and types on its "
|
|
492
|
+
"keyboard.[/yellow]"
|
|
493
|
+
)
|
|
494
|
+
allow_local = typer.confirm("Enable it?", default=False, err=True)
|
|
495
|
+
if not allow_local:
|
|
496
|
+
_err.print("[dim]Aborted: a local profile without the opt-in cannot run.[/dim]")
|
|
497
|
+
raise typer.Exit(EXIT_OK)
|
|
498
|
+
|
|
499
|
+
name = profile or typer.prompt("Profile name", default=kind.value, err=True)
|
|
500
|
+
return kind, name, host, port, allow_local, password
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _stdin_is_a_tty() -> bool:
|
|
504
|
+
"""Whether there is a human to ask. Indirect so it can be exercised in tests."""
|
|
505
|
+
return sys.stdin.isatty()
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _prompt_backend() -> BackendKind:
|
|
509
|
+
"""Ask until the answer is one of the backends.
|
|
510
|
+
|
|
511
|
+
Validated here rather than with click's Choice: click is typer's dependency, not ours, and
|
|
512
|
+
importing someone else's transitive dependency is how it breaks when they drop it.
|
|
513
|
+
"""
|
|
514
|
+
choices = [kind.value for kind in BackendKind]
|
|
515
|
+
while True:
|
|
516
|
+
answer = typer.prompt(
|
|
517
|
+
"Backend: local drives this machine, vnc drives a remote framebuffer",
|
|
518
|
+
default=BackendKind.LOCAL.value,
|
|
519
|
+
err=True,
|
|
520
|
+
).strip().lower()
|
|
521
|
+
if answer in choices:
|
|
522
|
+
return BackendKind(answer)
|
|
523
|
+
_err.print(f"[red]{answer!r} is not a backend.[/red] Choose one of: {', '.join(choices)}")
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _probe(root: Path, profile: str) -> dict[str, Any]:
|
|
527
|
+
"""Open the backend just configured and report what it sees.
|
|
528
|
+
|
|
529
|
+
A scale that cannot be derived is the most expensive failure this tool has, so setup is
|
|
530
|
+
where it should surface -- not the first click.
|
|
531
|
+
"""
|
|
532
|
+
try:
|
|
533
|
+
resolved = load_config(profile=profile, start=root)
|
|
534
|
+
session = Session.from_profile(config=resolved)
|
|
535
|
+
except Exception as exc:
|
|
536
|
+
return {"ok": False, "screen": None, "error": f"{type(exc).__name__}: {exc}"}
|
|
537
|
+
try:
|
|
538
|
+
screen = session.screen
|
|
539
|
+
except Exception as exc:
|
|
540
|
+
return {"ok": False, "screen": None, "error": f"{type(exc).__name__}: {exc}"}
|
|
541
|
+
finally:
|
|
542
|
+
session.close()
|
|
543
|
+
if screen.scale is None:
|
|
544
|
+
return {
|
|
545
|
+
"ok": False,
|
|
546
|
+
"screen": screen.model_dump(mode="json"),
|
|
547
|
+
"error": (
|
|
548
|
+
f"the backend reports {screen.width}x{screen.height} actuation units and "
|
|
549
|
+
f"{screen.screenshot_width}x{screen.screenshot_height} screenshot pixels, from "
|
|
550
|
+
"which no consistent scale can be derived. Set `scale` explicitly in the profile."
|
|
551
|
+
),
|
|
552
|
+
}
|
|
553
|
+
return {"ok": True, "screen": screen.model_dump(mode="json"), "error": None}
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def _report_next_steps(kind: BackendKind, profile: str, needs_password: bool) -> None:
|
|
557
|
+
if kind is BackendKind.VNC and needs_password:
|
|
558
|
+
_err.print(
|
|
559
|
+
f"[dim]If the server needs a password, set "
|
|
560
|
+
f"{profile_env_var(profile, 'password')} or put it in .use-computer/.env[/dim]"
|
|
561
|
+
)
|
|
562
|
+
_err.print(f"[green]ready[/green] try: use-computer screenshot --use {profile}")
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
@config_app.command("show")
|
|
566
|
+
def config_show(use: UseOption = None) -> None:
|
|
567
|
+
"""Print every resolved value, the layer it came from, and the variable that overrides it."""
|
|
568
|
+
try:
|
|
569
|
+
resolved = _config(use)
|
|
570
|
+
except UseComputerError as exc:
|
|
571
|
+
_fail(exc)
|
|
572
|
+
_emit(resolved.show())
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
# --- skill -------------------------------------------------------------------------------------
|
|
576
|
+
|
|
577
|
+
ScopeOption = Annotated[Scope, typer.Option("--scope", help="Where to install the skill.")]
|
|
578
|
+
DirOption = Annotated[
|
|
579
|
+
Path | None, typer.Option("--dir", help="Skills directory, overriding the scope.")
|
|
580
|
+
]
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
@skill_app.command("install")
|
|
584
|
+
def skill_install_command(
|
|
585
|
+
scope: ScopeOption = Scope.PROJECT,
|
|
586
|
+
dir: DirOption = None,
|
|
587
|
+
force: Annotated[bool, typer.Option("--force", help="Overwrite an existing copy.")] = False,
|
|
588
|
+
) -> None:
|
|
589
|
+
"""Install the bundled skill."""
|
|
590
|
+
try:
|
|
591
|
+
state = skill_install(scope, override=dir, force=force)
|
|
592
|
+
except UseComputerError as exc:
|
|
593
|
+
_fail(exc)
|
|
594
|
+
_emit({"action": "install", **_skill_json(state)})
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
@skill_app.command("update")
|
|
598
|
+
def skill_update_command(scope: ScopeOption = Scope.PROJECT, dir: DirOption = None) -> None:
|
|
599
|
+
"""Refresh an installed skill from the bundled copy."""
|
|
600
|
+
try:
|
|
601
|
+
state = skill_update(scope, override=dir)
|
|
602
|
+
except UseComputerError as exc:
|
|
603
|
+
_fail(exc)
|
|
604
|
+
_emit({"action": "update", **_skill_json(state)})
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
@skill_app.command("remove")
|
|
608
|
+
def skill_remove_command(scope: ScopeOption = Scope.PROJECT, dir: DirOption = None) -> None:
|
|
609
|
+
"""Remove an installed skill."""
|
|
610
|
+
try:
|
|
611
|
+
state = skill_remove(scope, override=dir)
|
|
612
|
+
except UseComputerError as exc:
|
|
613
|
+
_fail(exc)
|
|
614
|
+
_emit({"action": "remove", **_skill_json(state)})
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
@skill_app.command("status")
|
|
618
|
+
def skill_status_command(scope: ScopeOption = Scope.PROJECT, dir: DirOption = None) -> None:
|
|
619
|
+
"""Report whether the skill is installed and current."""
|
|
620
|
+
state = skill_status(scope, override=dir)
|
|
621
|
+
_emit({"action": "status", **_skill_json(state)})
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def _skill_json(state: Any) -> dict[str, Any]:
|
|
625
|
+
return {
|
|
626
|
+
"scope": state.scope.value,
|
|
627
|
+
"path": str(state.path),
|
|
628
|
+
"status": state.status,
|
|
629
|
+
"installed": state.installed,
|
|
630
|
+
"up-to-date": state.up_to_date,
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
# --- entry point -------------------------------------------------------------------------------
|
|
635
|
+
|
|
636
|
+
#: Every name argv may start with. Anything else is an argument to the default command.
|
|
637
|
+
_COMMANDS = frozenset(
|
|
638
|
+
{
|
|
639
|
+
"move",
|
|
640
|
+
"click",
|
|
641
|
+
"double-click",
|
|
642
|
+
"right-click",
|
|
643
|
+
"drag",
|
|
644
|
+
"scroll",
|
|
645
|
+
"type",
|
|
646
|
+
"key",
|
|
647
|
+
"screenshot",
|
|
648
|
+
"batch",
|
|
649
|
+
"config",
|
|
650
|
+
"skill",
|
|
651
|
+
}
|
|
652
|
+
)
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def apply_default_command(argv: list[str]) -> list[str]:
|
|
656
|
+
"""Insert the default command when argv starts with something that is not a command.
|
|
657
|
+
|
|
658
|
+
This is why the entry point exists: it is the supported way to have a default command in
|
|
659
|
+
typer, and it keeps working across the versions that changed how typer builds its group.
|
|
660
|
+
"""
|
|
661
|
+
if len(argv) < 2:
|
|
662
|
+
return argv
|
|
663
|
+
first = argv[1]
|
|
664
|
+
if first in _COMMANDS or first.startswith("-") and first != "-":
|
|
665
|
+
return argv
|
|
666
|
+
return [argv[0], DEFAULT_COMMAND, *argv[1:]]
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def main() -> None:
|
|
670
|
+
"""Console-script entry point."""
|
|
671
|
+
sys.argv = apply_default_command(sys.argv)
|
|
672
|
+
app()
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
if __name__ == "__main__": # pragma: no cover
|
|
676
|
+
main()
|