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.
@@ -0,0 +1,364 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Annotated, Any, cast
4
+
5
+ import typer
6
+ from rich.console import Console
7
+ from typer import Context
8
+
9
+ from krita_cli import _shared
10
+
11
+ app = typer.Typer()
12
+ console = Console()
13
+
14
+
15
+ @app.command("select-rect")
16
+ def select_rect(
17
+ ctx: Context,
18
+ x: int,
19
+ y: int,
20
+ width: int,
21
+ height: int,
22
+ ) -> None:
23
+ """Select a rectangular area."""
24
+ client = _shared._get_client(ctx)
25
+ with _shared._handle_errors():
26
+ result = client.select_rect(x=x, y=y, width=width, height=height)
27
+ _shared._print_result(result, f"Selected rectangle {width}x{height} at ({x}, {y})")
28
+
29
+
30
+ @app.command("select-ellipse")
31
+ def select_ellipse(
32
+ ctx: Context,
33
+ cx: int,
34
+ cy: int,
35
+ rx: int,
36
+ ry: int,
37
+ ) -> None:
38
+ """Select an elliptical area."""
39
+ client = _shared._get_client(ctx)
40
+ with _shared._handle_errors():
41
+ result = client.select_ellipse(cx=cx, cy=cy, rx=rx, ry=ry)
42
+ _shared._print_result(result, f"Selected ellipse at ({cx}, {cy}) with radii {rx}x{ry}")
43
+
44
+
45
+ @app.command("select-polygon")
46
+ def select_polygon(
47
+ ctx: Context,
48
+ points: list[str],
49
+ ) -> None:
50
+ """Select a polygonal area. Points as 'x,y' pairs (min 3)."""
51
+ parsed: list[list[int]] = []
52
+ for pt in points:
53
+ parts = pt.split(",")
54
+ if len(parts) != 2:
55
+ console.print(f"[red]Error:[/red] Invalid point format: {pt!r}. Use 'x,y'.")
56
+ raise typer.Exit(code=1)
57
+ try:
58
+ parsed.append([int(parts[0]), int(parts[1])])
59
+ except ValueError:
60
+ console.print(f"[red]Error:[/red] Invalid point coordinates: {pt!r}. Values must be integers.")
61
+ raise typer.Exit(code=1) from None
62
+
63
+ client = _shared._get_client(ctx)
64
+ with _shared._handle_errors():
65
+ result = client.select_polygon(points=parsed)
66
+ _shared._print_result(result, f"Selected polygon with {len(parsed)} points")
67
+
68
+
69
+ @app.command("select-area")
70
+ def select_area_compat(
71
+ ctx: Context,
72
+ x: int,
73
+ y: int,
74
+ width: int,
75
+ height: int,
76
+ ) -> None:
77
+ """Select a rectangular area.
78
+
79
+ Deprecated: prefer `krita select-rect` instead. This alias will be
80
+ removed in a future release.
81
+ """
82
+ select_rect(ctx, x, y, width, height)
83
+
84
+
85
+ @app.command("select-clear")
86
+ def clear_selection(ctx: Context) -> None:
87
+ """Clear the content of the current selection."""
88
+ client = _shared._get_client(ctx)
89
+ with _shared._handle_errors():
90
+ result = client.clear_selection()
91
+ _shared._print_result(result, "Cleared selection")
92
+
93
+
94
+ @app.command("select-invert")
95
+ def invert_selection(ctx: Context) -> None:
96
+ """Invert the current selection."""
97
+ client = _shared._get_client(ctx)
98
+ with _shared._handle_errors():
99
+ result = client.invert_selection()
100
+ _shared._print_result(result, "Inverted selection")
101
+
102
+
103
+ @app.command("select-fill")
104
+ def fill_selection(ctx: Context) -> None:
105
+ """Fill the current selection with foreground color."""
106
+ client = _shared._get_client(ctx)
107
+ with _shared._handle_errors():
108
+ result = client.fill_selection()
109
+ _shared._print_result(result, "Filled selection")
110
+
111
+
112
+ @app.command("select-info")
113
+ def selection_info(ctx: Context) -> None:
114
+ """Get information about the current selection."""
115
+ client = _shared._get_client(ctx)
116
+ with _shared._handle_errors():
117
+ result = client.selection_info()
118
+ if result.get("has_selection"):
119
+ bounds_raw = result.get("bounds", {})
120
+ if not isinstance(bounds_raw, dict):
121
+ bounds_raw = {}
122
+ bounds = cast("dict[str, Any]", bounds_raw)
123
+ console.print(
124
+ f"[green]Active selection:[/green] x={bounds.get('x')}, y={bounds.get('y')}, "
125
+ f"w={bounds.get('width')}, h={bounds.get('height')}"
126
+ )
127
+ else:
128
+ console.print("[dim]No active selection[/dim]")
129
+
130
+
131
+ @app.command("deselect")
132
+ def deselect(ctx: Context) -> None:
133
+ """Remove the current selection."""
134
+ client = _shared._get_client(ctx)
135
+ with _shared._handle_errors():
136
+ result = client.deselect()
137
+ _shared._print_result(result, "Deselected")
138
+
139
+
140
+ @app.command("select-by-color")
141
+ def select_by_color(
142
+ ctx: Context,
143
+ *,
144
+ x: Annotated[int | None, typer.Option("--x", "-x", help="X coordinate for magic wand (omit for global)")] = None,
145
+ y: Annotated[int | None, typer.Option("--y", "-y", help="Y coordinate for magic wand (omit for global)")] = None,
146
+ tolerance: Annotated[
147
+ float, typer.Option("--tolerance", "-t", help="Color tolerance (0.0-1.0)", min=0.0, max=1.0)
148
+ ] = 0.1,
149
+ contiguous: Annotated[
150
+ bool, typer.Option("--contiguous/--global", "-c/-g", help="Contiguous (magic wand) or global selection")
151
+ ] = True,
152
+ ) -> None:
153
+ """Select pixels by color similarity (magic wand or global)."""
154
+ client = _shared._get_client(ctx)
155
+ with _shared._handle_errors():
156
+ result = client.select_by_color(x=x, y=y, tolerance=tolerance, contiguous=contiguous)
157
+ method = "Magic wand" if contiguous else "Global"
158
+ count = result.get("selected_count", 0)
159
+ _shared._print_result(result, f"{method} color selection: {count} pixels (tolerance={tolerance})")
160
+
161
+
162
+ @app.command("select-by-alpha")
163
+ def select_by_alpha(
164
+ ctx: Context,
165
+ min_alpha: Annotated[int, typer.Option("--min", help="Minimum alpha value (0-255)", min=0, max=255)] = 1,
166
+ max_alpha: Annotated[int, typer.Option("--max", help="Maximum alpha value (0-255)", min=0, max=255)] = 255,
167
+ ) -> None:
168
+ """Select pixels by alpha value range."""
169
+ client = _shared._get_client(ctx)
170
+ with _shared._handle_errors():
171
+ result = client.select_by_alpha(min_alpha=min_alpha, max_alpha=max_alpha)
172
+ count = result.get("selected_count", 0)
173
+ _shared._print_result(result, f"Alpha selection: {count} pixels (alpha={min_alpha}-{max_alpha})")
174
+
175
+
176
+ @app.command("transform-selection")
177
+ def transform_selection(
178
+ ctx: Context,
179
+ dx: Annotated[int, typer.Option("--dx", help="Horizontal offset")] = 0,
180
+ dy: Annotated[int, typer.Option("--dy", help="Vertical offset")] = 0,
181
+ angle: Annotated[float, typer.Option("--angle", "-a", help="Rotation angle in degrees")] = 0.0,
182
+ scale_x: Annotated[float, typer.Option("--scale-x", help="Horizontal scale factor")] = 1.0,
183
+ scale_y: Annotated[float, typer.Option("--scale-y", help="Vertical scale factor")] = 1.0,
184
+ ) -> None:
185
+ """Transform the current selection (move, rotate, scale)."""
186
+ client = _shared._get_client(ctx)
187
+ with _shared._handle_errors():
188
+ result = client.transform_selection(dx=dx, dy=dy, angle=angle, scale_x=scale_x, scale_y=scale_y)
189
+ _shared._print_result(result, f"Transformed selection (dx={dx}, dy={dy}, angle={angle}°)")
190
+
191
+
192
+ @app.command("grow-selection")
193
+ def grow_selection(
194
+ ctx: Context,
195
+ pixels: Annotated[int, typer.Argument(help="Pixels to grow")],
196
+ ) -> None:
197
+ """Grow the current selection outward."""
198
+ client = _shared._get_client(ctx)
199
+ with _shared._handle_errors():
200
+ result = client.grow_selection(pixels)
201
+ _shared._print_result(result, f"Grew selection by {pixels}px")
202
+
203
+
204
+ @app.command("shrink-selection")
205
+ def shrink_selection(
206
+ ctx: Context,
207
+ pixels: Annotated[int, typer.Argument(help="Pixels to shrink")],
208
+ ) -> None:
209
+ """Shrink the current selection inward."""
210
+ client = _shared._get_client(ctx)
211
+ with _shared._handle_errors():
212
+ result = client.shrink_selection(pixels)
213
+ _shared._print_result(result, f"Shrunk selection by {pixels}px")
214
+
215
+
216
+ @app.command("border-selection")
217
+ def border_selection(
218
+ ctx: Context,
219
+ pixels: Annotated[int, typer.Argument(help="Border width in pixels")],
220
+ ) -> None:
221
+ """Create a border selection around the current selection."""
222
+ client = _shared._get_client(ctx)
223
+ with _shared._handle_errors():
224
+ result = client.border_selection(pixels)
225
+ _shared._print_result(result, f"Created {pixels}px border around selection")
226
+
227
+
228
+ @app.command("save-selection")
229
+ def save_selection(
230
+ ctx: Context,
231
+ path: Annotated[str, typer.Argument(help="Path to save selection mask (PNG)")],
232
+ ) -> None:
233
+ """Save current selection as a PNG mask image."""
234
+ client = _shared._get_client(ctx)
235
+ with _shared._handle_errors():
236
+ result = client.save_selection(path=path)
237
+ _shared._print_result(result, f"Saved selection to {path}")
238
+
239
+
240
+ @app.command("load-selection")
241
+ def load_selection(
242
+ ctx: Context,
243
+ path: Annotated[str, typer.Argument(help="Path to selection mask (PNG)")],
244
+ ) -> None:
245
+ """Load selection from a PNG mask image (white=selected, black=unselected)."""
246
+ client = _shared._get_client(ctx)
247
+ with _shared._handle_errors():
248
+ result = client.load_selection(path=path)
249
+ _shared._print_result(result, f"Loaded selection from {path}")
250
+
251
+
252
+ @app.command("selection-stats")
253
+ def selection_stats(ctx: Context) -> None:
254
+ """Get statistics about current selection (pixel count, centroid, bounding box)."""
255
+ client = _shared._get_client(ctx)
256
+ with _shared._handle_errors():
257
+ result = client.selection_stats()
258
+ count = result.get("pixel_count", 0)
259
+ bbox_raw = result.get("bounding_box", {})
260
+ if not isinstance(bbox_raw, dict):
261
+ bbox_raw = {}
262
+ bbox = cast("dict[str, Any]", bbox_raw)
263
+
264
+ console.print("[green]Selection Statistics:[/green]")
265
+ console.print(f" Pixel count: [bold]{count}[/bold]")
266
+ if bbox:
267
+ console.print(
268
+ f" Bounding box: x={bbox.get('x', '?')}, y={bbox.get('y', '?')}, "
269
+ f"w={bbox.get('width', '?')}, h={bbox.get('height', '?')}"
270
+ )
271
+ centroid_raw = result.get("centroid", {})
272
+ if not isinstance(centroid_raw, dict):
273
+ centroid_raw = {}
274
+ centroid = cast("dict[str, Any]", centroid_raw)
275
+
276
+ if centroid:
277
+ console.print(f" Centroid: ({centroid.get('x', '?')}, {centroid.get('y', '?')})")
278
+ area_pct = result.get("area_percentage")
279
+ if area_pct is not None:
280
+ pct = float(cast("Any", area_pct))
281
+ console.print(f" Area: {pct:.1f}% of canvas")
282
+
283
+
284
+ @app.command("save-channel")
285
+ def save_channel(
286
+ ctx: Context,
287
+ name: Annotated[str, typer.Argument(help="Name for the selection channel")],
288
+ ) -> None:
289
+ """Save current selection as a named channel."""
290
+ client = _shared._get_client(ctx)
291
+ with _shared._handle_errors():
292
+ result = client.save_selection_channel(name=name)
293
+ _shared._print_result(result, f"Saved selection channel '{name}'")
294
+
295
+
296
+ @app.command("load-channel")
297
+ def load_channel(
298
+ ctx: Context,
299
+ name: Annotated[str, typer.Argument(help="Name of the selection channel to load")],
300
+ ) -> None:
301
+ """Load a named selection channel."""
302
+ client = _shared._get_client(ctx)
303
+ with _shared._handle_errors():
304
+ result = client.load_selection_channel(name=name)
305
+ _shared._print_result(result, f"Loaded selection channel '{name}'")
306
+
307
+
308
+ @app.command("list-channels")
309
+ def list_channels(ctx: Context) -> None:
310
+ """List all saved selection channels."""
311
+ client = _shared._get_client(ctx)
312
+ with _shared._handle_errors():
313
+ result = client.list_selection_channels()
314
+ channels_raw = result.get("channels", [])
315
+ if not isinstance(channels_raw, list):
316
+ channels_raw = []
317
+ channels = cast("list[dict[str, Any]]", channels_raw)
318
+
319
+ count = result.get("count", 0)
320
+ if count == 0:
321
+ console.print("[dim]No saved selection channels[/dim]")
322
+ else:
323
+ console.print(f"[green]Selection Channels ({count}):[/green]")
324
+ for ch in channels:
325
+ console.print(f" - [bold]{ch.get('name', '?')}[/bold]")
326
+
327
+
328
+ @app.command("delete-channel")
329
+ def delete_channel(
330
+ ctx: Context,
331
+ name: Annotated[str, typer.Argument(help="Name of the selection channel to delete")],
332
+ ) -> None:
333
+ """Delete a saved selection channel."""
334
+ client = _shared._get_client(ctx)
335
+ with _shared._handle_errors():
336
+ result = client.delete_selection_channel(name=name)
337
+ _shared._print_result(result, f"Deleted selection channel '{name}'")
338
+
339
+
340
+ @app.command("security-status")
341
+ def security_status(ctx: Context) -> None:
342
+ """Show current security limits and usage."""
343
+ client = _shared._get_client(ctx)
344
+ with _shared._handle_errors():
345
+ result = client.get_security_status()
346
+ rl_raw = result.get("rate_limit", {})
347
+ if not isinstance(rl_raw, dict):
348
+ rl_raw = {}
349
+ rl = cast("dict[str, Any]", rl_raw)
350
+
351
+ payload_limit = result.get("payload_limit", 0)
352
+ if not isinstance(payload_limit, (int, float)):
353
+ payload_limit = 0
354
+
355
+ console.print("[green]Security Status:[/green]")
356
+ console.print(
357
+ f" Rate limit: [dim]{rl.get('current_usage', 0)}/{rl.get('max_commands_per_minute', '?')} per minute[/dim]"
358
+ )
359
+ console.print(f" Payload limit: [dim]{float(payload_limit) / (1024 * 1024):.0f}MB[/dim]")
360
+ console.print(f" Batch limit: [dim]{result.get('batch_size_limit', '?')} commands[/dim]")
361
+ console.print(
362
+ f" Max canvas: [dim]{result.get('max_canvas_dim', '?')}x{result.get('max_canvas_dim', '?')}[/dim]"
363
+ )
364
+ console.print(f" Max layers: [dim]{result.get('max_layers', '?')}[/dim]")
@@ -0,0 +1,101 @@
1
+ """Stroke-related CLI commands: stroke, fill, draw-shape."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+
7
+ import typer
8
+ from rich.console import Console
9
+ from typer import Context
10
+
11
+ from krita_cli import _shared
12
+ from krita_client import KritaError
13
+
14
+ console = Console()
15
+
16
+ app = typer.Typer()
17
+
18
+
19
+ @app.command()
20
+ def stroke(
21
+ ctx: Context,
22
+ points: Annotated[list[str], typer.Argument(help="Points as 'x,y' pairs (need at least 2)")],
23
+ pressure: Annotated[float, typer.Option("--pressure", help="Brush pressure (0.0-1.0)")] = 1.0,
24
+ size: Annotated[int | None, typer.Option("--size", "-s", help="Brush size in pixels")] = None,
25
+ hardness: Annotated[float, typer.Option("--hardness", help="Stroke hardness (0.0-1.0)")] = 0.5,
26
+ opacity: Annotated[float, typer.Option("--opacity", "-o", help="Stroke opacity (0.0-1.0)")] = 1.0,
27
+ ) -> None:
28
+ """Paint a stroke through a series of points."""
29
+ parsed_points: list[list[int]] = []
30
+ for pt in points:
31
+ parts = pt.split(",")
32
+ if len(parts) != 2:
33
+ console.print(f"[red]Error:[/red] Invalid point format: {pt!r}. Use 'x,y'.")
34
+ raise typer.Exit(code=1)
35
+ try:
36
+ parsed_points.append([int(parts[0]), int(parts[1])])
37
+ except ValueError:
38
+ console.print(f"[red]Error:[/red] Invalid point coordinates: {pt!r}. Values must be integers.")
39
+ raise typer.Exit(code=1) from None
40
+
41
+ try:
42
+ client = _shared._get_client(ctx)
43
+ result = client.stroke(
44
+ points=parsed_points,
45
+ pressure=pressure,
46
+ size=size,
47
+ hardness=hardness,
48
+ opacity=opacity,
49
+ )
50
+ _shared._format_result(result)
51
+ except KritaError as exc:
52
+ _shared._handle_error(exc)
53
+
54
+
55
+ @app.command()
56
+ def fill(
57
+ ctx: Context,
58
+ x: Annotated[int, typer.Argument(help="X coordinate")],
59
+ y: Annotated[int, typer.Argument(help="Y coordinate")],
60
+ radius: Annotated[int, typer.Option("--radius", "-r", help="Fill radius in pixels")] = 50,
61
+ ) -> None:
62
+ """Fill a circular area with the current color."""
63
+ try:
64
+ client = _shared._get_client(ctx)
65
+ result = client.fill(x=x, y=y, radius=radius)
66
+ _shared._format_result(result)
67
+ except KritaError as exc:
68
+ _shared._handle_error(exc)
69
+
70
+
71
+ @app.command("draw-shape")
72
+ def draw_shape(
73
+ ctx: Context,
74
+ shape: Annotated[str, typer.Argument(help="Shape type: rectangle, ellipse, or line")],
75
+ x: Annotated[int, typer.Argument(help="X coordinate")],
76
+ y: Annotated[int, typer.Argument(help="Y coordinate")],
77
+ width: Annotated[int, typer.Option("--width", "-W", help="Width")] = 100,
78
+ height: Annotated[int, typer.Option("--height", "-H", help="Height")] = 100,
79
+ *,
80
+ fill: Annotated[bool, typer.Option("--fill/--no-fill", help="Fill the shape")] = True,
81
+ stroke: Annotated[bool, typer.Option("--stroke/--no-stroke", help="Draw outline")] = False,
82
+ x2: Annotated[int | None, typer.Option("--x2", help="End X for lines")] = None,
83
+ y2: Annotated[int | None, typer.Option("--y2", help="End Y for lines")] = None,
84
+ ) -> None:
85
+ """Draw a shape on the canvas."""
86
+ try:
87
+ client = _shared._get_client(ctx)
88
+ result = client.draw_shape(
89
+ shape=shape,
90
+ x=x,
91
+ y=y,
92
+ width=width,
93
+ height=height,
94
+ fill=fill,
95
+ stroke=stroke,
96
+ x2=x2,
97
+ y2=y2,
98
+ )
99
+ _shared._format_result(result)
100
+ except KritaError as exc:
101
+ _shared._handle_error(exc)
@@ -0,0 +1,68 @@
1
+ """Plugin configuration management.
2
+
3
+ Provides functions to load, save, and modify plugin configuration stored in
4
+ ``~/.krita-cli/config.json``. The plugin reads this file on startup so that
5
+ CLI changes persist across Krita restarts.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ CONFIG_DIR = Path.home() / ".krita-cli"
15
+ CONFIG_FILE = CONFIG_DIR / "config.json"
16
+
17
+ DEFAULTS: dict[str, Any] = {
18
+ "port": 5678,
19
+ "canvas_output_dir": "~/krita-mcp-output",
20
+ "default_timeout": 30.0,
21
+ "export_timeout": 120.0,
22
+ "max_canvas_dim": 8192,
23
+ "max_batch_size": 50,
24
+ "max_layers": 100,
25
+ "max_commands_per_minute": 60,
26
+ }
27
+
28
+
29
+ def load_config() -> dict[str, Any]:
30
+ """Load plugin configuration from disk, falling back to defaults."""
31
+ if CONFIG_FILE.exists():
32
+ try:
33
+ return json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
34
+ except json.JSONDecodeError:
35
+ # Config file is corrupted; fall back to defaults
36
+ return dict(DEFAULTS)
37
+ return dict(DEFAULTS)
38
+
39
+
40
+ def save_config(config: dict[str, Any]) -> None:
41
+ """Persist *config* to disk, creating the config directory if needed."""
42
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
43
+ CONFIG_FILE.write_text(json.dumps(config, indent=2), encoding="utf-8")
44
+
45
+
46
+ def set_key(key: str, value: str) -> None:
47
+ """Set a single configuration *key* to *value* with type coercion.
48
+
49
+ Raises ``ValueError`` if *key* is not recognised.
50
+ """
51
+ if key not in DEFAULTS:
52
+ raise ValueError(f"Unknown config key: {key}")
53
+ config = load_config()
54
+ default = DEFAULTS[key]
55
+ if isinstance(default, int):
56
+ config[key] = int(value)
57
+ elif isinstance(default, float):
58
+ config[key] = float(value)
59
+ else:
60
+ config[key] = value
61
+ save_config(config)
62
+
63
+
64
+ def reset_config() -> dict[str, Any]:
65
+ """Reset configuration to defaults and persist them."""
66
+ defaults = dict(DEFAULTS)
67
+ save_config(defaults)
68
+ return defaults