krita-cli 1.0.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.
krita_client/client.py ADDED
@@ -0,0 +1,690 @@
1
+ """Typed HTTP client for communicating with the Krita plugin."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ import httpx
8
+
9
+ from krita_client.config import ClientConfig
10
+ from krita_client.models import (
11
+ COMMAND_MODELS,
12
+ BatchParams,
13
+ ClearParams,
14
+ CreateLayerParams,
15
+ DeleteLayerParams,
16
+ DrawShapeParams,
17
+ ErrorCode,
18
+ FillParams,
19
+ GetCanvasParams,
20
+ GetColorAtParams,
21
+ ListBrushesParams,
22
+ ListLayersParams,
23
+ NewCanvasParams,
24
+ OpenFileParams,
25
+ RenameLayerParams,
26
+ RollbackParams,
27
+ SaveParams,
28
+ SelectLayerParams,
29
+ SetBrushParams,
30
+ SetColorParams,
31
+ SetLayerOpacityParams,
32
+ SetLayerVisibilityParams,
33
+ StrokeParams,
34
+ )
35
+
36
+ if TYPE_CHECKING:
37
+ from typing import Self
38
+
39
+ from pydantic import BaseModel
40
+
41
+ MIN_PROTOCOL_VERSION = "1.0.0"
42
+ MAX_PROTOCOL_VERSION = "1.0.0"
43
+ _SUPPORTED_PROTOCOL_VERSION = 1
44
+ _CANNOT_CONNECT_MSG = "Cannot connect to Krita. Is Krita running with the MCP plugin enabled?"
45
+
46
+
47
+ class KritaError(Exception):
48
+ """Base exception for Krita client errors."""
49
+
50
+ def __init__(self, message: str, code: ErrorCode | None = None, recoverable: bool = False) -> None:
51
+ super().__init__(message)
52
+ self.message = message
53
+ self.code = code
54
+ self.recoverable = recoverable
55
+
56
+
57
+ class KritaConnectionError(KritaError):
58
+ """Raised when the Krita plugin is unreachable."""
59
+
60
+
61
+ class KritaCommandError(KritaError):
62
+ """Raised when a command execution fails."""
63
+
64
+
65
+ class KritaValidationError(KritaError):
66
+ """Raised when command parameters fail validation."""
67
+
68
+
69
+ class KritaClient:
70
+ """Typed HTTP client for the Krita MCP plugin.
71
+
72
+ All public methods correspond to a Krita plugin action.
73
+ Parameters are validated via pydantic models before being sent.
74
+ """
75
+
76
+ def __init__(self, config: ClientConfig | None = None) -> None:
77
+ self._config = config or ClientConfig()
78
+ self._client = httpx.Client(
79
+ base_url=self._config.url,
80
+ timeout=httpx.Timeout(
81
+ connect=5.0,
82
+ read=self._config.default_timeout,
83
+ write=self._config.default_timeout,
84
+ pool=5.0,
85
+ ),
86
+ )
87
+
88
+ @property
89
+ def config(self) -> ClientConfig:
90
+ return self._config
91
+
92
+ def close(self) -> None:
93
+ self._client.close()
94
+
95
+ def __enter__(self) -> Self:
96
+ return self
97
+
98
+ def __exit__(self, *args: object) -> None:
99
+ self.close()
100
+
101
+ # -- Internal helpers -----------------------------------------------------
102
+
103
+ def _send(
104
+ self,
105
+ action: str,
106
+ params: dict[str, object] | None = None,
107
+ timeout: float | None = None,
108
+ ) -> dict[str, object]:
109
+ """Send a command to the Krita plugin and return the JSON response."""
110
+ body: dict[str, object] = {"action": action, "params": params or {}}
111
+ request_timeout = timeout or self._config.default_timeout
112
+
113
+ try:
114
+ response = self._client.post(
115
+ "/",
116
+ json=body,
117
+ timeout=httpx.Timeout(
118
+ connect=5.0,
119
+ read=request_timeout,
120
+ write=request_timeout,
121
+ pool=5.0,
122
+ ),
123
+ )
124
+ data = response.json()
125
+ if "error" in data:
126
+ err = data["error"]
127
+ if isinstance(err, dict):
128
+ msg = err.get("message", "Unknown error")
129
+ code = err.get("code")
130
+ recov = err.get("recoverable", False)
131
+ raise KritaCommandError(msg, code=code, recoverable=recov)
132
+ raise KritaCommandError(str(err))
133
+ return data # type: ignore[no-any-return]
134
+ except httpx.ConnectError as exc:
135
+ raise KritaConnectionError(
136
+ _CANNOT_CONNECT_MSG, code=ErrorCode.PLUGIN_UNREACHABLE, recoverable=True
137
+ ) from exc
138
+ except httpx.HTTPStatusError as exc:
139
+ msg = f"HTTP {exc.response.status_code}: {exc.response.text}"
140
+ raise KritaCommandError(msg, code=ErrorCode.INTERNAL_ERROR) from exc
141
+ except httpx.HTTPError as exc:
142
+ raise KritaCommandError(str(exc), code=ErrorCode.COMMAND_TIMEOUT, recoverable=True) from exc
143
+
144
+ def _is_compatible(self, protocol_version: str) -> bool:
145
+ """Check if a protocol version string is within the supported range."""
146
+ try:
147
+ parts = protocol_version.split(".")
148
+ if len(parts) != 3:
149
+ return False
150
+ major, minor, patch = int(parts[0]), int(parts[1]), int(parts[2])
151
+ min_parts = MIN_PROTOCOL_VERSION.split(".")
152
+ max_parts = MAX_PROTOCOL_VERSION.split(".")
153
+ min_tuple = (int(min_parts[0]), int(min_parts[1]), int(min_parts[2]))
154
+ max_tuple = (int(max_parts[0]), int(max_parts[1]), int(max_parts[2]))
155
+ return min_tuple <= (major, minor, patch) <= max_tuple
156
+ except (ValueError, IndexError):
157
+ return False
158
+
159
+ def _health_get(self) -> dict[str, object]:
160
+ """Send a GET request to the health endpoint."""
161
+ try:
162
+ response = self._client.get(
163
+ "/health",
164
+ timeout=httpx.Timeout(
165
+ connect=5.0,
166
+ read=self._config.health_timeout,
167
+ write=5.0,
168
+ pool=5.0,
169
+ ),
170
+ )
171
+ data = response.json() # type: ignore[no-any-return]
172
+ protocol_version = data.get("protocol_version")
173
+ if protocol_version is not None:
174
+ if isinstance(protocol_version, int) and protocol_version > _SUPPORTED_PROTOCOL_VERSION:
175
+ raise KritaConnectionError(
176
+ f"Plugin protocol mismatch. Client expects v{_SUPPORTED_PROTOCOL_VERSION} "
177
+ f"but plugin is running v{protocol_version}. Please upgrade krita-cli.",
178
+ code=ErrorCode.INCOMPATIBLE_PROTOCOL,
179
+ recoverable=True,
180
+ )
181
+ if isinstance(protocol_version, str) and not self._is_compatible(protocol_version):
182
+ raise KritaConnectionError(
183
+ f"Incompatible protocol version: {protocol_version}. "
184
+ f"Expected {MIN_PROTOCOL_VERSION}-{MAX_PROTOCOL_VERSION}",
185
+ code=ErrorCode.INCOMPATIBLE_PROTOCOL,
186
+ recoverable=True,
187
+ )
188
+ return data
189
+ except httpx.ConnectError as exc:
190
+ raise KritaConnectionError(
191
+ _CANNOT_CONNECT_MSG, code=ErrorCode.PLUGIN_UNREACHABLE, recoverable=True
192
+ ) from exc
193
+
194
+ def _validate(
195
+ self,
196
+ model_cls: type[BaseModel],
197
+ params: dict[str, object],
198
+ ) -> dict[str, object]:
199
+ """Validate params against a pydantic model and return the validated dict."""
200
+ try:
201
+ instance = model_cls.model_validate(params)
202
+ return instance.model_dump(exclude_none=True)
203
+ except Exception as exc:
204
+ raise KritaValidationError(str(exc)) from exc
205
+
206
+ # -- Public API -----------------------------------------------------------
207
+
208
+ def health(self) -> dict[str, object]:
209
+ """Check if Krita is running and the plugin is active."""
210
+ return self._health_get()
211
+
212
+ def new_canvas(
213
+ self,
214
+ *,
215
+ width: int = 800,
216
+ height: int = 600,
217
+ name: str = "New Canvas",
218
+ background: str = "#1a1a2e",
219
+ ) -> dict[str, object]:
220
+ """Create a new canvas in Krita."""
221
+ validated = self._validate(
222
+ NewCanvasParams,
223
+ {"width": width, "height": height, "name": name, "background": background},
224
+ )
225
+ return self._send("new_canvas", validated)
226
+
227
+ def set_color(self, *, color: str) -> dict[str, object]:
228
+ """Set the foreground paint color."""
229
+ validated = self._validate(SetColorParams, {"color": color})
230
+ return self._send("set_color", validated)
231
+
232
+ def set_brush(
233
+ self,
234
+ *,
235
+ preset: str | None = None,
236
+ size: int | None = None,
237
+ opacity: float | None = None,
238
+ ) -> dict[str, object]:
239
+ """Set brush preset and properties."""
240
+ validated = self._validate(
241
+ SetBrushParams,
242
+ {"preset": preset, "size": size, "opacity": opacity},
243
+ )
244
+ return self._send("set_brush", validated)
245
+
246
+ def stroke(
247
+ self,
248
+ points: list[list[int]],
249
+ *,
250
+ pressure: float = 1.0,
251
+ size: int | None = None,
252
+ hardness: float = 0.5,
253
+ opacity: float = 1.0,
254
+ ) -> dict[str, object]:
255
+ """Paint a stroke through a series of points."""
256
+ validated = self._validate(
257
+ StrokeParams,
258
+ {
259
+ "points": points,
260
+ "pressure": pressure,
261
+ "size": size,
262
+ "hardness": hardness,
263
+ "opacity": opacity,
264
+ },
265
+ )
266
+ return self._send("stroke", validated)
267
+
268
+ def fill(self, x: int, y: int, *, radius: int = 50) -> dict[str, object]:
269
+ """Fill a circular area at a point."""
270
+ validated = self._validate(FillParams, {"x": x, "y": y, "radius": radius})
271
+ return self._send("fill", validated)
272
+
273
+ def draw_shape(
274
+ self,
275
+ shape: str,
276
+ x: int,
277
+ y: int,
278
+ *,
279
+ width: int = 100,
280
+ height: int = 100,
281
+ fill: bool = True,
282
+ stroke: bool = False,
283
+ x2: int | None = None,
284
+ y2: int | None = None,
285
+ line_width: int = 2,
286
+ ) -> dict[str, object]:
287
+ """Draw a shape on the canvas."""
288
+ validated = self._validate(
289
+ DrawShapeParams,
290
+ {
291
+ "shape": shape,
292
+ "x": x,
293
+ "y": y,
294
+ "width": width,
295
+ "height": height,
296
+ "fill": fill,
297
+ "stroke": stroke,
298
+ "x2": x2,
299
+ "y2": y2,
300
+ "line_width": line_width,
301
+ },
302
+ )
303
+ return self._send("draw_shape", validated)
304
+
305
+ def get_canvas(self, *, filename: str = "canvas.png") -> dict[str, object]:
306
+ """Export current canvas to a PNG file."""
307
+ validated = self._validate(GetCanvasParams, {"filename": filename})
308
+ return self._send("get_canvas", validated, timeout=self._config.export_timeout)
309
+
310
+ def undo(self) -> dict[str, object]:
311
+ """Undo the last action."""
312
+ return self._send("undo", {})
313
+
314
+ def redo(self) -> dict[str, object]:
315
+ """Redo the last undone action."""
316
+ return self._send("redo", {})
317
+
318
+ def clear(self, *, color: str = "#1a1a2e") -> dict[str, object]:
319
+ """Clear the canvas to a solid color."""
320
+ validated = self._validate(ClearParams, {"color": color})
321
+ return self._send("clear", validated)
322
+
323
+ def save(self, path: str) -> dict[str, object]:
324
+ """Save the current canvas to a specific file path."""
325
+ validated = self._validate(SaveParams, {"path": path})
326
+ return self._send("save", validated, timeout=self._config.export_timeout)
327
+
328
+ def get_color_at(self, x: int, y: int) -> dict[str, object]:
329
+ """Sample the color at a specific pixel."""
330
+ validated = self._validate(GetColorAtParams, {"x": x, "y": y})
331
+ return self._send("get_color_at", validated)
332
+
333
+ def list_brushes(self, *, filter: str = "", limit: int = 20) -> dict[str, object]: # noqa: A002
334
+ """List available brush presets."""
335
+ validated = self._validate(ListBrushesParams, {"filter": filter, "limit": limit})
336
+ return self._send("list_brushes", validated)
337
+
338
+ def open_file(self, path: str) -> dict[str, object]:
339
+ """Open an existing file in Krita."""
340
+ validated = self._validate(OpenFileParams, {"path": path})
341
+ return self._send("open_file", validated)
342
+
343
+ def batch_execute(self, commands: list[dict[str, object]], *, stop_on_error: bool = False) -> dict[str, object]:
344
+ """Execute multiple commands in a single batch.
345
+
346
+ Args:
347
+ commands: List of command dicts, each with "action" and "params" keys.
348
+ stop_on_error: Stop executing remaining commands on first error.
349
+
350
+ Returns:
351
+ Dict with keys:
352
+ - "status": "ok" (all succeeded), "partial" (some failed), or "error" (all failed)
353
+ - "results": List of per-command results with "action", "status", and "result"/"error"
354
+ - "count": Number of commands executed
355
+ """
356
+ validated = self._validate(BatchParams, {"commands": commands, "stop_on_error": stop_on_error})
357
+ return self._send("batch", validated)
358
+
359
+ def batch(self, commands: list[dict[str, object]], *, stop_on_error: bool = False) -> dict[str, object]:
360
+ """Execute multiple commands in a single batch (alias for batch_execute)."""
361
+ return self.batch_execute(commands, stop_on_error=stop_on_error)
362
+
363
+ def get_command_history(self, *, limit: int = 20) -> dict[str, object]:
364
+ """Retrieve recent command execution history."""
365
+ return self._send("get_command_history", {"limit": limit})
366
+
367
+ def rollback(self, batch_id: str) -> dict[str, object]:
368
+ """Roll back a batch execution.
369
+
370
+ Args:
371
+ batch_id: The unique ID of the batch to roll back.
372
+
373
+ Returns:
374
+ Dict with keys:
375
+ - "status": "ok" or "error"
376
+ - "message": Optional error message
377
+ """
378
+ validated = self._validate(RollbackParams, {"batch_id": batch_id})
379
+ return self._send("rollback", validated)
380
+
381
+ def get_canvas_info(self) -> dict[str, object]:
382
+ """Get information about the current canvas."""
383
+ return self._send("get_canvas_info", {})
384
+
385
+ def get_current_color(self) -> dict[str, object]:
386
+ """Get the current foreground and background colors."""
387
+ return self._send("get_current_color", {})
388
+
389
+ def get_current_brush(self) -> dict[str, object]:
390
+ """Get the current brush preset and properties."""
391
+ return self._send("get_current_brush", {})
392
+
393
+ def select_rect(
394
+ self,
395
+ x: int,
396
+ y: int,
397
+ *,
398
+ width: int,
399
+ height: int,
400
+ ) -> dict[str, object]:
401
+ """Select a rectangular area on the canvas."""
402
+ from krita_client.models import SelectRectParams
403
+
404
+ validated = self._validate(
405
+ SelectRectParams,
406
+ {"x": x, "y": y, "width": width, "height": height},
407
+ )
408
+ return self._send("select_rect", validated)
409
+
410
+ def select_ellipse(
411
+ self,
412
+ cx: int,
413
+ cy: int,
414
+ *,
415
+ rx: int,
416
+ ry: int,
417
+ ) -> dict[str, object]:
418
+ """Select an elliptical area on the canvas."""
419
+ from krita_client.models import SelectEllipseParams
420
+
421
+ validated = self._validate(
422
+ SelectEllipseParams,
423
+ {"cx": cx, "cy": cy, "rx": rx, "ry": ry},
424
+ )
425
+ return self._send("select_ellipse", validated)
426
+
427
+ def select_polygon(
428
+ self,
429
+ points: list[list[int]],
430
+ ) -> dict[str, object]:
431
+ """Select a polygonal area on the canvas."""
432
+ from krita_client.models import SelectPolygonParams
433
+
434
+ validated = self._validate(SelectPolygonParams, {"points": points})
435
+ return self._send("select_polygon", validated)
436
+
437
+ def selection_info(self) -> dict[str, object]:
438
+ """Get information about the current selection."""
439
+ return self._send("selection_info", {})
440
+
441
+ def get_capabilities(self) -> dict[str, object]:
442
+ """Get detected API capabilities from the plugin."""
443
+ return self._send("get_capabilities", {})
444
+
445
+ def get_security_status(self) -> dict[str, object]:
446
+ """Get current security limits and usage from the plugin."""
447
+ return self._send("get_security_status", {})
448
+
449
+ def transform_selection(
450
+ self,
451
+ *,
452
+ dx: int = 0,
453
+ dy: int = 0,
454
+ angle: float = 0.0,
455
+ scale_x: float = 1.0,
456
+ scale_y: float = 1.0,
457
+ ) -> dict[str, object]:
458
+ """Transform the current selection (move, rotate, scale)."""
459
+ from krita_client.models import TransformSelectionParams
460
+
461
+ validated = self._validate(
462
+ TransformSelectionParams,
463
+ {"dx": dx, "dy": dy, "angle": angle, "scale_x": scale_x, "scale_y": scale_y},
464
+ )
465
+ return self._send("transform_selection", validated)
466
+
467
+ def grow_selection(self, pixels: int) -> dict[str, object]:
468
+ """Grow the current selection outward by N pixels."""
469
+ from krita_client.models import ModifySelectionParams
470
+
471
+ validated = self._validate(ModifySelectionParams, {"pixels": pixels})
472
+ return self._send("grow_selection", validated)
473
+
474
+ def shrink_selection(self, pixels: int) -> dict[str, object]:
475
+ """Shrink the current selection inward by N pixels."""
476
+ from krita_client.models import ModifySelectionParams
477
+
478
+ validated = self._validate(ModifySelectionParams, {"pixels": pixels})
479
+ return self._send("shrink_selection", validated)
480
+
481
+ def border_selection(self, pixels: int) -> dict[str, object]:
482
+ """Create a border selection of N pixels around the current selection."""
483
+ from krita_client.models import ModifySelectionParams
484
+
485
+ validated = self._validate(ModifySelectionParams, {"pixels": pixels})
486
+ return self._send("border_selection", validated)
487
+
488
+ def combine_selections(
489
+ self,
490
+ operation: str,
491
+ ) -> dict[str, object]:
492
+ """Combine selections using union, intersect, or subtract."""
493
+ from krita_client.models import CombineSelectionParams
494
+
495
+ validated = self._validate(CombineSelectionParams, {"operation": operation})
496
+ return self._send("combine_selections", validated)
497
+
498
+ def clear_selection(self) -> dict[str, object]:
499
+ """Clear the current selection."""
500
+ return self._send("clear_selection", {})
501
+
502
+ def invert_selection(self) -> dict[str, object]:
503
+ """Invert the current selection."""
504
+ return self._send("invert_selection", {})
505
+
506
+ def fill_selection(self) -> dict[str, object]:
507
+ """Fill the current selection with the foreground color."""
508
+ return self._send("fill_selection", {})
509
+
510
+ def deselect(self) -> dict[str, object]:
511
+ """Remove the current selection."""
512
+ return self._send("deselect", {})
513
+
514
+ def select_by_color(
515
+ self,
516
+ *,
517
+ x: int | None = None,
518
+ y: int | None = None,
519
+ tolerance: float = 0.1,
520
+ contiguous: bool = True,
521
+ ) -> dict[str, object]:
522
+ """Select pixels by color similarity.
523
+
524
+ Args:
525
+ x: X coordinate for point-based selection (magic wand). None for global.
526
+ y: Y coordinate for point-based selection (magic wand). None for global.
527
+ tolerance: Color tolerance (0.0-1.0).
528
+ contiguous: If True, only select contiguous region (magic wand).
529
+ If False, select all matching pixels globally.
530
+ """
531
+ from krita_client.models import SelectByColorParams
532
+
533
+ params: dict[str, object] = {"tolerance": tolerance, "contiguous": contiguous}
534
+ if x is not None and y is not None:
535
+ params["x"] = x
536
+ params["y"] = y
537
+ validated = self._validate(SelectByColorParams, params)
538
+ return self._send("select_by_color", validated)
539
+
540
+ def select_by_alpha(
541
+ self,
542
+ *,
543
+ min_alpha: int = 1,
544
+ max_alpha: int = 255,
545
+ ) -> dict[str, object]:
546
+ """Select pixels by alpha value range.
547
+
548
+ Args:
549
+ min_alpha: Minimum alpha value (0-255).
550
+ max_alpha: Maximum alpha value (0-255).
551
+ """
552
+ from krita_client.models import SelectByAlphaParams
553
+
554
+ validated = self._validate(SelectByAlphaParams, {"min_alpha": min_alpha, "max_alpha": max_alpha})
555
+ return self._send("select_by_alpha", validated)
556
+
557
+ def save_selection(
558
+ self,
559
+ path: str,
560
+ *,
561
+ format: str = "png", # noqa: A002
562
+ ) -> dict[str, object]:
563
+ """Save current selection to a file as a mask.
564
+
565
+ Args:
566
+ path: File path to save selection mask.
567
+ format: Image format (default: png).
568
+ """
569
+ from krita_client.models import SaveSelectionParams
570
+
571
+ validated = self._validate(SaveSelectionParams, {"path": path, "format": format})
572
+ return self._send("save_selection", validated)
573
+
574
+ def load_selection(
575
+ self,
576
+ path: str,
577
+ ) -> dict[str, object]:
578
+ """Load selection from a mask file.
579
+
580
+ Args:
581
+ path: File path to load selection mask from.
582
+ """
583
+ from krita_client.models import LoadSelectionParams
584
+
585
+ validated = self._validate(LoadSelectionParams, {"path": path})
586
+ return self._send("load_selection", validated)
587
+
588
+ def selection_stats(self) -> dict[str, object]:
589
+ """Get statistics about the current selection.
590
+
591
+ Returns pixel count, centroid, area percentage, etc.
592
+ """
593
+ return self._send("selection_stats", {})
594
+
595
+ def save_selection_channel(self, *, name: str) -> dict[str, object]:
596
+ """Save current selection as a named channel.
597
+
598
+ Args:
599
+ name: Name for the selection channel.
600
+ """
601
+ from krita_client.models import SelectionChannelParams
602
+
603
+ validated = self._validate(SelectionChannelParams, {"name": name})
604
+ return self._send("save_selection_channel", validated)
605
+
606
+ def load_selection_channel(self, *, name: str) -> dict[str, object]:
607
+ """Load a named selection channel.
608
+
609
+ Args:
610
+ name: Name of the selection channel to load.
611
+ """
612
+ from krita_client.models import SelectionChannelParams
613
+
614
+ validated = self._validate(SelectionChannelParams, {"name": name})
615
+ return self._send("load_selection_channel", validated)
616
+
617
+ def list_selection_channels(self) -> dict[str, object]:
618
+ """List all saved selection channels."""
619
+ return self._send("list_selection_channels", {})
620
+
621
+ def delete_selection_channel(self, *, name: str) -> dict[str, object]:
622
+ """Delete a saved selection channel.
623
+
624
+ Args:
625
+ name: Name of the selection channel to delete.
626
+ """
627
+ from krita_client.models import SelectionChannelParams
628
+
629
+ validated = self._validate(SelectionChannelParams, {"name": name})
630
+ return self._send("delete_selection_channel", validated)
631
+
632
+ # -- Layer management -----------------------------------------------------
633
+
634
+ def list_layers(self) -> dict[str, object]:
635
+ """List all layers in the current document."""
636
+ validated = self._validate(ListLayersParams, {})
637
+ return self._send("list_layers", validated)
638
+
639
+ def create_layer(
640
+ self,
641
+ *,
642
+ name: str = "New Layer",
643
+ layer_type: str = "paintlayer",
644
+ ) -> dict[str, object]:
645
+ """Create a new layer in the current document."""
646
+ validated = self._validate(CreateLayerParams, {"name": name, "layer_type": layer_type})
647
+ return self._send("create_layer", validated)
648
+
649
+ def select_layer(self, *, name: str) -> dict[str, object]:
650
+ """Select a layer by name."""
651
+ validated = self._validate(SelectLayerParams, {"name": name})
652
+ return self._send("select_layer", validated)
653
+
654
+ def delete_layer(self, *, name: str) -> dict[str, object]:
655
+ """Delete a layer by name."""
656
+ validated = self._validate(DeleteLayerParams, {"name": name})
657
+ return self._send("delete_layer", validated)
658
+
659
+ def rename_layer(self, *, old_name: str, new_name: str) -> dict[str, object]:
660
+ """Rename a layer."""
661
+ validated = self._validate(RenameLayerParams, {"old_name": old_name, "new_name": new_name})
662
+ return self._send("rename_layer", validated)
663
+
664
+ def set_layer_opacity(self, *, name: str, opacity: float) -> dict[str, object]:
665
+ """Set the opacity of a layer."""
666
+ validated = self._validate(SetLayerOpacityParams, {"name": name, "opacity": opacity})
667
+ return self._send("set_layer_opacity", validated)
668
+
669
+ def set_layer_visibility(self, *, name: str, visible: bool) -> dict[str, object]:
670
+ """Toggle the visibility of a layer."""
671
+ validated = self._validate(SetLayerVisibilityParams, {"name": name, "visible": visible})
672
+ return self._send("set_layer_visibility", validated)
673
+
674
+ # -- Generic command dispatch ---------------------------------------------
675
+
676
+ def send_command(
677
+ self,
678
+ action: str,
679
+ params: dict[str, object] | None = None,
680
+ timeout: float | None = None,
681
+ ) -> dict[str, object]:
682
+ """Send an arbitrary command to the Krita plugin.
683
+
684
+ If the action is known, params are validated against the corresponding
685
+ pydantic model. Unknown actions are sent as-is.
686
+ """
687
+ model_cls = COMMAND_MODELS.get(action)
688
+ if model_cls is not None and params is not None:
689
+ params = self._validate(model_cls, params)
690
+ return self._send(action, params, timeout=timeout)