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/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()