@agent-webui/ai-desk-harness-gimp 1.0.29-beta1

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.
Files changed (48) hide show
  1. package/README.md +4 -0
  2. package/manifest.json +22 -0
  3. package/package.json +11 -0
  4. package/python/agent-harness/GIMP.md +301 -0
  5. package/python/agent-harness/build/lib/cli_anything/gimp/__init__.py +1 -0
  6. package/python/agent-harness/build/lib/cli_anything/gimp/__main__.py +3 -0
  7. package/python/agent-harness/build/lib/cli_anything/gimp/core/__init__.py +1 -0
  8. package/python/agent-harness/build/lib/cli_anything/gimp/core/canvas.py +193 -0
  9. package/python/agent-harness/build/lib/cli_anything/gimp/core/export.py +479 -0
  10. package/python/agent-harness/build/lib/cli_anything/gimp/core/filters.py +382 -0
  11. package/python/agent-harness/build/lib/cli_anything/gimp/core/layers.py +249 -0
  12. package/python/agent-harness/build/lib/cli_anything/gimp/core/media.py +174 -0
  13. package/python/agent-harness/build/lib/cli_anything/gimp/core/project.py +131 -0
  14. package/python/agent-harness/build/lib/cli_anything/gimp/core/session.py +130 -0
  15. package/python/agent-harness/build/lib/cli_anything/gimp/gimp_cli.py +788 -0
  16. package/python/agent-harness/build/lib/cli_anything/gimp/tests/__init__.py +1 -0
  17. package/python/agent-harness/build/lib/cli_anything/gimp/tests/test_core.py +478 -0
  18. package/python/agent-harness/build/lib/cli_anything/gimp/tests/test_full_e2e.py +578 -0
  19. package/python/agent-harness/build/lib/cli_anything/gimp/utils/__init__.py +1 -0
  20. package/python/agent-harness/build/lib/cli_anything/gimp/utils/gimp_backend.py +208 -0
  21. package/python/agent-harness/build/lib/cli_anything/gimp/utils/repl_skin.py +498 -0
  22. package/python/agent-harness/cli_anything/gimp/README.md +202 -0
  23. package/python/agent-harness/cli_anything/gimp/__init__.py +1 -0
  24. package/python/agent-harness/cli_anything/gimp/__main__.py +3 -0
  25. package/python/agent-harness/cli_anything/gimp/core/__init__.py +1 -0
  26. package/python/agent-harness/cli_anything/gimp/core/canvas.py +193 -0
  27. package/python/agent-harness/cli_anything/gimp/core/export.py +479 -0
  28. package/python/agent-harness/cli_anything/gimp/core/filters.py +382 -0
  29. package/python/agent-harness/cli_anything/gimp/core/layers.py +249 -0
  30. package/python/agent-harness/cli_anything/gimp/core/media.py +174 -0
  31. package/python/agent-harness/cli_anything/gimp/core/project.py +131 -0
  32. package/python/agent-harness/cli_anything/gimp/core/session.py +130 -0
  33. package/python/agent-harness/cli_anything/gimp/gimp_cli.py +788 -0
  34. package/python/agent-harness/cli_anything/gimp/tests/TEST.md +137 -0
  35. package/python/agent-harness/cli_anything/gimp/tests/__init__.py +1 -0
  36. package/python/agent-harness/cli_anything/gimp/tests/test_core.py +478 -0
  37. package/python/agent-harness/cli_anything/gimp/tests/test_full_e2e.py +578 -0
  38. package/python/agent-harness/cli_anything/gimp/utils/__init__.py +1 -0
  39. package/python/agent-harness/cli_anything/gimp/utils/gimp_backend.py +208 -0
  40. package/python/agent-harness/cli_anything/gimp/utils/repl_skin.py +498 -0
  41. package/python/agent-harness/cli_anything_gimp.egg-info/PKG-INFO +236 -0
  42. package/python/agent-harness/cli_anything_gimp.egg-info/SOURCES.txt +25 -0
  43. package/python/agent-harness/cli_anything_gimp.egg-info/dependency_links.txt +1 -0
  44. package/python/agent-harness/cli_anything_gimp.egg-info/entry_points.txt +2 -0
  45. package/python/agent-harness/cli_anything_gimp.egg-info/not-zip-safe +1 -0
  46. package/python/agent-harness/cli_anything_gimp.egg-info/requires.txt +7 -0
  47. package/python/agent-harness/cli_anything_gimp.egg-info/top_level.txt +1 -0
  48. package/python/agent-harness/setup.py +54 -0
@@ -0,0 +1,788 @@
1
+ #!/usr/bin/env python3
2
+ """GIMP CLI — A stateful command-line interface for image editing.
3
+
4
+ This CLI provides full image editing capabilities using Pillow as the
5
+ backend engine, with a project format that tracks layers, filters,
6
+ and history.
7
+
8
+ Usage:
9
+ # One-shot commands
10
+ python3 -m cli.gimp_cli project new --width 1920 --height 1080
11
+ python3 -m cli.gimp_cli layer add-from-file photo.jpg --name "Background"
12
+ python3 -m cli.gimp_cli filter add brightness --layer 0 --param factor=1.3
13
+
14
+ # Interactive REPL
15
+ python3 -m cli.gimp_cli repl
16
+ """
17
+
18
+ import sys
19
+ import os
20
+ import json
21
+ import click
22
+ from typing import Optional
23
+
24
+ # Add parent to path for imports
25
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
26
+
27
+ from cli_anything.gimp.core.session import Session
28
+ from cli_anything.gimp.core import project as proj_mod
29
+ from cli_anything.gimp.core import layers as layer_mod
30
+ from cli_anything.gimp.core import filters as filt_mod
31
+ from cli_anything.gimp.core import canvas as canvas_mod
32
+ from cli_anything.gimp.core import media as media_mod
33
+ from cli_anything.gimp.core import export as export_mod
34
+
35
+ # Global session state
36
+ _session: Optional[Session] = None
37
+ _json_output = False
38
+ _repl_mode = False
39
+
40
+
41
+ def get_session() -> Session:
42
+ global _session
43
+ if _session is None:
44
+ _session = Session()
45
+ return _session
46
+
47
+
48
+ def output(data, message: str = ""):
49
+ if _json_output:
50
+ click.echo(json.dumps(data, indent=2, default=str))
51
+ else:
52
+ if message:
53
+ click.echo(message)
54
+ if isinstance(data, dict):
55
+ _print_dict(data)
56
+ elif isinstance(data, list):
57
+ _print_list(data)
58
+ else:
59
+ click.echo(str(data))
60
+
61
+
62
+ def _print_dict(d: dict, indent: int = 0):
63
+ prefix = " " * indent
64
+ for k, v in d.items():
65
+ if isinstance(v, dict):
66
+ click.echo(f"{prefix}{k}:")
67
+ _print_dict(v, indent + 1)
68
+ elif isinstance(v, list):
69
+ click.echo(f"{prefix}{k}:")
70
+ _print_list(v, indent + 1)
71
+ else:
72
+ click.echo(f"{prefix}{k}: {v}")
73
+
74
+
75
+ def _print_list(items: list, indent: int = 0):
76
+ prefix = " " * indent
77
+ for i, item in enumerate(items):
78
+ if isinstance(item, dict):
79
+ click.echo(f"{prefix}[{i}]")
80
+ _print_dict(item, indent + 1)
81
+ else:
82
+ click.echo(f"{prefix}- {item}")
83
+
84
+
85
+ def handle_error(func):
86
+ def wrapper(*args, **kwargs):
87
+ try:
88
+ return func(*args, **kwargs)
89
+ except FileNotFoundError as e:
90
+ if _json_output:
91
+ click.echo(json.dumps({"error": str(e), "type": "file_not_found"}))
92
+ else:
93
+ click.echo(f"Error: {e}", err=True)
94
+ if not _repl_mode:
95
+ sys.exit(1)
96
+ except (ValueError, IndexError, RuntimeError) as e:
97
+ if _json_output:
98
+ click.echo(json.dumps({"error": str(e), "type": type(e).__name__}))
99
+ else:
100
+ click.echo(f"Error: {e}", err=True)
101
+ if not _repl_mode:
102
+ sys.exit(1)
103
+ except FileExistsError as e:
104
+ if _json_output:
105
+ click.echo(json.dumps({"error": str(e), "type": "file_exists"}))
106
+ else:
107
+ click.echo(f"Error: {e}", err=True)
108
+ if not _repl_mode:
109
+ sys.exit(1)
110
+ wrapper.__name__ = func.__name__
111
+ wrapper.__doc__ = func.__doc__
112
+ return wrapper
113
+
114
+
115
+ # ── Main CLI Group ──────────────────────────────────────────────
116
+ @click.group(invoke_without_command=True)
117
+ @click.option("--json", "use_json", is_flag=True, help="Output as JSON")
118
+ @click.option("--project", "project_path", type=str, default=None,
119
+ help="Path to .gimp-cli.json project file")
120
+ @click.pass_context
121
+ def cli(ctx, use_json, project_path):
122
+ """GIMP CLI — Stateful image editing from the command line.
123
+
124
+ Run without a subcommand to enter interactive REPL mode.
125
+ """
126
+ global _json_output
127
+ _json_output = use_json
128
+
129
+ if project_path:
130
+ sess = get_session()
131
+ if not sess.has_project():
132
+ proj = proj_mod.open_project(project_path)
133
+ sess.set_project(proj, project_path)
134
+
135
+ if ctx.invoked_subcommand is None:
136
+ ctx.invoke(repl, project_path=None)
137
+
138
+
139
+ # ── Project Commands ─────────────────────────────────────────────
140
+ @cli.group()
141
+ def project():
142
+ """Project management commands."""
143
+ pass
144
+
145
+
146
+ @project.command("new")
147
+ @click.option("--width", "-w", type=int, default=1920, help="Canvas width")
148
+ @click.option("--height", "-h", type=int, default=1080, help="Canvas height")
149
+ @click.option("--mode", type=click.Choice(["RGB", "RGBA", "L", "LA"]), default="RGB")
150
+ @click.option("--background", "-bg", default="#ffffff", help="Background color")
151
+ @click.option("--dpi", type=int, default=72, help="Resolution in DPI")
152
+ @click.option("--name", "-n", default="untitled", help="Project name")
153
+ @click.option("--profile", "-p", type=str, default=None, help="Canvas profile")
154
+ @click.option("--output", "-o", type=str, default=None, help="Save path")
155
+ @handle_error
156
+ def project_new(width, height, mode, background, dpi, name, profile, output):
157
+ """Create a new project."""
158
+ proj = proj_mod.create_project(
159
+ width=width, height=height, color_mode=mode,
160
+ background=background, dpi=dpi, name=name, profile=profile,
161
+ )
162
+ sess = get_session()
163
+ sess.set_project(proj, output)
164
+ if output:
165
+ proj_mod.save_project(proj, output)
166
+ output_data = proj_mod.get_project_info(proj)
167
+ globals()["output"](output_data, f"Created project: {name}")
168
+
169
+
170
+ @project.command("open")
171
+ @click.argument("path")
172
+ @handle_error
173
+ def project_open(path):
174
+ """Open an existing project."""
175
+ proj = proj_mod.open_project(path)
176
+ sess = get_session()
177
+ sess.set_project(proj, path)
178
+ info = proj_mod.get_project_info(proj)
179
+ output(info, f"Opened: {path}")
180
+
181
+
182
+ @project.command("save")
183
+ @click.argument("path", required=False)
184
+ @handle_error
185
+ def project_save(path):
186
+ """Save the current project."""
187
+ sess = get_session()
188
+ saved = sess.save_session(path)
189
+ output({"saved": saved}, f"Saved to: {saved}")
190
+
191
+
192
+ @project.command("info")
193
+ @handle_error
194
+ def project_info():
195
+ """Show project information."""
196
+ sess = get_session()
197
+ info = proj_mod.get_project_info(sess.get_project())
198
+ output(info)
199
+
200
+
201
+ @project.command("profiles")
202
+ @handle_error
203
+ def project_profiles():
204
+ """List available canvas profiles."""
205
+ profiles = proj_mod.list_profiles()
206
+ output(profiles, "Available profiles:")
207
+
208
+
209
+ @project.command("json")
210
+ @handle_error
211
+ def project_json():
212
+ """Print raw project JSON."""
213
+ sess = get_session()
214
+ click.echo(json.dumps(sess.get_project(), indent=2, default=str))
215
+
216
+
217
+ # ── Layer Commands ───────────────────────────────────────────────
218
+ @cli.group()
219
+ def layer():
220
+ """Layer management commands."""
221
+ pass
222
+
223
+
224
+ @layer.command("new")
225
+ @click.option("--name", "-n", default="New Layer", help="Layer name")
226
+ @click.option("--type", "layer_type", type=click.Choice(["image", "text", "solid"]),
227
+ default="image", help="Layer type")
228
+ @click.option("--width", "-w", type=int, default=None, help="Layer width")
229
+ @click.option("--height", "-h", type=int, default=None, help="Layer height")
230
+ @click.option("--fill", default="transparent", help="Fill: transparent, white, black, or hex")
231
+ @click.option("--opacity", type=float, default=1.0, help="Layer opacity 0.0-1.0")
232
+ @click.option("--mode", default="normal", help="Blend mode")
233
+ @click.option("--position", "-p", type=int, default=None, help="Stack position (0=top)")
234
+ @handle_error
235
+ def layer_new(name, layer_type, width, height, fill, opacity, mode, position):
236
+ """Create a new blank layer."""
237
+ sess = get_session()
238
+ sess.snapshot(f"Add layer: {name}")
239
+ proj = sess.get_project()
240
+ layer = layer_mod.add_layer(
241
+ proj, name=name, layer_type=layer_type, width=width, height=height,
242
+ fill=fill, opacity=opacity, blend_mode=mode, position=position,
243
+ )
244
+ output(layer, f"Added layer: {name}")
245
+
246
+
247
+ @layer.command("add-from-file")
248
+ @click.argument("path")
249
+ @click.option("--name", "-n", default=None, help="Layer name")
250
+ @click.option("--position", "-p", type=int, default=None, help="Stack position")
251
+ @click.option("--opacity", type=float, default=1.0, help="Layer opacity")
252
+ @click.option("--mode", default="normal", help="Blend mode")
253
+ @handle_error
254
+ def layer_add_from_file(path, name, position, opacity, mode):
255
+ """Add a layer from an image file."""
256
+ sess = get_session()
257
+ sess.snapshot(f"Add layer from: {path}")
258
+ proj = sess.get_project()
259
+ layer = layer_mod.add_from_file(
260
+ proj, path=path, name=name, position=position,
261
+ opacity=opacity, blend_mode=mode,
262
+ )
263
+ output(layer, f"Added layer from: {path}")
264
+
265
+
266
+ @layer.command("list")
267
+ @handle_error
268
+ def layer_list():
269
+ """List all layers."""
270
+ sess = get_session()
271
+ layers = layer_mod.list_layers(sess.get_project())
272
+ output(layers, "Layers (top to bottom):")
273
+
274
+
275
+ @layer.command("remove")
276
+ @click.argument("index", type=int)
277
+ @handle_error
278
+ def layer_remove(index):
279
+ """Remove a layer by index."""
280
+ sess = get_session()
281
+ sess.snapshot(f"Remove layer {index}")
282
+ removed = layer_mod.remove_layer(sess.get_project(), index)
283
+ output(removed, f"Removed layer {index}: {removed.get('name', '')}")
284
+
285
+
286
+ @layer.command("duplicate")
287
+ @click.argument("index", type=int)
288
+ @handle_error
289
+ def layer_duplicate(index):
290
+ """Duplicate a layer."""
291
+ sess = get_session()
292
+ sess.snapshot(f"Duplicate layer {index}")
293
+ dup = layer_mod.duplicate_layer(sess.get_project(), index)
294
+ output(dup, f"Duplicated layer {index}")
295
+
296
+
297
+ @layer.command("move")
298
+ @click.argument("index", type=int)
299
+ @click.option("--to", type=int, required=True, help="Target position")
300
+ @handle_error
301
+ def layer_move(index, to):
302
+ """Move a layer to a new position."""
303
+ sess = get_session()
304
+ sess.snapshot(f"Move layer {index} to {to}")
305
+ layer_mod.move_layer(sess.get_project(), index, to)
306
+ output({"moved": index, "to": to}, f"Moved layer {index} to position {to}")
307
+
308
+
309
+ @layer.command("set")
310
+ @click.argument("index", type=int)
311
+ @click.argument("prop")
312
+ @click.argument("value")
313
+ @handle_error
314
+ def layer_set(index, prop, value):
315
+ """Set a layer property (name, opacity, visible, mode, offset_x, offset_y)."""
316
+ sess = get_session()
317
+ sess.snapshot(f"Set layer {index} {prop}={value}")
318
+ layer_mod.set_layer_property(sess.get_project(), index, prop, value)
319
+ output({"layer": index, "property": prop, "value": value},
320
+ f"Set layer {index} {prop} = {value}")
321
+
322
+
323
+ @layer.command("flatten")
324
+ @handle_error
325
+ def layer_flatten():
326
+ """Flatten all visible layers."""
327
+ sess = get_session()
328
+ sess.snapshot("Flatten layers")
329
+ layer_mod.flatten_layers(sess.get_project())
330
+ output({"status": "flatten_pending"}, "Layers marked for flattening (applied on export)")
331
+
332
+
333
+ @layer.command("merge-down")
334
+ @click.argument("index", type=int)
335
+ @handle_error
336
+ def layer_merge_down(index):
337
+ """Merge a layer with the one below it."""
338
+ sess = get_session()
339
+ sess.snapshot(f"Merge down layer {index}")
340
+ layer_mod.merge_down(sess.get_project(), index)
341
+ output({"status": "merge_pending", "layer": index},
342
+ f"Layer {index} marked for merge-down (applied on export)")
343
+
344
+
345
+ # ── Canvas Commands ──────────────────────────────────────────────
346
+ @cli.group()
347
+ def canvas():
348
+ """Canvas operations."""
349
+ pass
350
+
351
+
352
+ @canvas.command("info")
353
+ @handle_error
354
+ def canvas_info():
355
+ """Show canvas information."""
356
+ sess = get_session()
357
+ info = canvas_mod.get_canvas_info(sess.get_project())
358
+ output(info)
359
+
360
+
361
+ @canvas.command("resize")
362
+ @click.option("--width", "-w", type=int, required=True)
363
+ @click.option("--height", "-h", type=int, required=True)
364
+ @click.option("--anchor", default="center",
365
+ help="Anchor: center, top-left, top-right, bottom-left, bottom-right, top, bottom, left, right")
366
+ @handle_error
367
+ def canvas_resize(width, height, anchor):
368
+ """Resize the canvas (without scaling content)."""
369
+ sess = get_session()
370
+ sess.snapshot(f"Resize canvas to {width}x{height}")
371
+ result = canvas_mod.resize_canvas(sess.get_project(), width, height, anchor)
372
+ output(result, f"Canvas resized to {width}x{height}")
373
+
374
+
375
+ @canvas.command("scale")
376
+ @click.option("--width", "-w", type=int, required=True)
377
+ @click.option("--height", "-h", type=int, required=True)
378
+ @click.option("--resample", default="lanczos",
379
+ type=click.Choice(["nearest", "bilinear", "bicubic", "lanczos"]))
380
+ @handle_error
381
+ def canvas_scale(width, height, resample):
382
+ """Scale the canvas and all content proportionally."""
383
+ sess = get_session()
384
+ sess.snapshot(f"Scale canvas to {width}x{height}")
385
+ result = canvas_mod.scale_canvas(sess.get_project(), width, height, resample)
386
+ output(result, f"Canvas scaled to {width}x{height}")
387
+
388
+
389
+ @canvas.command("crop")
390
+ @click.option("--left", "-l", type=int, required=True)
391
+ @click.option("--top", "-t", type=int, required=True)
392
+ @click.option("--right", "-r", type=int, required=True)
393
+ @click.option("--bottom", "-b", type=int, required=True)
394
+ @handle_error
395
+ def canvas_crop(left, top, right, bottom):
396
+ """Crop the canvas to a rectangle."""
397
+ sess = get_session()
398
+ sess.snapshot(f"Crop canvas ({left},{top})-({right},{bottom})")
399
+ result = canvas_mod.crop_canvas(sess.get_project(), left, top, right, bottom)
400
+ output(result, "Canvas cropped")
401
+
402
+
403
+ @canvas.command("mode")
404
+ @click.argument("mode", type=click.Choice(["RGB", "RGBA", "L", "LA", "CMYK", "P"]))
405
+ @handle_error
406
+ def canvas_mode(mode):
407
+ """Set the canvas color mode."""
408
+ sess = get_session()
409
+ sess.snapshot(f"Change mode to {mode}")
410
+ result = canvas_mod.set_mode(sess.get_project(), mode)
411
+ output(result, f"Canvas mode changed to {mode}")
412
+
413
+
414
+ @canvas.command("dpi")
415
+ @click.argument("dpi", type=int)
416
+ @handle_error
417
+ def canvas_dpi(dpi):
418
+ """Set the canvas DPI."""
419
+ sess = get_session()
420
+ result = canvas_mod.set_dpi(sess.get_project(), dpi)
421
+ output(result, f"DPI set to {dpi}")
422
+
423
+
424
+ # ── Filter Commands ──────────────────────────────────────────────
425
+ @cli.group("filter")
426
+ def filter_group():
427
+ """Filter management commands."""
428
+ pass
429
+
430
+
431
+ @filter_group.command("list-available")
432
+ @click.option("--category", "-c", type=str, default=None,
433
+ help="Filter by category: adjustment, blur, stylize, transform")
434
+ @handle_error
435
+ def filter_list_available(category):
436
+ """List all available filters."""
437
+ filters = filt_mod.list_available(category)
438
+ output(filters, "Available filters:")
439
+
440
+
441
+ @filter_group.command("info")
442
+ @click.argument("name")
443
+ @handle_error
444
+ def filter_info(name):
445
+ """Show details about a filter."""
446
+ info = filt_mod.get_filter_info(name)
447
+ output(info)
448
+
449
+
450
+ @filter_group.command("add")
451
+ @click.argument("name")
452
+ @click.option("--layer", "-l", "layer_index", type=int, default=0, help="Layer index")
453
+ @click.option("--param", "-p", multiple=True, help="Parameter: key=value")
454
+ @handle_error
455
+ def filter_add(name, layer_index, param):
456
+ """Add a filter to a layer."""
457
+ params = {}
458
+ for p in param:
459
+ if "=" not in p:
460
+ raise ValueError(f"Invalid param format: '{p}'. Use key=value.")
461
+ k, v = p.split("=", 1)
462
+ try:
463
+ v = float(v) if "." in v else int(v)
464
+ except ValueError:
465
+ pass
466
+ params[k] = v
467
+
468
+ sess = get_session()
469
+ sess.snapshot(f"Add filter {name} to layer {layer_index}")
470
+ result = filt_mod.add_filter(sess.get_project(), name, layer_index, params)
471
+ output(result, f"Added filter: {name}")
472
+
473
+
474
+ @filter_group.command("remove")
475
+ @click.argument("filter_index", type=int)
476
+ @click.option("--layer", "-l", "layer_index", type=int, default=0)
477
+ @handle_error
478
+ def filter_remove(filter_index, layer_index):
479
+ """Remove a filter by index."""
480
+ sess = get_session()
481
+ sess.snapshot(f"Remove filter {filter_index} from layer {layer_index}")
482
+ result = filt_mod.remove_filter(sess.get_project(), filter_index, layer_index)
483
+ output(result, f"Removed filter {filter_index}")
484
+
485
+
486
+ @filter_group.command("set")
487
+ @click.argument("filter_index", type=int)
488
+ @click.argument("param")
489
+ @click.argument("value")
490
+ @click.option("--layer", "-l", "layer_index", type=int, default=0)
491
+ @handle_error
492
+ def filter_set(filter_index, param, value, layer_index):
493
+ """Set a filter parameter."""
494
+ try:
495
+ value = float(value) if "." in str(value) else int(value)
496
+ except ValueError:
497
+ pass
498
+ sess = get_session()
499
+ sess.snapshot(f"Set filter {filter_index} {param}={value}")
500
+ filt_mod.set_filter_param(sess.get_project(), filter_index, param, value, layer_index)
501
+ output({"filter": filter_index, "param": param, "value": value},
502
+ f"Set filter {filter_index} {param} = {value}")
503
+
504
+
505
+ @filter_group.command("list")
506
+ @click.option("--layer", "-l", "layer_index", type=int, default=0)
507
+ @handle_error
508
+ def filter_list(layer_index):
509
+ """List filters on a layer."""
510
+ sess = get_session()
511
+ filters = filt_mod.list_filters(sess.get_project(), layer_index)
512
+ output(filters, f"Filters on layer {layer_index}:")
513
+
514
+
515
+ # ── Media Commands ───────────────────────────────────────────────
516
+ @cli.group()
517
+ def media():
518
+ """Media file operations."""
519
+ pass
520
+
521
+
522
+ @media.command("probe")
523
+ @click.argument("path")
524
+ @handle_error
525
+ def media_probe(path):
526
+ """Analyze an image file."""
527
+ info = media_mod.probe_image(path)
528
+ output(info)
529
+
530
+
531
+ @media.command("list")
532
+ @handle_error
533
+ def media_list():
534
+ """List media files referenced in the project."""
535
+ sess = get_session()
536
+ media = media_mod.list_media_in_project(sess.get_project())
537
+ output(media, "Referenced media files:")
538
+
539
+
540
+ @media.command("check")
541
+ @handle_error
542
+ def media_check():
543
+ """Check that all referenced media files exist."""
544
+ sess = get_session()
545
+ result = media_mod.check_media(sess.get_project())
546
+ output(result)
547
+
548
+
549
+ @media.command("histogram")
550
+ @click.argument("path")
551
+ @handle_error
552
+ def media_histogram(path):
553
+ """Show histogram analysis of an image."""
554
+ result = media_mod.get_image_histogram(path)
555
+ output(result)
556
+
557
+
558
+ # ── Export Commands ──────────────────────────────────────────────
559
+ @cli.group("export")
560
+ def export_group():
561
+ """Export/render commands."""
562
+ pass
563
+
564
+
565
+ @export_group.command("presets")
566
+ @handle_error
567
+ def export_presets():
568
+ """List export presets."""
569
+ presets = export_mod.list_presets()
570
+ output(presets, "Export presets:")
571
+
572
+
573
+ @export_group.command("preset-info")
574
+ @click.argument("name")
575
+ @handle_error
576
+ def export_preset_info(name):
577
+ """Show preset details."""
578
+ info = export_mod.get_preset_info(name)
579
+ output(info)
580
+
581
+
582
+ @export_group.command("render")
583
+ @click.argument("output_path")
584
+ @click.option("--preset", "-p", default="png", help="Export preset")
585
+ @click.option("--overwrite", is_flag=True, help="Overwrite existing file")
586
+ @click.option("--quality", "-q", type=int, default=None, help="Quality override")
587
+ @click.option("--format", "fmt", type=str, default=None, help="Format override")
588
+ @handle_error
589
+ def export_render(output_path, preset, overwrite, quality, fmt):
590
+ """Render the project to an image file."""
591
+ sess = get_session()
592
+ result = export_mod.render(
593
+ sess.get_project(), output_path,
594
+ preset=preset, overwrite=overwrite,
595
+ quality=quality, format_override=fmt,
596
+ )
597
+ output(result, f"Rendered to: {output_path}")
598
+
599
+
600
+ # ── Session Commands ─────────────────────────────────────────────
601
+ @cli.group()
602
+ def session():
603
+ """Session management commands."""
604
+ pass
605
+
606
+
607
+ @session.command("status")
608
+ @handle_error
609
+ def session_status():
610
+ """Show session status."""
611
+ sess = get_session()
612
+ output(sess.status())
613
+
614
+
615
+ @session.command("undo")
616
+ @handle_error
617
+ def session_undo():
618
+ """Undo the last operation."""
619
+ sess = get_session()
620
+ desc = sess.undo()
621
+ output({"undone": desc}, f"Undone: {desc}")
622
+
623
+
624
+ @session.command("redo")
625
+ @handle_error
626
+ def session_redo():
627
+ """Redo the last undone operation."""
628
+ sess = get_session()
629
+ desc = sess.redo()
630
+ output({"redone": desc}, f"Redone: {desc}")
631
+
632
+
633
+ @session.command("history")
634
+ @handle_error
635
+ def session_history():
636
+ """Show undo history."""
637
+ sess = get_session()
638
+ history = sess.list_history()
639
+ output(history, "Undo history:")
640
+
641
+
642
+ # ── Draw Commands ────────────────────────────────────────────────
643
+ @cli.group()
644
+ def draw():
645
+ """Drawing operations (applied at render time)."""
646
+ pass
647
+
648
+
649
+ @draw.command("text")
650
+ @click.option("--layer", "-l", "layer_index", type=int, default=0)
651
+ @click.option("--text", "-t", required=True, help="Text to draw")
652
+ @click.option("--x", type=int, default=0, help="X position")
653
+ @click.option("--y", type=int, default=0, help="Y position")
654
+ @click.option("--font", default="Arial", help="Font name")
655
+ @click.option("--size", type=int, default=24, help="Font size")
656
+ @click.option("--color", default="#000000", help="Text color (hex)")
657
+ @handle_error
658
+ def draw_text(layer_index, text, x, y, font, size, color):
659
+ """Draw text on a layer (by converting it to a text layer)."""
660
+ sess = get_session()
661
+ sess.snapshot(f"Draw text on layer {layer_index}")
662
+ proj = sess.get_project()
663
+ layers = proj.get("layers", [])
664
+ if layer_index < 0 or layer_index >= len(layers):
665
+ raise IndexError(f"Layer index {layer_index} out of range")
666
+ layer = layers[layer_index]
667
+ layer["type"] = "text"
668
+ layer["text"] = text
669
+ layer["font"] = font
670
+ layer["font_size"] = size
671
+ layer["color"] = color
672
+ layer["offset_x"] = x
673
+ layer["offset_y"] = y
674
+ output({"layer": layer_index, "text": text}, f"Set text on layer {layer_index}")
675
+
676
+
677
+ @draw.command("rect")
678
+ @click.option("--layer", "-l", "layer_index", type=int, default=0)
679
+ @click.option("--x1", type=int, required=True)
680
+ @click.option("--y1", type=int, required=True)
681
+ @click.option("--x2", type=int, required=True)
682
+ @click.option("--y2", type=int, required=True)
683
+ @click.option("--fill", default=None, help="Fill color (hex)")
684
+ @click.option("--outline", default=None, help="Outline color (hex)")
685
+ @click.option("--width", "line_width", type=int, default=1, help="Outline width")
686
+ @handle_error
687
+ def draw_rect(layer_index, x1, y1, x2, y2, fill, outline, line_width):
688
+ """Draw a rectangle (stored as drawing operation)."""
689
+ sess = get_session()
690
+ sess.snapshot(f"Draw rect on layer {layer_index}")
691
+ proj = sess.get_project()
692
+ layers = proj.get("layers", [])
693
+ if layer_index < 0 or layer_index >= len(layers):
694
+ raise IndexError(f"Layer index {layer_index} out of range")
695
+ layer = layers[layer_index]
696
+ if "draw_ops" not in layer:
697
+ layer["draw_ops"] = []
698
+ layer["draw_ops"].append({
699
+ "type": "rect",
700
+ "x1": x1, "y1": y1, "x2": x2, "y2": y2,
701
+ "fill": fill, "outline": outline, "width": line_width,
702
+ })
703
+ output({"layer": layer_index, "shape": "rect", "coords": f"({x1},{y1})-({x2},{y2})"},
704
+ f"Drew rectangle on layer {layer_index}")
705
+
706
+
707
+ # ── REPL ─────────────────────────────────────────────────────────
708
+ @cli.command()
709
+ @click.option("--project", "project_path", type=str, default=None)
710
+ @handle_error
711
+ def repl(project_path):
712
+ """Start interactive REPL session."""
713
+ from cli_anything.gimp.utils.repl_skin import ReplSkin
714
+
715
+ global _repl_mode
716
+ _repl_mode = True
717
+
718
+ skin = ReplSkin("gimp", version="1.0.0")
719
+
720
+ if project_path:
721
+ sess = get_session()
722
+ proj = proj_mod.open_project(project_path)
723
+ sess.set_project(proj, project_path)
724
+
725
+ skin.print_banner()
726
+
727
+ pt_session = skin.create_prompt_session()
728
+
729
+ _repl_commands = {
730
+ "project": "new|open|save|info|profiles|json",
731
+ "layer": "new|add-from-file|list|remove|duplicate|move|set|flatten|merge-down",
732
+ "canvas": "info|resize|scale|crop|mode|dpi",
733
+ "filter": "list-available|info|add|remove|set|list",
734
+ "media": "probe|list|check|histogram",
735
+ "export": "presets|preset-info|render",
736
+ "draw": "text|rect",
737
+ "session": "status|undo|redo|history",
738
+ "help": "Show this help",
739
+ "quit": "Exit REPL",
740
+ }
741
+
742
+ while True:
743
+ try:
744
+ # Determine project name for prompt
745
+ try:
746
+ sess = get_session()
747
+ proj_name = ""
748
+ if sess.has_project():
749
+ p = sess.get_project()
750
+ proj_name = p.get("name", "") if isinstance(p, dict) else ""
751
+ except Exception:
752
+ proj_name = ""
753
+
754
+ line = skin.get_input(pt_session, project_name=proj_name, modified=False)
755
+ if not line:
756
+ continue
757
+ if line.lower() in ("quit", "exit", "q"):
758
+ skin.print_goodbye()
759
+ break
760
+ if line.lower() == "help":
761
+ skin.help(_repl_commands)
762
+ continue
763
+
764
+ # Parse and execute command
765
+ args = line.split()
766
+ try:
767
+ cli.main(args, standalone_mode=False)
768
+ except SystemExit:
769
+ pass
770
+ except click.exceptions.UsageError as e:
771
+ skin.warning(f"Usage error: {e}")
772
+ except Exception as e:
773
+ skin.error(f"{e}")
774
+
775
+ except (EOFError, KeyboardInterrupt):
776
+ skin.print_goodbye()
777
+ break
778
+
779
+ _repl_mode = False
780
+
781
+
782
+ # ── Entry Point ──────────────────────────────────────────────────
783
+ def main():
784
+ cli()
785
+
786
+
787
+ if __name__ == "__main__":
788
+ main()