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.
@@ -0,0 +1,121 @@
1
+ """Screenshots, and the before/after comparison that tells an agent whether anything happened.
2
+
3
+ A click that lands on nothing looks exactly like a click that worked. Comparing a screenshot
4
+ taken before and after an action gives the calling agent the feedback signal it needs to
5
+ correct a stale coordinate instead of retrying forever.
6
+
7
+ The question is "did something happen", not "which pixels differ", so comparison runs on
8
+ downscaled greyscale images. This module is pure: it takes images and returns a report.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import base64
14
+ import io
15
+ from datetime import datetime, timezone
16
+ from pathlib import Path
17
+
18
+ from PIL import Image, ImageChops
19
+ from pydantic import BaseModel, ConfigDict, Field
20
+
21
+ from use_computer.coordinates import CoordinateSpace
22
+
23
+ #: Fraction of differing pixels below which a change is noise -- a caret, a clock.
24
+ DEFAULT_THRESHOLD = 0.002
25
+
26
+ #: Longest edge the images are reduced to before comparison.
27
+ COMPARE_SIZE = 256
28
+
29
+ #: Per-pixel greyscale delta counted as a difference.
30
+ PIXEL_DELTA = 16
31
+
32
+
33
+ class Screenshot(BaseModel):
34
+ """A captured screen."""
35
+
36
+ model_config = ConfigDict(frozen=True)
37
+
38
+ path: Path | None = None
39
+ data: bytes | None = Field(default=None, repr=False)
40
+ width: int
41
+ height: int
42
+ space: CoordinateSpace = CoordinateSpace.SCREENSHOT
43
+ captured_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
44
+
45
+ def to_image(self) -> Image.Image:
46
+ """Load the screenshot as a PIL image, from memory or from disk."""
47
+ if self.data is not None:
48
+ return Image.open(io.BytesIO(self.data))
49
+ if self.path is not None:
50
+ return Image.open(self.path)
51
+ raise ValueError("screenshot has neither data nor path")
52
+
53
+ def base64(self) -> str:
54
+ """The PNG bytes, base64-encoded, for transport to the calling agent."""
55
+ if self.data is not None:
56
+ return base64.b64encode(self.data).decode("ascii")
57
+ if self.path is not None:
58
+ return base64.b64encode(self.path.read_bytes()).decode("ascii")
59
+ raise ValueError("screenshot has neither data nor path")
60
+
61
+
62
+ class ChangeReport(BaseModel):
63
+ """Whether the screen changed, by how much, and where."""
64
+
65
+ model_config = ConfigDict(frozen=True)
66
+
67
+ changed: bool
68
+ magnitude: float = Field(description="Fraction of pixels that differ, 0.0 to 1.0.")
69
+ threshold: float
70
+ bbox: tuple[int, int, int, int] | None = Field(
71
+ default=None, description="Bounding box of the change, in screenshot pixels."
72
+ )
73
+
74
+
75
+ def compare(
76
+ before: Screenshot, after: Screenshot, threshold: float = DEFAULT_THRESHOLD
77
+ ) -> ChangeReport:
78
+ """Compare two screenshots and report whether the screen actually changed.
79
+
80
+ The result is advisory: a false result does not fail the action, it tells the agent the
81
+ coordinate was probably stale.
82
+ """
83
+ if (before.width, before.height) != (after.width, after.height):
84
+ # A resolution change is unambiguously a change, and the images are not comparable.
85
+ return ChangeReport(changed=True, magnitude=1.0, threshold=threshold, bbox=None)
86
+
87
+ left = _prepare(before.to_image())
88
+ right = _prepare(after.to_image())
89
+
90
+ diff = ImageChops.difference(left, right)
91
+ mask = diff.point(lambda value: 255 if value >= PIXEL_DELTA else 0)
92
+ differing = mask.histogram()[255]
93
+ total = mask.width * mask.height
94
+ magnitude = differing / total if total else 0.0
95
+ changed = magnitude > threshold
96
+
97
+ bbox = None
98
+ if changed:
99
+ raw = mask.getbbox()
100
+ if raw is not None:
101
+ scale_x = after.width / mask.width
102
+ scale_y = after.height / mask.height
103
+ bbox = (
104
+ int(raw[0] * scale_x),
105
+ int(raw[1] * scale_y),
106
+ int(raw[2] * scale_x),
107
+ int(raw[3] * scale_y),
108
+ )
109
+ return ChangeReport(changed=changed, magnitude=magnitude, threshold=threshold, bbox=bbox)
110
+
111
+
112
+ def _prepare(image: Image.Image) -> Image.Image:
113
+ grey = image.convert("L")
114
+ longest = max(grey.width, grey.height)
115
+ if longest > COMPARE_SIZE:
116
+ ratio = COMPARE_SIZE / longest
117
+ grey = grey.resize(
118
+ (max(1, int(grey.width * ratio)), max(1, int(grey.height * ratio))),
119
+ Image.Resampling.BILINEAR,
120
+ )
121
+ return grey
use_computer/config.py ADDED
@@ -0,0 +1,532 @@
1
+ """Configuration: project root discovery, named profiles, and layered resolution.
2
+
3
+ Adding a backend target is editing a file, not writing code -- so the config file is the
4
+ feature, and `config show` is what makes a misconfiguration debuggable in one command. Every
5
+ resolved value carries the layer it came from, tracked as data during resolution rather than
6
+ reconstructed for display.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import re
13
+ import sys
14
+ from pathlib import Path
15
+ from typing import Any, Literal
16
+
17
+ from dotenv import dotenv_values
18
+ from pydantic import BaseModel, ConfigDict, Field
19
+ from pydantic_settings import BaseSettings, SettingsConfigDict
20
+
21
+ from use_computer.compare import DEFAULT_THRESHOLD
22
+ from use_computer.coordinates import CoordinateSpace
23
+ from use_computer.errors import ConfigError
24
+
25
+ if sys.version_info >= (3, 11):
26
+ import tomllib
27
+ else: # pragma: no cover - exercised on 3.10 only
28
+ import tomli as tomllib
29
+
30
+ #: The directory that marks a project root, found by walking up the way git finds its own.
31
+ PROJECT_DIR = ".use-computer"
32
+ CONFIG_FILENAME = "config.toml"
33
+ ENV_FILENAME = ".env"
34
+
35
+ #: Environment prefix. Declared by :class:`Settings` and reused by the resolver, so the two
36
+ #: cannot drift apart.
37
+ ENV_PREFIX = "USE_COMPUTER_"
38
+
39
+ #: Layers, highest precedence first. The tuple *is* the precedence rule.
40
+ LAYERS = ("cli", "env", "dotenv", "profile", "config", "global-config", "default")
41
+ Layer = Literal["cli", "env", "dotenv", "profile", "config", "global-config", "default"]
42
+
43
+ #: Fields whose value is masked wherever configuration is printed.
44
+ SECRET_FIELDS = frozenset({"password"})
45
+
46
+
47
+ class BackendProfile(BaseModel):
48
+ """A named backend target from the config file."""
49
+
50
+ model_config = ConfigDict(extra="ignore")
51
+
52
+ name: str
53
+ backend: Literal["local", "vnc"]
54
+ host: str | None = None
55
+ port: int = 5900
56
+ password: str | None = None
57
+ allow_local: bool = False
58
+ scale: float | None = Field(
59
+ default=None,
60
+ description="Explicit screenshot/actuation ratio. An explicit scale is trusted.",
61
+ )
62
+
63
+
64
+ class Settings(BaseSettings):
65
+ """The settings model. Declares the environment prefix the resolver scans with."""
66
+
67
+ model_config = SettingsConfigDict(env_prefix=ENV_PREFIX, extra="ignore")
68
+
69
+ default_profile: str | None = None
70
+ delay: float = 0.0
71
+ typing_rate: float = 0.02
72
+ verify: bool = False
73
+ verify_threshold: float = DEFAULT_THRESHOLD
74
+ space: CoordinateSpace = CoordinateSpace.SCREENSHOT
75
+ dry_run: bool = False
76
+ allow_local: bool = False
77
+ continue_on_error: bool = False
78
+
79
+
80
+ #: Scalar settings, usable at the top level of the config file and inside a profile.
81
+ SCALAR_FIELDS = tuple(Settings.model_fields)
82
+
83
+ #: Keys accepted at the top level of a config file.
84
+ TOP_LEVEL_KEYS = frozenset({*SCALAR_FIELDS, "profiles"})
85
+
86
+ #: Defaults for the fields a profile owns, which are not part of :class:`Settings`.
87
+ PROFILE_DEFAULTS: dict[str, Any] = {"port": 5900}
88
+
89
+ #: Keys accepted inside a profile. `default_profile` is meaningless there.
90
+ PROFILE_KEYS = frozenset(
91
+ {*SCALAR_FIELDS, "backend", "host", "port", "password", "scale"} - {"default_profile"}
92
+ )
93
+
94
+
95
+ class ResolvedValue(BaseModel):
96
+ """One setting, with the layer it came from."""
97
+
98
+ model_config = ConfigDict(frozen=True)
99
+
100
+ value: Any
101
+ layer: Layer
102
+ env: str = Field(description="The variable that would override this value.")
103
+ source: str | None = Field(default=None, description="File the value was read from.")
104
+ secret: bool = False
105
+
106
+ def display(self) -> Any:
107
+ return "***" if self.secret and self.value is not None else self.value
108
+
109
+
110
+ class ResolvedConfig(BaseModel):
111
+ """Everything a run needs, plus where each value came from."""
112
+
113
+ model_config = ConfigDict(frozen=True)
114
+
115
+ project_root: Path | None
116
+ config_file: Path | None
117
+ global_config_file: Path | None
118
+ profile_name: str | None
119
+ values: dict[str, ResolvedValue]
120
+ warnings: tuple[str, ...] = ()
121
+
122
+ def get(self, field: str) -> Any:
123
+ entry = self.values.get(field)
124
+ return entry.value if entry else None
125
+
126
+ @property
127
+ def settings(self) -> Settings:
128
+ """The scalar settings, validated."""
129
+ return Settings(**{f: self.get(f) for f in SCALAR_FIELDS if self.get(f) is not None})
130
+
131
+ @property
132
+ def profile(self) -> BackendProfile:
133
+ """The selected profile, with every layer applied on top of it."""
134
+ if self.profile_name is None:
135
+ raise ConfigError(
136
+ "no profile selected: pass --use <profile>, or set `default-profile` in "
137
+ f"{PROJECT_DIR}/{CONFIG_FILENAME}, or set {ENV_PREFIX}DEFAULT_PROFILE."
138
+ )
139
+ backend = self.get("backend")
140
+ if backend is None:
141
+ raise ConfigError(
142
+ f"profile {self.profile_name!r} does not exist or declares no `backend`. "
143
+ f"Define it in {PROJECT_DIR}/{CONFIG_FILENAME} under "
144
+ f"[profiles.{self.profile_name}]."
145
+ )
146
+ return BackendProfile(
147
+ name=self.profile_name,
148
+ backend=backend,
149
+ host=self.get("host"),
150
+ port=self.get("port") or PROFILE_DEFAULTS["port"],
151
+ password=self.get("password"),
152
+ allow_local=bool(self.get("allow_local")),
153
+ scale=self.get("scale"),
154
+ )
155
+
156
+ def show(self) -> dict[str, Any]:
157
+ """The `config show` payload: every value, its layer, its variable, secrets masked."""
158
+ return {
159
+ "project-root": str(self.project_root) if self.project_root else None,
160
+ "config-file": str(self.config_file) if self.config_file else None,
161
+ "global-config-file": str(self.global_config_file) if self.global_config_file else None,
162
+ "profile": self.profile_name,
163
+ "layers": list(LAYERS),
164
+ "values": {
165
+ name: {
166
+ "value": entry.display(),
167
+ "layer": entry.layer,
168
+ "env": entry.env,
169
+ "source": entry.source,
170
+ }
171
+ for name, entry in sorted(self.values.items())
172
+ },
173
+ }
174
+
175
+
176
+ # --- Locations ---------------------------------------------------------------------------------
177
+
178
+
179
+ def find_project_root(start: Path | None = None) -> Path | None:
180
+ """Walk up from ``start`` looking for a ``.use-computer`` directory, the way git does."""
181
+ current = (start or Path.cwd()).resolve()
182
+ for candidate in (current, *current.parents):
183
+ if (candidate / PROJECT_DIR).is_dir():
184
+ return candidate
185
+ return None
186
+
187
+
188
+ def xdg_config_dir() -> Path:
189
+ """The XDG config directory. Configuration is not disposable, so never a cache directory."""
190
+ base = os.environ.get("XDG_CONFIG_HOME")
191
+ return (Path(base) if base else Path.home() / ".config") / "use-computer"
192
+
193
+
194
+ def xdg_data_dir() -> Path:
195
+ """The XDG data directory, for anything stored."""
196
+ base = os.environ.get("XDG_DATA_HOME")
197
+ return (Path(base) if base else Path.home() / ".local" / "share") / "use-computer"
198
+
199
+
200
+ def env_var_for(field: str) -> str:
201
+ return ENV_PREFIX + field.upper()
202
+
203
+
204
+ # --- Reading -----------------------------------------------------------------------------------
205
+
206
+
207
+ def _normalise(mapping: dict[str, Any]) -> dict[str, Any]:
208
+ """Config files are written in kebab-case; fields are snake_case."""
209
+ return {key.replace("-", "_"): value for key, value in mapping.items()}
210
+
211
+
212
+ def _read_toml(path: Path) -> dict[str, Any]:
213
+ try:
214
+ with path.open("rb") as handle:
215
+ loaded: dict[str, Any] = tomllib.load(handle)
216
+ return loaded
217
+ except OSError as exc:
218
+ raise ConfigError(f"cannot read {path}: {exc}") from exc
219
+ except tomllib.TOMLDecodeError as exc:
220
+ raise ConfigError(f"{path} is not valid TOML: {exc}") from exc
221
+
222
+
223
+ def _check_unknown(raw: dict[str, Any], path: Path, warnings: list[str]) -> None:
224
+ """Warn about unknown keys -- including keys inside profiles that are not selected.
225
+
226
+ A typo in an unused profile is exactly the kind of thing discovered at the worst moment.
227
+ """
228
+ for key in raw:
229
+ if key.replace("-", "_") not in TOP_LEVEL_KEYS:
230
+ warnings.append(f"{path}: unknown key {key!r}")
231
+ profiles = raw.get("profiles") or {}
232
+ if not isinstance(profiles, dict):
233
+ warnings.append(f"{path}: `profiles` must be a table")
234
+ return
235
+ for profile_name, profile in profiles.items():
236
+ if not isinstance(profile, dict):
237
+ warnings.append(f"{path}: profile {profile_name!r} must be a table")
238
+ continue
239
+ for key in profile:
240
+ if key.replace("-", "_") not in PROFILE_KEYS:
241
+ warnings.append(f"{path}: unknown key {key!r} in profile {profile_name!r}")
242
+
243
+
244
+ def _env_layer(environ: dict[str, str]) -> tuple[dict[str, Any], dict[str, dict[str, Any]]]:
245
+ """Split ``USE_COMPUTER_*`` into scalar overrides and per-profile overrides.
246
+
247
+ ``USE_COMPUTER_PROFILES__STAGING__PASSWORD`` targets one profile; everything else is a
248
+ scalar override.
249
+ """
250
+ scalars: dict[str, Any] = {}
251
+ profiles: dict[str, dict[str, Any]] = {}
252
+ for raw_key, raw_value in environ.items():
253
+ if not raw_key.startswith(ENV_PREFIX):
254
+ continue
255
+ name = raw_key[len(ENV_PREFIX) :].lower()
256
+ if name.startswith("profiles__"):
257
+ parts = name.split("__")
258
+ if len(parts) == 3:
259
+ profiles.setdefault(parts[1], {})[parts[2]] = raw_value
260
+ continue
261
+ scalars[name] = raw_value
262
+ return scalars, profiles
263
+
264
+
265
+ # --- Resolution --------------------------------------------------------------------------------
266
+
267
+
268
+ def load(
269
+ cli: dict[str, Any] | None = None,
270
+ *,
271
+ profile: str | None = None,
272
+ start: Path | None = None,
273
+ environ: dict[str, str] | None = None,
274
+ ) -> ResolvedConfig:
275
+ """Resolve configuration across every layer.
276
+
277
+ Precedence, highest to lowest: CLI flags, environment variables, .env files, the selected
278
+ profile, top-level config keys, the global config, field defaults.
279
+ """
280
+ cli_values = {key: value for key, value in (cli or {}).items() if value is not None}
281
+ environ = dict(os.environ if environ is None else environ)
282
+ warnings: list[str] = []
283
+
284
+ root = find_project_root(start)
285
+ config_file = root / PROJECT_DIR / CONFIG_FILENAME if root else None
286
+ if config_file is not None and not config_file.is_file():
287
+ config_file = None
288
+ global_file: Path | None = xdg_config_dir() / CONFIG_FILENAME
289
+ if global_file is not None and not global_file.is_file():
290
+ global_file = None
291
+
292
+ project_raw = _read_toml(config_file) if config_file else {}
293
+ global_raw = _read_toml(global_file) if global_file else {}
294
+ if config_file:
295
+ _check_unknown(project_raw, config_file, warnings)
296
+ if global_file:
297
+ _check_unknown(global_raw, global_file, warnings)
298
+
299
+ dotenv_raw: dict[str, Any] = {}
300
+ dotenv_profiles: dict[str, dict[str, Any]] = {}
301
+ dotenv_file = root / PROJECT_DIR / ENV_FILENAME if root else None
302
+ if dotenv_file is not None and dotenv_file.is_file():
303
+ present = {k: v for k, v in dotenv_values(dotenv_file).items() if v is not None}
304
+ dotenv_raw, dotenv_profiles = _env_layer(present)
305
+ else:
306
+ dotenv_file = None
307
+
308
+ env_raw, env_profiles = _env_layer(environ)
309
+
310
+ # Pass one: the profile name itself, resolved without a profile layer.
311
+ selected = profile or cli_values.get("profile")
312
+ if selected is None:
313
+ for candidate in (
314
+ env_raw.get("default_profile"),
315
+ dotenv_raw.get("default_profile"),
316
+ _normalise(project_raw).get("default_profile"),
317
+ _normalise(global_raw).get("default_profile"),
318
+ ):
319
+ if candidate:
320
+ selected = str(candidate)
321
+ break
322
+
323
+ # Pass two: every field, over the full stack.
324
+ profile_raw: dict[str, Any] = {}
325
+ profile_source: Path | None = None
326
+ if selected:
327
+ for raw, source in ((project_raw, config_file), (global_raw, global_file)):
328
+ entry = (raw.get("profiles") or {}).get(selected)
329
+ if isinstance(entry, dict):
330
+ profile_raw = _normalise(entry)
331
+ profile_source = source
332
+ break
333
+ else:
334
+ warnings.append(f"profile {selected!r} is not defined in any config file")
335
+ for overrides in (dotenv_profiles.get(selected), env_profiles.get(selected)):
336
+ if overrides:
337
+ profile_raw = {**profile_raw, **overrides}
338
+
339
+ stack: list[tuple[Layer, dict[str, Any], Path | None]] = [
340
+ ("cli", {k: v for k, v in cli_values.items() if k != "profile"}, None),
341
+ ("env", env_raw, None),
342
+ ("dotenv", dotenv_raw, dotenv_file),
343
+ ("profile", profile_raw, profile_source),
344
+ (
345
+ "config",
346
+ _normalise({k: v for k, v in project_raw.items() if k != "profiles"}),
347
+ config_file,
348
+ ),
349
+ (
350
+ "global-config",
351
+ _normalise({k: v for k, v in global_raw.items() if k != "profiles"}),
352
+ global_file,
353
+ ),
354
+ ]
355
+
356
+ fields = (*SCALAR_FIELDS, "backend", "host", "port", "scale", "password")
357
+ defaults = Settings()
358
+ values: dict[str, ResolvedValue] = {}
359
+ for field in fields:
360
+ for layer, mapping, source in stack:
361
+ if field in mapping:
362
+ values[field] = ResolvedValue(
363
+ value=_coerce(field, mapping[field]),
364
+ layer=layer,
365
+ env=env_var_for(field),
366
+ source=str(source) if source else None,
367
+ secret=field in SECRET_FIELDS,
368
+ )
369
+ break
370
+ else:
371
+ values[field] = ResolvedValue(
372
+ value=getattr(defaults, field, PROFILE_DEFAULTS.get(field)),
373
+ layer="default",
374
+ env=env_var_for(field),
375
+ secret=field in SECRET_FIELDS,
376
+ )
377
+ if selected:
378
+ values["default_profile"] = values["default_profile"].model_copy(
379
+ update={"value": selected}
380
+ )
381
+
382
+ return ResolvedConfig(
383
+ project_root=root,
384
+ config_file=config_file,
385
+ global_config_file=global_file,
386
+ profile_name=selected,
387
+ values=values,
388
+ warnings=tuple(warnings),
389
+ )
390
+
391
+
392
+ # --- Writing ---------------------------------------------------------------------------------
393
+
394
+
395
+ def _toml_string(value: str) -> str:
396
+ """TOML basic string. Values here are hosts and profile names, but never assume."""
397
+ escaped = value.replace("\\", "\\\\").replace('"', '\\"')
398
+ return f'"{escaped}"'
399
+
400
+
401
+ _BARE_KEY = re.compile(r"^[A-Za-z0-9_-]+$")
402
+
403
+
404
+ def _toml_key(value: str) -> str:
405
+ """A TOML key. Bare when it can be, quoted when it must be.
406
+
407
+ The table header is a key, not a value: interpolating a name with a quote or a dot into
408
+ `[profiles.<name>]` produces a file the loader then refuses to read.
409
+ """
410
+ return value if _BARE_KEY.match(value) else _toml_string(value)
411
+
412
+
413
+ def render_config(
414
+ profile: str,
415
+ backend: str,
416
+ *,
417
+ host: str | None = None,
418
+ port: int = 5900,
419
+ allow_local: bool = False,
420
+ ) -> str:
421
+ """The config file a guided setup writes.
422
+
423
+ Commented, because the file is the thing the user edits next.
424
+ """
425
+ lines = [
426
+ "# Written by `use-computer config init`. Edit it freely.",
427
+ "#",
428
+ "# This file is discovered by walking up from the current directory, the way git finds",
429
+ "# its own, and is meant to be committed. Secrets belong in .use-computer/.env, which",
430
+ "# is not. `use-computer config show` prints every resolved value and where it came from.",
431
+ "",
432
+ f"default-profile = {_toml_string(profile)}",
433
+ "",
434
+ "# Seconds to wait after each action, so the application can react.",
435
+ "delay = 0.1",
436
+ "",
437
+ f"[profiles.{_toml_key(profile)}]",
438
+ f"backend = {_toml_string(backend)}",
439
+ ]
440
+ if backend == "vnc":
441
+ lines += [
442
+ f"host = {_toml_string(host or '')}",
443
+ f"port = {port}",
444
+ "# The password belongs in .use-computer/.env, not here:",
445
+ f"# {profile_env_var(profile, 'password')}=...",
446
+ ]
447
+ if backend == "local":
448
+ lines += [
449
+ "# The local backend moves THIS machine's pointer and types on THIS machine's",
450
+ "# keyboard. That is why it is off by default; this line is the explicit opt-in.",
451
+ f"allow-local = {str(bool(allow_local)).lower()}",
452
+ ]
453
+ return "\n".join(lines) + "\n"
454
+
455
+
456
+ def profile_env_var(profile: str, field: str) -> str:
457
+ """The variable that overrides one field of one profile."""
458
+ return f"{ENV_PREFIX}PROFILES__{profile.upper()}__{field.upper()}"
459
+
460
+
461
+ def write_initial_config(
462
+ root: Path,
463
+ profile: str,
464
+ backend: str,
465
+ *,
466
+ host: str | None = None,
467
+ port: int = 5900,
468
+ allow_local: bool = False,
469
+ password: str | None = None,
470
+ force: bool = False,
471
+ ) -> tuple[Path, Path | None]:
472
+ """Create ``.use-computer/config.toml`` under ``root``, and a ``.env`` if given a password.
473
+
474
+ Returns the config path and the .env path, the latter ``None`` when no password was given.
475
+
476
+ Raises:
477
+ ConfigError: when a config is already there and ``force`` was not given.
478
+ """
479
+ directory = root / PROJECT_DIR
480
+ config_path = directory / CONFIG_FILENAME
481
+ if config_path.exists() and not force:
482
+ raise ConfigError(
483
+ f"{config_path} already exists. Edit it, or pass --force to replace it. "
484
+ "`config init` creates a config; it does not merge into one."
485
+ )
486
+ directory.mkdir(parents=True, exist_ok=True)
487
+ config_path.write_text(
488
+ render_config(profile, backend, host=host, port=port, allow_local=allow_local),
489
+ encoding="utf-8",
490
+ )
491
+
492
+ env_path: Path | None = None
493
+ if password:
494
+ env_path = directory / ENV_FILENAME
495
+ line = f"{profile_env_var(profile, 'password')}={password}\n"
496
+ existing = env_path.read_text(encoding="utf-8") if env_path.exists() else ""
497
+ if existing and not existing.endswith("\n"):
498
+ existing += "\n"
499
+ env_path.write_text(existing + line, encoding="utf-8")
500
+ # The file holds a secret from the moment it is written.
501
+ env_path.chmod(0o600)
502
+ return config_path, env_path
503
+
504
+
505
+ _TRUE = {"1", "true", "yes", "on"}
506
+ _FALSE = {"0", "false", "no", "off"}
507
+ _BOOL_FIELDS = frozenset({"verify", "dry_run", "allow_local", "continue_on_error"})
508
+ _FLOAT_FIELDS = frozenset({"delay", "typing_rate", "verify_threshold", "scale"})
509
+
510
+
511
+ def _coerce(field: str, value: Any) -> Any:
512
+ """Environment variables and .env files arrive as strings; TOML arrives already typed."""
513
+ if not isinstance(value, str):
514
+ return value
515
+ lowered = value.strip().lower()
516
+ if field in _BOOL_FIELDS:
517
+ if lowered in _TRUE:
518
+ return True
519
+ if lowered in _FALSE:
520
+ return False
521
+ raise ConfigError(f"{env_var_for(field)}={value!r} is not a boolean")
522
+ if field in _FLOAT_FIELDS:
523
+ try:
524
+ return float(value)
525
+ except ValueError as exc:
526
+ raise ConfigError(f"{env_var_for(field)}={value!r} is not a number") from exc
527
+ if field == "port":
528
+ try:
529
+ return int(value)
530
+ except ValueError as exc:
531
+ raise ConfigError(f"{env_var_for(field)}={value!r} is not an integer") from exc
532
+ return value