add auto-save and dry-run for all session-based softwares on one-shot cmds; highlight the design in cli-anything skill

This commit is contained in:
yuhao
2026-04-13 17:32:06 +00:00
parent 1b90df1b7f
commit 18f75791f3
16 changed files with 280 additions and 35 deletions
@@ -121,8 +121,10 @@ def handle_error(func):
@click.option("--json", "use_json", is_flag=True, help="Output as JSON")
@click.option("--project", "project_path", type=str, default=None,
help="Path to .audacity-cli.json project file")
@click.option("--dry-run", "dry_run", is_flag=True, default=False,
help="Run command without saving changes to disk")
@click.pass_context
def cli(ctx, use_json, project_path):
def cli(ctx, use_json, project_path, dry_run):
"""Audacity CLI — Stateful audio editing from the command line.
Run without a subcommand to enter interactive REPL mode.
@@ -140,6 +142,21 @@ def cli(ctx, use_json, project_path):
ctx.invoke(repl, project_path=None)
@cli.result_callback()
def auto_save_on_exit(result, use_json, project_path, dry_run, **kwargs):
"""Auto-save project after one-shot commands if state was modified."""
if _repl_mode:
return
if dry_run:
return
sess = get_session()
if sess.has_project() and sess._modified and sess.project_path:
try:
sess.save_session()
except Exception as e:
click.echo(f"Warning: Auto-save failed: {e}", err=True)
# -- Project Commands ------------------------------------------------------
@cli.group()
def project():
@@ -118,8 +118,10 @@ def handle_error(func):
@click.option("--json", "use_json", is_flag=True, help="Output as JSON")
@click.option("--project", "project_path", type=str, default=None,
help="Path to .blend-cli.json project file")
@click.option("--dry-run", "dry_run", is_flag=True, default=False,
help="Run command without saving changes to disk")
@click.pass_context
def cli(ctx, use_json, project_path):
def cli(ctx, use_json, project_path, dry_run):
"""Blender CLI — Stateful 3D scene editing from the command line.
Run without a subcommand to enter interactive REPL mode.
@@ -137,6 +139,21 @@ def cli(ctx, use_json, project_path):
ctx.invoke(repl, project_path=None)
@cli.result_callback()
def auto_save_on_exit(result, use_json, project_path, dry_run, **kwargs):
"""Auto-save project after one-shot commands if state was modified."""
if _repl_mode:
return
if dry_run:
return
sess = get_session()
if sess.has_project() and sess._modified and sess.project_path:
try:
sess.save_session()
except Exception as e:
click.echo(f"Warning: Auto-save failed: {e}", err=True)
# ── Scene Commands ──────────────────────────────────────────────
@cli.group()
def scene():
@@ -119,6 +119,12 @@ This command implements the complete cli-anything methodology to build a product
/cli-anything https://github.com/blender/blender
```
## Auto-Save + --dry-run (Required for Session-Based CLIs)
**Session-based CLIs must auto-save after one-shot mutations.** Without this, one-shot commands silently lose changes because `save_session()` is never called before the process exits. A `--dry-run` flag must also be provided to suppress the save.
See [`guides/auto-save-dry-run.md`](../guides/auto-save-dry-run.md) for the full pattern, code examples, and when it applies.
## Success Criteria
The command succeeds when:
@@ -132,3 +138,4 @@ The command succeeds when:
8. SKILL.md is generated with proper YAML frontmatter and command documentation
9. setup.py is created and local installation works
10. CLI is available in PATH as `cli-anything-<software>`
11. **Session-based CLIs implement auto-save + `--dry-run`** (see [guide](../guides/auto-save-dry-run.md))
@@ -0,0 +1,87 @@
# Auto-Save + --dry-run for One-Shot Commands
Session-based CLIs must auto-save after one-shot mutations and support `--dry-run` to suppress it.
## Problem
One-shot commands like `cli-anything-kdenlive --project p.json bin import video.mp4` mutate the in-memory project but never call `save_session()`. The project file on disk is unchanged when the process exits — changes are silently lost.
## Solution
Two additions to `<software>_cli.py`:
### 1. Add `--dry-run` to the main CLI group
```python
@click.group(invoke_without_command=True)
@click.option("--json", "use_json", is_flag=True, help="Output as JSON")
@click.option("--project", "project_path", type=str, default=None,
help="Path to project file")
@click.option("--dry-run", "dry_run", is_flag=True, default=False,
help="Run command without saving changes to disk")
@click.pass_context
def cli(ctx, use_json, project_path, dry_run):
...
```
### 2. Add `@cli.result_callback()` after the group
```python
@cli.result_callback()
def auto_save_on_exit(result, use_json, project_path, dry_run, **kwargs):
"""Auto-save project after one-shot commands if state was modified."""
if _repl_mode:
return
if dry_run:
return
sess = get_session()
if sess.has_project() and sess._modified and sess.project_path:
try:
sess.save_session()
except Exception as e:
click.echo(f"Warning: Auto-save failed: {e}", err=True)
```
**How it works:**
- `result_callback` fires once after the CLI group's command chain completes
- Checks `_repl_mode` (skip in REPL — user saves manually), `dry_run` (skip if set), and `sess._modified` (skip if nothing changed)
- Calls `sess.save_session()` which uses atomic `_locked_save_json` (see [`session-locking.md`](session-locking.md))
- Does NOT fire if `sys.exit(1)` was called in `handle_error` (error path) — correct behavior
### Alternative: `ctx.call_on_close` pattern
For harnesses that open the session inline (not via a global singleton), use a closure instead:
```python
def cli(ctx, use_json, project_path, dry_run):
...
if project_path:
sess = get_session()
proj = proj_mod.open_project(project_path)
sess.set_project(proj, project_path)
def _auto_save():
if dry_run:
return
if sess._modified and sess.project_path and not _repl_mode:
sess.save_session()
ctx.call_on_close(_auto_save)
```
## `--dry-run` semantics
| Mode | Behavior |
|------|----------|
| One-shot (default) | Command executes, output is printed, project is auto-saved |
| One-shot + `--dry-run` | Command executes, output is printed, project is **not** saved |
| REPL | `--dry-run` is accepted but ignored (REPL never auto-saves) |
## When this applies
**Required** for any harness where:
- `core/session.py` exists with `save_session()` and `_modified` tracking
- The CLI accepts a `--project` flag to load a file-backed project
- Commands call `sess.snapshot()` before mutations
**Does not apply** to stateless API clients, service wrappers, or harnesses without a persistent project file.
@@ -130,8 +130,10 @@ def handle_error(func):
@click.option("--json", "json_mode", is_flag=True, help="Output in JSON format")
@click.option("--session", "session_id", default=None, help="Session ID to use/resume")
@click.option("--project", "project_path", default=None, help="Open a project file")
@click.option("--dry-run", "dry_run", is_flag=True, default=False,
help="Run command without saving changes to disk")
@click.pass_context
def cli(ctx, json_mode, session_id, project_path):
def cli(ctx, json_mode, session_id, project_path, dry_run):
"""Draw.io CLI — Diagram creation from the command line.
A stateful CLI for manipulating draw.io diagram files.
@@ -153,6 +155,8 @@ def cli(ctx, json_mode, session_id, project_path):
# Auto-save on exit when --project was used and project was modified
@ctx.call_on_close
def _auto_save():
if dry_run:
return
if project_path and _session and _session.is_open and _session.is_modified:
_session.save_project()
@@ -171,8 +171,10 @@ output_fn = output
@click.group(invoke_without_command=True)
@click.option("--json", "use_json", is_flag=True, help="Output in JSON format.")
@click.option("--project", "-p", type=click.Path(), help="Load project file.")
@click.option("--dry-run", "dry_run", is_flag=True, default=False,
help="Run command without saving changes to disk")
@click.pass_context
def cli(ctx: click.Context, use_json: bool, project: Optional[str]) -> None:
def cli(ctx: click.Context, use_json: bool, project: Optional[str], dry_run: bool) -> None:
"""cli-anything-freecad — CLI harness for FreeCAD 3D CAD modeler."""
global _json_output
_json_output = use_json
@@ -184,6 +186,8 @@ def cli(ctx: click.Context, use_json: bool, project: Optional[str]) -> None:
# Auto-save after one-shot commands when --project is used
def _auto_save():
if dry_run:
return
if sess._modified and sess.project_path and not _repl_mode:
sess.save_session()
@@ -118,8 +118,10 @@ def handle_error(func):
@click.option("--json", "use_json", is_flag=True, help="Output as JSON")
@click.option("--project", "project_path", type=str, default=None,
help="Path to .gimp-cli.json project file")
@click.option("--dry-run", "dry_run", is_flag=True, default=False,
help="Run command without saving changes to disk")
@click.pass_context
def cli(ctx, use_json, project_path):
def cli(ctx, use_json, project_path, dry_run):
"""GIMP CLI — Stateful image editing from the command line.
Run without a subcommand to enter interactive REPL mode.
@@ -138,12 +140,18 @@ def cli(ctx, use_json, project_path):
@cli.result_callback()
def auto_save_on_cli(result, **kwargs):
"""Auto-save project after CLI commands when --project is specified."""
if not _repl_mode:
sess = get_session()
if sess.has_project() and sess._modified and sess.project_path:
proj_mod.save_project(sess.get_project(), sess.project_path)
def auto_save_on_exit(result, use_json, project_path, dry_run, **kwargs):
"""Auto-save project after one-shot commands if state was modified."""
if _repl_mode:
return
if dry_run:
return
sess = get_session()
if sess.has_project() and sess._modified and sess.project_path:
try:
sess.save_session()
except Exception as e:
click.echo(f"Warning: Auto-save failed: {e}", err=True)
# ── Project Commands ─────────────────────────────────────────────
@@ -40,6 +40,7 @@ _session: Optional[Session] = None
_json_output = False
_repl_mode = False
_auto_save = False
_dry_run = False
def get_session() -> Session:
@@ -123,8 +124,10 @@ def handle_error(func):
help="Path to .inkscape-cli.json project file")
@click.option("-s", "--save", "auto_save", is_flag=True,
help="Auto-save project after each mutation command (one-shot mode)")
@click.option("--dry-run", "dry_run", is_flag=True, default=False,
help="Run command without saving changes to disk")
@click.pass_context
def cli(ctx, use_json, project_path, auto_save):
def cli(ctx, use_json, project_path, auto_save, dry_run):
"""Inkscape CLI — Stateful vector graphics editing from the command line.
Run without a subcommand to enter interactive REPL mode.
@@ -132,9 +135,10 @@ def cli(ctx, use_json, project_path, auto_save):
Use -s/--save to automatically save changes after each mutation command.
This is useful in one-shot mode where each command runs in a new process.
"""
global _json_output, _auto_save
global _json_output, _auto_save, _dry_run
_json_output = use_json
_auto_save = auto_save
_dry_run = dry_run
if project_path:
sess = get_session()
@@ -151,7 +155,9 @@ def cli(ctx, use_json, project_path, auto_save):
def _auto_save_callback():
"""Auto-save callback that runs after each command."""
global _auto_save, _session
global _auto_save, _session, _dry_run
if _dry_run:
return
if _auto_save and _session and _session.has_project() and _session._modified:
# Don't auto-save if we're in REPL mode (user can explicitly save)
if not _repl_mode:
@@ -125,8 +125,10 @@ def parse_time(value: str) -> float:
@click.option("--json", "use_json", is_flag=True, help="Output as JSON")
@click.option("--project", "project_path", type=str, default=None,
help="Path to .kdenlive-cli.json project file")
@click.option("--dry-run", "dry_run", is_flag=True, default=False,
help="Run command without saving changes to disk")
@click.pass_context
def cli(ctx, use_json, project_path):
def cli(ctx, use_json, project_path, dry_run):
"""Kdenlive CLI — Stateful video editing from the command line.
Run without a subcommand to enter interactive REPL mode.
@@ -144,6 +146,21 @@ def cli(ctx, use_json, project_path):
ctx.invoke(repl, project_path=None)
@cli.result_callback()
def auto_save_on_exit(result, use_json, project_path, dry_run, **kwargs):
"""Auto-save project after one-shot commands if state was modified."""
if _repl_mode:
return
if dry_run:
return
sess = get_session()
if sess.has_project() and sess._modified and sess.project_path:
try:
sess.save_session()
except Exception as e:
click.echo(f"Warning: Auto-save failed: {e}", err=True)
# ── Project Commands ────────────────────────────────────────────
@cli.group()
def project():
@@ -113,12 +113,25 @@ def _save_current(ctx: click.Context) -> None:
@click.group(invoke_without_command=True)
@click.option("--json", "use_json", is_flag=True, default=False, help="Output in JSON format.")
@click.option("--project", "-p", type=click.Path(), default=None, help="Path to project JSON file.")
@click.option("--dry-run", "dry_run", is_flag=True, default=False,
help="Run command without saving changes to disk")
@click.pass_context
def cli(ctx, use_json, project):
def cli(ctx, use_json, project, dry_run):
"""cli-anything-krita: CLI harness for Krita digital painting."""
ctx.ensure_object(dict)
ctx.obj["json"] = use_json
ctx.obj["project"] = project
# Auto-save after one-shot commands when --project is used
is_oneshot = ctx.invoked_subcommand is not None
@ctx.call_on_close
def _auto_save():
if dry_run or not is_oneshot:
return
if _current_project and _current_project_path:
save_project(_current_project, _current_project_path)
if ctx.invoked_subcommand is None:
ctx.invoke(repl, project_path=project)
@@ -117,8 +117,10 @@ def handle_error(func):
@click.option("--json", "use_json", is_flag=True, help="Output as JSON")
@click.option("--project", "project_path", type=str, default=None,
help="Path to .lo-cli.json project file")
@click.option("--dry-run", "dry_run", is_flag=True, default=False,
help="Run command without saving changes to disk")
@click.pass_context
def cli(ctx, use_json, project_path):
def cli(ctx, use_json, project_path, dry_run):
"""LibreOffice CLI -- Stateful document editing from the command line.
Run without a subcommand to enter interactive REPL mode.
@@ -136,6 +138,21 @@ def cli(ctx, use_json, project_path):
ctx.invoke(repl, project_path=None)
@cli.result_callback()
def auto_save_on_exit(result, use_json, project_path, dry_run, **kwargs):
"""Auto-save project after one-shot commands if state was modified."""
if _repl_mode:
return
if dry_run:
return
sess = get_session()
if sess.has_project() and sess._modified and sess.project_path:
try:
sess.save_session()
except Exception as e:
click.echo(f"Warning: Auto-save failed: {e}", err=True)
# ── Document Commands ────────────────────────────────────────────
@cli.group()
def document():
@@ -40,8 +40,10 @@ def emit(data, message: str | None = None) -> None:
@click.group(invoke_without_command=True)
@click.option("--json", "json_mode", is_flag=True, help="Emit machine-readable JSON")
@click.option("--project", "project_path", default=None, help="Open a Mermaid project file")
@click.option("--dry-run", "dry_run", is_flag=True, default=False,
help="Run command without saving changes to disk")
@click.pass_context
def cli(ctx, json_mode: bool, project_path: str | None) -> None:
def cli(ctx, json_mode: bool, project_path: str | None, dry_run: bool) -> None:
"""CLI harness for Mermaid Live Editor state files and renderer URLs."""
global _json_output, _session
_json_output = json_mode
@@ -51,6 +53,8 @@ def cli(ctx, json_mode: bool, project_path: str | None) -> None:
@ctx.call_on_close
def _auto_save() -> None:
if dry_run:
return
if project_path and _session and _session.is_open and _session.modified:
_session.save_project()
@@ -98,8 +98,10 @@ def handle_error(func):
@click.option("--json", "use_json", is_flag=True, help="Output as JSON")
@click.option("--project", "project_path", type=str, default=None,
help="Path to score file to open")
@click.option("--dry-run", "dry_run", is_flag=True, default=False,
help="Run command without saving changes to disk")
@click.pass_context
def cli(ctx, use_json, project_path):
def cli(ctx, use_json, project_path, dry_run):
"""MuseScore CLI — Music notation from the command line.
Run without a subcommand to enter interactive REPL mode.
@@ -117,6 +119,21 @@ def cli(ctx, use_json, project_path):
ctx.invoke(repl)
@cli.result_callback()
def auto_save_on_exit(result, use_json, project_path, dry_run, **kwargs):
"""Auto-save project after one-shot commands if state was modified."""
if _repl_mode:
return
if dry_run:
return
sess = get_session()
if sess.has_project() and sess._modified and sess.project_path:
try:
sess.save_session()
except Exception as e:
click.echo(f"Warning: Auto-save failed: {e}", err=True)
# ── Project Commands ──────────────────────────────────────────────────
@cli.group()
@@ -118,8 +118,10 @@ def handle_error(func):
@click.option("--json", "use_json", is_flag=True, help="Output as JSON")
@click.option("--project", "project_path", type=str, default=None,
help="Path to OBS scene collection JSON file")
@click.option("--dry-run", "dry_run", is_flag=True, default=False,
help="Run command without saving changes to disk")
@click.pass_context
def cli(ctx, use_json, project_path):
def cli(ctx, use_json, project_path, dry_run):
"""OBS Studio CLI -- Stateful scene collection editing from the command line.
Run without a subcommand to enter interactive REPL mode.
@@ -137,6 +139,21 @@ def cli(ctx, use_json, project_path):
ctx.invoke(repl, project_path=None)
@cli.result_callback()
def auto_save_on_exit(result, use_json, project_path, dry_run, **kwargs):
"""Auto-save project after one-shot commands if state was modified."""
if _repl_mode:
return
if dry_run:
return
sess = get_session()
if sess.has_project() and sess._modified and sess.project_path:
try:
sess.save_session()
except Exception as e:
click.echo(f"Warning: Auto-save failed: {e}", err=True)
# -- Project Commands --------------------------------------------------------
@cli.group()
def project():
@@ -26,6 +26,7 @@ _session: Optional[Session] = None
_json_output = False
_repl_mode = False
_auto_save = False
_dry_run = False
# ── Output helpers ────────────────────────────────────────────────────────
@@ -115,8 +116,10 @@ def handle_error(func):
@click.option("--session", "session_id", default=None, help="Session ID to resume")
@click.option("--project", "project_path", default=None, help="Open project on start")
@click.option("-s", "--save", "auto_save", is_flag=True, help="Auto-save after mutations")
@click.option("--dry-run", "dry_run", is_flag=True, default=False,
help="Run command without saving changes to disk")
@click.pass_context
def cli(ctx, json_mode, session_id, project_path, auto_save):
def cli(ctx, json_mode, session_id, project_path, auto_save, dry_run):
"""Openscreen CLI — Screen recording editor.
Edit screen recordings via command line: zoom, speed, trim, crop,
@@ -124,9 +127,10 @@ def cli(ctx, json_mode, session_id, project_path, auto_save):
Run without a subcommand to enter REPL mode.
"""
global _session, _json_output, _auto_save
global _session, _json_output, _auto_save, _dry_run
_json_output = json_mode
_auto_save = auto_save
_dry_run = dry_run
_session = Session(session_id)
if project_path:
@@ -212,7 +216,7 @@ def project_set(key, value):
pass
result = proj_mod.set_setting(_session, key, value)
output(result)
if _auto_save and _session.project_path:
if _auto_save and not _dry_run and _session.project_path:
_session.save_project()
@@ -246,7 +250,7 @@ def zoom_add(start, end, depth, focus_x, focus_y, focus_mode):
_session, start, end, depth, focus_x, focus_y, focus_mode
)
output(result, "Zoom region added")
if _auto_save and _session.project_path:
if _auto_save and not _dry_run and _session.project_path:
_session.save_project()
@@ -257,7 +261,7 @@ def zoom_remove(region_id):
"""Remove a zoom region by ID."""
result = tl_mod.remove_zoom_region(_session, region_id)
output(result)
if _auto_save and _session.project_path:
if _auto_save and not _dry_run and _session.project_path:
_session.save_project()
@@ -286,7 +290,7 @@ def speed_add(start, end, spd):
"""Add a speed region."""
result = tl_mod.add_speed_region(_session, start, end, spd)
output(result, "Speed region added")
if _auto_save and _session.project_path:
if _auto_save and not _dry_run and _session.project_path:
_session.save_project()
@@ -297,7 +301,7 @@ def speed_remove(region_id):
"""Remove a speed region by ID."""
result = tl_mod.remove_speed_region(_session, region_id)
output(result)
if _auto_save and _session.project_path:
if _auto_save and not _dry_run and _session.project_path:
_session.save_project()
@@ -325,7 +329,7 @@ def trim_add(start, end):
"""Add a trim region (cuts this section out)."""
result = tl_mod.add_trim_region(_session, start, end)
output(result, "Trim region added")
if _auto_save and _session.project_path:
if _auto_save and not _dry_run and _session.project_path:
_session.save_project()
@@ -364,7 +368,7 @@ def crop_set(x, y, w, h):
"""Set crop region (normalized 0-1 coordinates)."""
result = tl_mod.set_crop(_session, x, y, w, h)
output(result, "Crop updated")
if _auto_save and _session.project_path:
if _auto_save and not _dry_run and _session.project_path:
_session.save_project()
@@ -400,7 +404,7 @@ def annotation_add_text(start, end, text, x, y, font_size, color, bg_color):
_session, start, end, text, x, y, font_size, color, bg_color
)
output(result, "Annotation added")
if _auto_save and _session.project_path:
if _auto_save and not _dry_run and _session.project_path:
_session.save_project()
@@ -115,6 +115,7 @@ def handle_error(func):
_repl_mode = False
_auto_save = False
_dry_run = False
# ============================================================================
@@ -125,23 +126,26 @@ _auto_save = False
@click.option("--json", "json_mode", is_flag=True, help="Output in JSON format")
@click.option("--session", "session_id", default=None, help="Session ID to use/resume")
@click.option("--project", "project_path", default=None, help="Open a project file")
@click.option("-s", "--save", "auto_save", is_flag=True,
@click.option("-s", "--save", "auto_save", is_flag=True,
help="Auto-save project after each mutation command (one-shot mode)")
@click.option("--dry-run", "dry_run", is_flag=True, default=False,
help="Run command without saving changes to disk")
@click.pass_context
def cli(ctx, json_mode, session_id, project_path, auto_save):
def cli(ctx, json_mode, session_id, project_path, auto_save, dry_run):
"""Shotcut CLI — Video editing from the command line.
A stateful CLI for manipulating Shotcut/MLT video projects.
Designed for AI agents and power users.
Run without a subcommand to enter interactive REPL mode.
Use -s/--save to automatically save changes after each mutation command.
This is useful in one-shot mode where each command runs in a new process.
"""
global _json_output, _session, _auto_save
global _json_output, _session, _auto_save, _dry_run
_json_output = json_mode
_auto_save = auto_save
_dry_run = dry_run
if session_id:
_session = Session(session_id)
@@ -160,7 +164,9 @@ def cli(ctx, json_mode, session_id, project_path, auto_save):
def _auto_save_callback():
"""Auto-save callback that runs after each command."""
global _auto_save, _session
global _auto_save, _session, _dry_run
if _dry_run:
return
if _auto_save and _session and _session.is_open and _session.is_modified:
# Don't auto-save if we're in REPL mode (user can explicitly save)
if not _repl_mode: