From 803dfd6c76db7d4645c7f337e476ce1807359db3 Mon Sep 17 00:00:00 2001 From: sehawq Date: Sun, 15 Mar 2026 18:46:45 +0300 Subject: [PATCH 01/22] Docs: clarify OpenCode command directory --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 7cff8bca9..9337d0fef 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,8 @@ cp CLI-Anything/opencode-commands/*.md .opencode/commands/ cp CLI-Anything/cli-anything-plugin/HARNESS.md .opencode/commands/ ``` +> **Note:** Some OpenCode builds use `~/.config/opencode/command/` (singular) instead of `commands/`. If `commands/` does not exist, use `command/` for both global and project-level installs. + > **Note:** `HARNESS.md` is the methodology spec that all commands reference. It must be in the same directory as the commands. This adds 5 slash commands: `/cli-anything`, `/cli-anything-refine`, `/cli-anything-test`, `/cli-anything-validate`, and `/cli-anything-list`. From 24082fe2b76b24dfb8b351d9658a3dfc7becc4e1 Mon Sep 17 00:00:00 2001 From: "J. Courtland Colburn" Date: Mon, 16 Mar 2026 18:07:51 -0400 Subject: [PATCH 02/22] chore: add .worktrees/ to .gitignore --- shotcut/agent-harness/.gitignore | 1 + 1 file changed, 1 insertion(+) create mode 100644 shotcut/agent-harness/.gitignore diff --git a/shotcut/agent-harness/.gitignore b/shotcut/agent-harness/.gitignore new file mode 100644 index 000000000..e458ed577 --- /dev/null +++ b/shotcut/agent-harness/.gitignore @@ -0,0 +1 @@ +.worktrees/ From 05876bdf5d92465f3e02965cc0551f0d7a71ca11 Mon Sep 17 00:00:00 2001 From: "J. Courtland Colburn" Date: Mon, 16 Mar 2026 18:12:20 -0400 Subject: [PATCH 03/22] fix(shotcut): set tractor out= before melt render to avoid 4-hour output When clips are added to the timeline, the MLT tractor element is created with out="00:00:00.000" and never updated. The background track defaults to out="04:00:00.000" (a Shotcut convention). Without an explicit tractor out, melt falls back to the longest track and renders a ~4-hour file instead of the actual content duration. Fix: calculate the real content duration from all non-background playlists (summing entry in/out spans and blank lengths), then set out= on the tractor, background playlist entry, and black producer before writing the temp MLT. Co-Authored-By: Claude Sonnet 4.6 --- .../cli_anything/shotcut/core/export.py | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/shotcut/agent-harness/cli_anything/shotcut/core/export.py b/shotcut/agent-harness/cli_anything/shotcut/core/export.py index d396a1c65..610fe861a 100644 --- a/shotcut/agent-harness/cli_anything/shotcut/core/export.py +++ b/shotcut/agent-harness/cli_anything/shotcut/core/export.py @@ -6,6 +6,7 @@ import shutil from typing import Optional from ..utils import mlt_xml +from ..utils.time import timecode_to_frames, frames_to_timecode from .session import Session @@ -411,6 +412,57 @@ def render(session: Session, output_path: str, width, height) +def _set_tractor_out(session: Session) -> None: + """Set tractor out= to actual timeline duration before passing to melt. + + The tractor is created with out="00:00:00.000" and never updated as clips + are added. Without this fix, melt falls back to the longest track in the + multitrack — the 4-hour black background — and renders a 4-hour file. + """ + profile = session.get_profile() + fps_num = int(profile.get("frame_rate_num", 30000)) + fps_den = int(profile.get("frame_rate_den", 1001)) + + tractor = mlt_xml.get_main_tractor(session.root) + if tractor is None: + return + + tracks = mlt_xml.get_tractor_tracks(tractor) + max_frames = 0 + for te in tracks: + prod_id = te.get("producer", "") + if prod_id == "background": + continue + playlist = mlt_xml.find_element_by_id(session.root, prod_id) + if playlist is None: + continue + total = 0 + for child in playlist: + if child.tag == "entry": + in_f = timecode_to_frames(child.get("in", "0"), fps_num, fps_den) + out_f = timecode_to_frames(child.get("out", "0"), fps_num, fps_den) + total += max(0, out_f - in_f + 1) + elif child.tag == "blank": + try: + total += timecode_to_frames(child.get("length", "0"), fps_num, fps_den) + except Exception: + pass + max_frames = max(max_frames, total) + + if max_frames > 0: + out_tc = frames_to_timecode(max_frames - 1, fps_num, fps_den) + tractor.set("out", out_tc) + # Cap the background track to the same duration so melt doesn't + # extend the render to the 4-hour background default. + bg_playlist = mlt_xml.find_element_by_id(session.root, "background") + if bg_playlist is not None: + for entry in bg_playlist.findall("entry"): + entry.set("out", out_tc) + black_producer = session.root.find(".//producer[@id='black']") + if black_producer is not None: + black_producer.set("out", out_tc) + + def _render_with_melt(session: Session, output_path: str, preset: dict, melt_path: str, width: Optional[int], height: Optional[int], @@ -418,6 +470,10 @@ def _render_with_melt(session: Session, output_path: str, """Render using melt command.""" import tempfile + # Fix tractor out before rendering — without this melt renders the full + # 4-hour background track instead of the actual content duration. + _set_tractor_out(session) + # Save project to temp file with tempfile.NamedTemporaryFile(suffix=".mlt", delete=False, mode="w") as f: temp_mlt = f.name From 0c620b954300e86781b4c1715b2fbfa34e4e933c Mon Sep 17 00:00:00 2001 From: sehawq Date: Wed, 8 Apr 2026 17:23:02 +0300 Subject: [PATCH 04/22] test(shotcut): add unit tests for _set_tractor_out() Cover single clip, multi-segment with blanks, and empty timeline cases to verify tractor/background/black producer out= is set correctly. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../cli_anything/shotcut/tests/test_core.py | 68 ++++++++++++++++++- 1 file changed, 67 insertions(+), 1 deletion(-) diff --git a/shotcut/agent-harness/cli_anything/shotcut/tests/test_core.py b/shotcut/agent-harness/cli_anything/shotcut/tests/test_core.py index 4ed8fc144..14ff6c180 100644 --- a/shotcut/agent-harness/cli_anything/shotcut/tests/test_core.py +++ b/shotcut/agent-harness/cli_anything/shotcut/tests/test_core.py @@ -25,8 +25,10 @@ from cli_anything.shotcut.utils.mlt_xml import ( create_blank_project, mlt_to_string, parse_mlt, write_mlt, get_property, set_property, get_main_tractor, get_tractor_tracks, get_all_producers, get_playlist_entries, find_element_by_id, - add_filter_to_element, + add_filter_to_element, add_track_to_tractor, add_entry_to_playlist, + add_blank_to_playlist, ) +from cli_anything.shotcut.utils.time import frames_to_timecode as _ftc # ============================================================================ @@ -689,6 +691,70 @@ class TestExport: finally: os.unlink(tmpfile) + def test_set_tractor_out_single_clip(self): + """_set_tractor_out sets tractor out to match a single clip duration.""" + s = Session() + proj_mod.new_project(s, "hd1080p30") + tractor = get_main_tractor(s.root) + playlist_id, _ = add_track_to_tractor(s.root, tractor, "video", "V1") + playlist = find_element_by_id(s.root, playlist_id) + + # Add a 6-second clip (frames 0–179 at 29.97fps ≈ 180 frames) + prod = s.root.makeelement("producer", {"id": "clip1"}) + s.root.insert(0, prod) + add_entry_to_playlist(playlist, "clip1", "00:00:00.000", "00:00:05.999") + + # Before fix: tractor out is still the initial value + assert tractor.get("out") == "00:00:00.000" + + export_mod._set_tractor_out(s) + + # After fix: tractor out should reflect the clip duration + assert tractor.get("out") != "00:00:00.000" + assert tractor.get("out") != "04:00:00.000" + # Background entry should be capped too + bg = find_element_by_id(s.root, "background") + bg_entry = bg.find("entry") + assert bg_entry.get("out") == tractor.get("out") + # Black producer should be capped + black = s.root.find(".//producer[@id='black']") + assert black.get("out") == tractor.get("out") + + def test_set_tractor_out_multi_segment(self): + """_set_tractor_out sums entry spans and blanks across segments.""" + s = Session() + proj_mod.new_project(s, "hd1080p30") + tractor = get_main_tractor(s.root) + pid, _ = add_track_to_tractor(s.root, tractor, "video", "V1") + playlist = find_element_by_id(s.root, pid) + + # Two 3-second clips with a 1-second blank between + prod1 = s.root.makeelement("producer", {"id": "seg1"}) + prod2 = s.root.makeelement("producer", {"id": "seg2"}) + s.root.insert(0, prod1) + s.root.insert(0, prod2) + add_entry_to_playlist(playlist, "seg1", "00:00:00.000", "00:00:02.999") + add_blank_to_playlist(playlist, "00:00:01.000") + add_entry_to_playlist(playlist, "seg2", "00:00:00.000", "00:00:02.999") + + export_mod._set_tractor_out(s) + + # Tractor out should cover all segments + blank (~7 seconds) + out_tc = tractor.get("out") + assert out_tc != "00:00:00.000" + assert out_tc != "04:00:00.000" + + def test_set_tractor_out_empty_timeline(self): + """_set_tractor_out is a no-op on a blank project with no clips.""" + s = Session() + proj_mod.new_project(s, "hd1080p30") + tractor = get_main_tractor(s.root) + + export_mod._set_tractor_out(s) + + # No clips → tractor out should stay unchanged + assert tractor.get("out") == "00:00:00.000" + # ============================================================================ # Integration: full workflow From 90881789c510fe7fb607c86e66d05a635f688245 Mon Sep 17 00:00:00 2001 From: sehawq Date: Wed, 8 Apr 2026 17:27:23 +0300 Subject: [PATCH 05/22] docs: merge OpenCode command directory notes --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 9337d0fef..4415c5134 100644 --- a/README.md +++ b/README.md @@ -154,7 +154,7 @@ cp CLI-Anything/opencode-commands/*.md .opencode/commands/ cp CLI-Anything/cli-anything-plugin/HARNESS.md .opencode/commands/ ``` -> **Note:** Some OpenCode builds use `~/.config/opencode/command/` (singular) instead of `commands/`. If `commands/` does not exist, use `command/` for both global and project-level installs. +> **Note:** Please upgrade to the latest OpenCode. Older versions use `command/` (singular) instead of `commands/`. If `commands/` does not exist, use `command/` for both global and project-level installs. > **Note:** `HARNESS.md` is the methodology spec that all commands reference. It must be in the same directory as the commands. From 5952147376c69ec88d8942a9531c8886501cef49 Mon Sep 17 00:00:00 2001 From: sjhddh Date: Wed, 8 Apr 2026 18:55:47 +0200 Subject: [PATCH 06/22] fix: handle empty skill_intro in skill_description generation When a harness has no README.md, skill_intro is an empty string. The previous code unconditionally appended " - ..." producing malformed output like "Command-line interface for TestApp - ..." in SKILL.md YAML frontmatter. Also fixed: when skill_intro is non-empty but <=100 chars, no ellipsis is appended (previously appended to complete text). Applied the same fix to both: - cli-anything-plugin/skill_generator.py (line 118) - mubu/agent-harness/skill_generator.py (line 238) Updated test_harness_without_readme to assert skill_description is well-formed when intro is empty. Co-Authored-By: Claude Sonnet 4.6 --- cli-anything-plugin/skill_generator.py | 13 ++++++++++--- cli-anything-plugin/tests/test_skill_generator.py | 3 +++ mubu/agent-harness/skill_generator.py | 11 +++++++++-- 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/cli-anything-plugin/skill_generator.py b/cli-anything-plugin/skill_generator.py index ecf14c089..de2d9a6a7 100644 --- a/cli-anything-plugin/skill_generator.py +++ b/cli-anything-plugin/skill_generator.py @@ -115,7 +115,12 @@ def extract_cli_metadata(harness_path: str) -> SkillMetadata: # Build skill name and description skill_name = f"cli-anything-{software_name}" - skill_description = f"Command-line interface for {_format_display_name(software_name)} - {skill_intro[:100]}..." + if skill_intro: + intro_snippet = skill_intro[:100] + suffix = "..." if len(skill_intro) > 100 else "" + skill_description = f"Command-line interface for {_format_display_name(software_name)} - {intro_snippet}{suffix}" + else: + skill_description = f"Command-line interface for {_format_display_name(software_name)}" return SkillMetadata( skill_name=skill_name, @@ -159,14 +164,16 @@ def extract_system_package(content: str) -> Optional[str]: patterns = [ r"`apt install ([\w\-]+)`", r"`brew install ([\w\-]+)`", - r"apt-get install ([\w\-]+)", + r"`apt-get install ([\w\-]+)`", ] for pattern in patterns: match = re.search(pattern, content) if match: package = match.group(1) - if "apt" in pattern: + if "apt-get" in pattern: + return f"apt-get install {package}" + elif "apt" in pattern: return f"apt install {package}" elif "brew" in pattern: return f"brew install {package}" diff --git a/cli-anything-plugin/tests/test_skill_generator.py b/cli-anything-plugin/tests/test_skill_generator.py index dec23e868..b3cc25304 100644 --- a/cli-anything-plugin/tests/test_skill_generator.py +++ b/cli-anything-plugin/tests/test_skill_generator.py @@ -326,6 +326,9 @@ class TestEdgeCases: assert metadata.skill_intro == "" # No README → empty intro assert metadata.version == "1.0.0" assert metadata.command_groups == [] + # skill_description must not contain trailing " - ..." when intro is empty + assert " - " not in metadata.skill_description + assert not metadata.skill_description.endswith("...") def test_harness_with_system_package(self, tmp_path): """README with apt install instructions should extract system_package.""" diff --git a/mubu/agent-harness/skill_generator.py b/mubu/agent-harness/skill_generator.py index 3e1a62749..7f9327892 100644 --- a/mubu/agent-harness/skill_generator.py +++ b/mubu/agent-harness/skill_generator.py @@ -76,13 +76,15 @@ def extract_system_package(content: str) -> Optional[str]: patterns = [ r"`apt install ([\w\-]+)`", r"`brew install ([\w\-]+)`", - r"apt-get install ([\w\-]+)", + r"`apt-get install ([\w\-]+)`", ] for pattern in patterns: match = re.search(pattern, content) if match: package = match.group(1) + if "apt-get" in pattern: + return f"apt-get install {package}" if "apt" in pattern: return f"apt install {package}" if "brew" in pattern: @@ -235,7 +237,12 @@ def extract_cli_metadata(harness_path: str) -> SkillMetadata: command_groups = extract_commands_from_cli(cli_file) if cli_file.exists() else [] examples = generate_examples(software_name, command_groups) skill_name = f"cli-anything-{software_name}" - skill_description = f"Command-line interface for {_format_display_name(software_name)} - {skill_intro[:100]}..." + if skill_intro: + intro_snippet = skill_intro[:100] + suffix = "..." if len(skill_intro) > 100 else "" + skill_description = f"Command-line interface for {_format_display_name(software_name)} - {intro_snippet}{suffix}" + else: + skill_description = f"Command-line interface for {_format_display_name(software_name)}" return SkillMetadata( skill_name=skill_name, From 1a93cce183b0ce0bb129c8b68afeae52a24bb383 Mon Sep 17 00:00:00 2001 From: sjhddh Date: Sun, 12 Apr 2026 23:34:47 +0200 Subject: [PATCH 07/22] refactor: remove extract_system_package changes (split to #204) Co-Authored-By: Claude Opus 4.6 --- cli-anything-plugin/skill_generator.py | 6 ++---- mubu/agent-harness/skill_generator.py | 4 +--- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/cli-anything-plugin/skill_generator.py b/cli-anything-plugin/skill_generator.py index de2d9a6a7..fe7cc7907 100644 --- a/cli-anything-plugin/skill_generator.py +++ b/cli-anything-plugin/skill_generator.py @@ -164,16 +164,14 @@ def extract_system_package(content: str) -> Optional[str]: patterns = [ r"`apt install ([\w\-]+)`", r"`brew install ([\w\-]+)`", - r"`apt-get install ([\w\-]+)`", + r"apt-get install ([\w\-]+)", ] for pattern in patterns: match = re.search(pattern, content) if match: package = match.group(1) - if "apt-get" in pattern: - return f"apt-get install {package}" - elif "apt" in pattern: + if "apt" in pattern: return f"apt install {package}" elif "brew" in pattern: return f"brew install {package}" diff --git a/mubu/agent-harness/skill_generator.py b/mubu/agent-harness/skill_generator.py index 7f9327892..a1c070f7c 100644 --- a/mubu/agent-harness/skill_generator.py +++ b/mubu/agent-harness/skill_generator.py @@ -76,15 +76,13 @@ def extract_system_package(content: str) -> Optional[str]: patterns = [ r"`apt install ([\w\-]+)`", r"`brew install ([\w\-]+)`", - r"`apt-get install ([\w\-]+)`", + r"apt-get install ([\w\-]+)", ] for pattern in patterns: match = re.search(pattern, content) if match: package = match.group(1) - if "apt-get" in pattern: - return f"apt-get install {package}" if "apt" in pattern: return f"apt install {package}" if "brew" in pattern: From 8bcc93f14077ce6092b3f75bd0cd1283f8a2f2b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E5=AE=B6=E5=90=8D?= Date: Thu, 16 Apr 2026 11:54:58 +0800 Subject: [PATCH 08/22] docs: correct SKILL.md contribution paths --- CONTRIBUTING.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1e3dae366..7b6bc79be 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,7 +15,7 @@ Adding a new CLI harness is the most impactful contribution. You can either add Place your code under `/agent-harness/` and ensure the following: 1. **`.md`** — the SOP document exists at `/agent-harness/.md` describing the harness architecture. -2. **`SKILL.md`** — the AI-discoverable skill definition exists inside the Python package at `cli_anything//SKILL.md`. +2. **`SKILL.md`** — the AI-discoverable skill definition exists inside the Python package at `cli_anything//skills/SKILL.md`. 3. **Tests** — unit tests (`test_core.py`, passable without backend) and E2E tests (`test_full_e2e.py`) are present and passing. 4. **`README.md`** — the project README includes the new software with a link to its harness directory. 5. **`registry.json`** — add an entry for the new software (see [Registry fields](#registry-fields) below). @@ -30,7 +30,7 @@ Requirements for standalone CLIs: 1. **Published package** — your CLI must be installable via `pip install ` (PyPI) or a `pip install git+https://...` URL. 2. **`SKILL.md`** — an AI-discoverable skill definition exists somewhere in your repo. 3. **Tests** — your repo should have its own test suite. -4. **`registry.json`** — add an entry with `source_url` pointing to your repo and `skill_md` pointing to the raw URL of your SKILL.md (see [Registry fields](#registry-fields) below). +4. **`registry.json`** — add an entry with `source_url` pointing to your repo and `skill_md` pointing to the full URL of your SKILL.md (see [Registry fields](#registry-fields) below). ### B) New Features From 4fb063c297e626377fc0b73d799e3360e0e2423d Mon Sep 17 00:00:00 2001 From: yanmingyu92 Date: Thu, 16 Apr 2026 21:11:29 -0500 Subject: [PATCH 09/22] feat: Add py4csr to public registry --- public_registry.json | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/public_registry.json b/public_registry.json index 2bcaa8998..dec2f6965 100644 --- a/public_registry.json +++ b/public_registry.json @@ -238,6 +238,25 @@ "url": "https://obsidian.md" } ] + }, + { + "name": "py4csr", + "display_name": "TraceCSR / Py4CSR CLI", + "version": "latest", + "description": "GxP-compliant agent harness for CDISC Clinical Study Report (CSR) and Tables/Figures/Listings (TFL) generation", + "category": "data-science", + "requires": "Python >= 3.10", + "homepage": "https://github.com/yanmingyu92/py4csr", + "source_url": "https://github.com/yanmingyu92/py4csr", + "package_manager": "pip", + "install_cmd": "pip install py4csr", + "entry_point": "tracecsr", + "contributors": [ + { + "name": "yanmingyu92", + "url": "https://github.com/yanmingyu92" + } + ] } ] } From ab65e0a69100a07e87bdf8486aeb215cfd994b78 Mon Sep 17 00:00:00 2001 From: haorui-harry <2224882012@qq.com> Date: Fri, 17 Apr 2026 16:20:48 +0800 Subject: [PATCH 10/22] feat(openclaw): add OpenClaw Macro System agent-harness Implements the full 7-layer architecture described in the OpenClaw Macro System spec as a working CLI-Anything agent harness: - cli-anything-openclaw CLI (L6 Unified CLI Entry) macro run/list/info/validate/dry-run/define session status/history/save/list backends (shows routing availability) Interactive REPL (default) - MacroRuntime (L5): validate params -> check preconditions -> execute steps -> check postconditions -> collect outputs -> telemetry - MacroDefinition + YAML loader (L4): parameterized, composable, self-verifying macro definitions stored in macro_definitions/ - RoutingEngine (L3): selects backend by priority (native_api=100, gui_macro=80, file_transform=70, semantic_ui=50, recovery=10) - Execution Backends (L2): native_api -- subprocess / shell commands file_transform -- XML, JSON, text file editing semantic_ui -- accessibility + keyboard shortcuts (xdotool on Linux) gui_macro -- precompiled coordinate-based replay (pyautogui) recovery -- retry + fallback orchestration - ExecutionSession: run history, telemetry, disk persistence 64/64 tests passing (test_core.py + test_full_e2e.py with real CLI subprocess) --- .../cli_anything/openclaw/README.md | 75 +++ .../cli_anything/openclaw/__init__.py | 1 + .../cli_anything/openclaw/__main__.py | 5 + .../openclaw/backends/__init__.py | 0 .../cli_anything/openclaw/backends/base.py | 89 +++ .../openclaw/backends/file_transform.py | 203 ++++++ .../openclaw/backends/gui_macro.py | 205 ++++++ .../openclaw/backends/native_api.py | 167 +++++ .../openclaw/backends/recovery.py | 128 ++++ .../openclaw/backends/semantic_ui.py | 202 ++++++ .../cli_anything/openclaw/core/__init__.py | 0 .../cli_anything/openclaw/core/macro_model.py | 342 ++++++++++ .../cli_anything/openclaw/core/registry.py | 164 +++++ .../cli_anything/openclaw/core/routing.py | 113 ++++ .../cli_anything/openclaw/core/runtime.py | 306 +++++++++ .../cli_anything/openclaw/core/session.py | 186 ++++++ .../examples/export_file.yaml | 63 ++ .../examples/transform_json.yaml | 52 ++ .../macro_definitions/examples/undo_last.yaml | 37 ++ .../openclaw/macro_definitions/manifest.yaml | 10 + .../cli_anything/openclaw/openclaw_cli.py | 572 +++++++++++++++++ .../cli_anything/openclaw/skills/SKILL.md | 220 +++++++ .../cli_anything/openclaw/tests/__init__.py | 0 .../cli_anything/openclaw/tests/test_core.py | 595 ++++++++++++++++++ .../openclaw/tests/test_full_e2e.py | 369 +++++++++++ .../cli_anything/openclaw/utils/__init__.py | 0 .../cli_anything/openclaw/utils/repl_skin.py | 521 +++++++++++++++ openclaw-skill/agent-harness/setup.py | 58 ++ 28 files changed, 4683 insertions(+) create mode 100644 openclaw-skill/agent-harness/cli_anything/openclaw/README.md create mode 100644 openclaw-skill/agent-harness/cli_anything/openclaw/__init__.py create mode 100644 openclaw-skill/agent-harness/cli_anything/openclaw/__main__.py create mode 100644 openclaw-skill/agent-harness/cli_anything/openclaw/backends/__init__.py create mode 100644 openclaw-skill/agent-harness/cli_anything/openclaw/backends/base.py create mode 100644 openclaw-skill/agent-harness/cli_anything/openclaw/backends/file_transform.py create mode 100644 openclaw-skill/agent-harness/cli_anything/openclaw/backends/gui_macro.py create mode 100644 openclaw-skill/agent-harness/cli_anything/openclaw/backends/native_api.py create mode 100644 openclaw-skill/agent-harness/cli_anything/openclaw/backends/recovery.py create mode 100644 openclaw-skill/agent-harness/cli_anything/openclaw/backends/semantic_ui.py create mode 100644 openclaw-skill/agent-harness/cli_anything/openclaw/core/__init__.py create mode 100644 openclaw-skill/agent-harness/cli_anything/openclaw/core/macro_model.py create mode 100644 openclaw-skill/agent-harness/cli_anything/openclaw/core/registry.py create mode 100644 openclaw-skill/agent-harness/cli_anything/openclaw/core/routing.py create mode 100644 openclaw-skill/agent-harness/cli_anything/openclaw/core/runtime.py create mode 100644 openclaw-skill/agent-harness/cli_anything/openclaw/core/session.py create mode 100644 openclaw-skill/agent-harness/cli_anything/openclaw/macro_definitions/examples/export_file.yaml create mode 100644 openclaw-skill/agent-harness/cli_anything/openclaw/macro_definitions/examples/transform_json.yaml create mode 100644 openclaw-skill/agent-harness/cli_anything/openclaw/macro_definitions/examples/undo_last.yaml create mode 100644 openclaw-skill/agent-harness/cli_anything/openclaw/macro_definitions/manifest.yaml create mode 100644 openclaw-skill/agent-harness/cli_anything/openclaw/openclaw_cli.py create mode 100644 openclaw-skill/agent-harness/cli_anything/openclaw/skills/SKILL.md create mode 100644 openclaw-skill/agent-harness/cli_anything/openclaw/tests/__init__.py create mode 100644 openclaw-skill/agent-harness/cli_anything/openclaw/tests/test_core.py create mode 100644 openclaw-skill/agent-harness/cli_anything/openclaw/tests/test_full_e2e.py create mode 100644 openclaw-skill/agent-harness/cli_anything/openclaw/utils/__init__.py create mode 100644 openclaw-skill/agent-harness/cli_anything/openclaw/utils/repl_skin.py create mode 100644 openclaw-skill/agent-harness/setup.py diff --git a/openclaw-skill/agent-harness/cli_anything/openclaw/README.md b/openclaw-skill/agent-harness/cli_anything/openclaw/README.md new file mode 100644 index 000000000..195869bdb --- /dev/null +++ b/openclaw-skill/agent-harness/cli_anything/openclaw/README.md @@ -0,0 +1,75 @@ +# OpenClaw Macro System + +**OpenClaw Macro System** is a layered CLI that converts GUI workflows into +parameterized, agent-callable macros. Agents call `macro run ` through +a stable CLI; the system routes execution to the right backend (native plugin, +file transform, semantic UI, or compiled GUI replay) — invisible to the agent. + +## Installation + +```bash +pip install -e . +``` + +**Dependencies:** Python 3.10+, PyYAML, click, prompt-toolkit + +## Usage + +```bash +# List available macros +cli-anything-openclaw macro list --json + +# Inspect a macro +cli-anything-openclaw macro info export_file --json + +# Execute a macro +cli-anything-openclaw macro run transform_json \ + --param file=/tmp/config.json \ + --param key=theme --param value=dark --json + +# Dry run +cli-anything-openclaw --dry-run macro run export_file \ + --param output=/tmp/out.txt --json + +# Interactive REPL +cli-anything-openclaw +``` + +## Run Tests + +```bash +cd openclaw-skill/agent-harness +pip install -e ".[dev]" +python -m pytest cli_anything/openclaw/tests/ -v -s +``` + +## Architecture + +``` +cli-anything-openclaw (CLI) + └─▶ macro run --param key=value + │ + MacroRuntime + │ validate params + │ check preconditions + │ for each step: + │ RoutingEngine → select backend + │ Backend.execute(step, params) + │ check postconditions + └─▶ ExecutionResult { success, output, telemetry } +``` + +**Backends:** +- `native_api` — subprocess / shell commands +- `file_transform` — XML, JSON, text file editing +- `semantic_ui` — accessibility controls + keyboard shortcuts +- `gui_macro` — precompiled coordinate-based replay +- `recovery` — retry + fallback orchestration + +## Adding a Macro + +1. Create `cli_anything/openclaw/macro_definitions/my_macro.yaml` +2. Add it to `macro_definitions/manifest.yaml` +3. Verify: `cli-anything-openclaw macro validate my_macro --json` + +See `skills/SKILL.md` (installed with the package) for full macro YAML schema. diff --git a/openclaw-skill/agent-harness/cli_anything/openclaw/__init__.py b/openclaw-skill/agent-harness/cli_anything/openclaw/__init__.py new file mode 100644 index 000000000..58d9139ae --- /dev/null +++ b/openclaw-skill/agent-harness/cli_anything/openclaw/__init__.py @@ -0,0 +1 @@ +# cli_anything/openclaw package diff --git a/openclaw-skill/agent-harness/cli_anything/openclaw/__main__.py b/openclaw-skill/agent-harness/cli_anything/openclaw/__main__.py new file mode 100644 index 000000000..720ddd9e5 --- /dev/null +++ b/openclaw-skill/agent-harness/cli_anything/openclaw/__main__.py @@ -0,0 +1,5 @@ +"""Enable: python3 -m cli_anything.openclaw""" +from cli_anything.openclaw.openclaw_cli import cli + +if __name__ == "__main__": + cli() diff --git a/openclaw-skill/agent-harness/cli_anything/openclaw/backends/__init__.py b/openclaw-skill/agent-harness/cli_anything/openclaw/backends/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/openclaw-skill/agent-harness/cli_anything/openclaw/backends/base.py b/openclaw-skill/agent-harness/cli_anything/openclaw/backends/base.py new file mode 100644 index 000000000..1b22b2537 --- /dev/null +++ b/openclaw-skill/agent-harness/cli_anything/openclaw/backends/base.py @@ -0,0 +1,89 @@ +"""Backend base classes and result types. + +All execution backends inherit from Backend and return StepResult. +""" + +from __future__ import annotations + +import time +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from typing import Any, Optional + + +@dataclass +class StepResult: + """Result of a single macro step execution.""" + success: bool + output: dict = field(default_factory=dict) + error: str = "" + duration_ms: float = 0.0 + backend_used: str = "" + + def to_dict(self) -> dict: + return { + "success": self.success, + "output": self.output, + "error": self.error, + "duration_ms": self.duration_ms, + "backend_used": self.backend_used, + } + + +class BackendContext: + """Runtime context passed to each backend during step execution.""" + + def __init__( + self, + params: dict, + previous_results: Optional[list[StepResult]] = None, + dry_run: bool = False, + timeout_ms: int = 30_000, + ): + self.params = params + self.previous_results: list[StepResult] = previous_results or [] + self.dry_run = dry_run + self.timeout_ms = timeout_ms + self._start = time.time() + + def elapsed_ms(self) -> float: + return (time.time() - self._start) * 1000 + + +class Backend(ABC): + """Abstract base class for all execution backends. + + Concrete backends implement execute() and return a StepResult. + """ + + name: str = "base" + priority: int = 0 + + @abstractmethod + def execute( + self, + step: "MacroStep", # type: ignore[name-defined] + params: dict, + context: BackendContext, + ) -> StepResult: + """Execute a macro step. + + Args: + step: The MacroStep definition being executed. + params: Fully resolved (substituted) parameters. + context: Runtime context with previous results and flags. + + Returns: + StepResult describing success/failure and captured output. + """ + + def is_available(self) -> bool: + """Return True if this backend can be used in the current environment.""" + return True + + def describe(self) -> dict: + return { + "name": self.name, + "priority": self.priority, + "available": self.is_available(), + } diff --git a/openclaw-skill/agent-harness/cli_anything/openclaw/backends/file_transform.py b/openclaw-skill/agent-harness/cli_anything/openclaw/backends/file_transform.py new file mode 100644 index 000000000..879110149 --- /dev/null +++ b/openclaw-skill/agent-harness/cli_anything/openclaw/backends/file_transform.py @@ -0,0 +1,203 @@ +"""FileTransformBackend — read, transform, and write project files. + +Supports XML (ElementTree), JSON, and plain text transformations. + +Example macro step: + + - backend: file_transform + action: json_set + params: + input_file: ${project_file} + output_file: ${project_file} + path: settings.grid_size + value: 20 + + - backend: file_transform + action: xml_set_attr + params: + input_file: diagram.drawio + output_file: diagram.drawio + xpath: .//mxCell[@id='1'] + attr: style + value: rounded=1; + + - backend: file_transform + action: text_replace + params: + input_file: config.ini + output_file: config.ini + find: "theme=default" + replace: "theme=dark" +""" + +from __future__ import annotations + +import json +import os +import time +from pathlib import Path + +from cli_anything.openclaw.backends.base import Backend, BackendContext, StepResult +from cli_anything.openclaw.core.macro_model import MacroStep, substitute + + +class FileTransformBackend(Backend): + """Transform project files without invoking the target application.""" + + name = "file_transform" + priority = 70 + + def execute(self, step: MacroStep, params: dict, context: BackendContext) -> StepResult: + t0 = time.time() + action = step.action + step_params = substitute(step.params, params) + + if context.dry_run: + return StepResult( + success=True, + output={"dry_run": True, "action": action}, + backend_used=self.name, + duration_ms=(time.time() - t0) * 1000, + ) + + dispatch = { + "json_get": self._json_get, + "json_set": self._json_set, + "json_delete": self._json_delete, + "xml_set_attr": self._xml_set_attr, + "xml_get_attr": self._xml_get_attr, + "text_replace": self._text_replace, + "copy_file": self._copy_file, + } + + handler = dispatch.get(action) + if handler is None: + return StepResult( + success=False, + error=f"FileTransformBackend: unknown action '{action}'.", + backend_used=self.name, + duration_ms=(time.time() - t0) * 1000, + ) + + try: + output = handler(step_params) + return StepResult( + success=True, + output=output or {}, + backend_used=self.name, + duration_ms=(time.time() - t0) * 1000, + ) + except Exception as exc: + return StepResult( + success=False, + error=f"FileTransformBackend.{action}: {exc}", + backend_used=self.name, + duration_ms=(time.time() - t0) * 1000, + ) + + # ── JSON actions ───────────────────────────────────────────────────── + + def _json_get(self, p: dict) -> dict: + """Read a value from a JSON file by dot-path.""" + data = self._load_json(p["input_file"]) + val = self._dotpath_get(data, p["path"]) + return {"value": val, "path": p["path"]} + + def _json_set(self, p: dict) -> dict: + """Set a value in a JSON file by dot-path and write it back.""" + path = p.get("path", "") + value = p["value"] + data = self._load_json(p["input_file"]) if Path(p["input_file"]).is_file() else {} + self._dotpath_set(data, path, value) + self._save_json(p.get("output_file", p["input_file"]), data) + return {"path": path, "value": value} + + def _json_delete(self, p: dict) -> dict: + """Delete a key from a JSON file by dot-path.""" + data = self._load_json(p["input_file"]) + self._dotpath_delete(data, p["path"]) + self._save_json(p.get("output_file", p["input_file"]), data) + return {"deleted": p["path"]} + + # ── XML actions ────────────────────────────────────────────────────── + + def _xml_set_attr(self, p: dict) -> dict: + """Set an XML element attribute matched by XPath.""" + from xml.etree import ElementTree as ET + tree = ET.parse(p["input_file"]) + root = tree.getroot() + elements = root.findall(p["xpath"]) + if not elements: + raise ValueError(f"XPath matched nothing: {p['xpath']}") + for el in elements: + el.set(p["attr"], str(p["value"])) + tree.write(p.get("output_file", p["input_file"]), encoding="unicode", xml_declaration=True) + return {"matched": len(elements), "attr": p["attr"]} + + def _xml_get_attr(self, p: dict) -> dict: + """Get an XML element attribute matched by XPath.""" + from xml.etree import ElementTree as ET + tree = ET.parse(p["input_file"]) + root = tree.getroot() + elements = root.findall(p["xpath"]) + values = [el.get(p["attr"]) for el in elements] + return {"values": values, "attr": p["attr"]} + + # ── Text actions ───────────────────────────────────────────────────── + + def _text_replace(self, p: dict) -> dict: + """Simple find-and-replace in a text file.""" + content = Path(p["input_file"]).read_text(encoding="utf-8") + count = content.count(p["find"]) + content = content.replace(p["find"], p["replace"]) + out = p.get("output_file", p["input_file"]) + Path(out).write_text(content, encoding="utf-8") + return {"replacements": count} + + def _copy_file(self, p: dict) -> dict: + """Copy a file from src to dst.""" + import shutil + shutil.copy2(p["src"], p["dst"]) + size = os.path.getsize(p["dst"]) + return {"src": p["src"], "dst": p["dst"], "size": size} + + # ── Helpers ────────────────────────────────────────────────────────── + + def _load_json(self, path: str) -> dict: + with open(path, encoding="utf-8") as f: + return json.load(f) + + def _save_json(self, path: str, data: dict) -> None: + Path(path).parent.mkdir(parents=True, exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2, ensure_ascii=False) + + def _dotpath_get(self, data: dict, path: str): + keys = path.split(".") + cur = data + for k in keys: + if isinstance(cur, dict) and k in cur: + cur = cur[k] + else: + return None + return cur + + def _dotpath_set(self, data: dict, path: str, value) -> None: + keys = path.split(".") + cur = data + for k in keys[:-1]: + if k not in cur or not isinstance(cur[k], dict): + cur[k] = {} + cur = cur[k] + cur[keys[-1]] = value + + def _dotpath_delete(self, data: dict, path: str) -> None: + keys = path.split(".") + cur = data + for k in keys[:-1]: + if isinstance(cur, dict) and k in cur: + cur = cur[k] + else: + return + if isinstance(cur, dict) and keys[-1] in cur: + del cur[keys[-1]] diff --git a/openclaw-skill/agent-harness/cli_anything/openclaw/backends/gui_macro.py b/openclaw-skill/agent-harness/cli_anything/openclaw/backends/gui_macro.py new file mode 100644 index 000000000..3dcbe9eca --- /dev/null +++ b/openclaw-skill/agent-harness/cli_anything/openclaw/backends/gui_macro.py @@ -0,0 +1,205 @@ +"""GUIMacroBackend — replay precompiled coordinate-based macro sequences. + +A compiled macro is a JSON blob describing an exact sequence of mouse clicks, +key presses, and wait conditions. These are fast to execute but fragile to +layout changes. + +Compiled macro format (stored separately, referenced by step params): + + { + "version": 1, + "screen_resolution": "1920x1080", + "layout_hash": "abc123", + "steps": [ + {"type": "click", "x": 100, "y": 200, "button": "left", "delay_ms": 200}, + {"type": "key", "keys": "ctrl+s", "delay_ms": 100}, + {"type": "type", "text": "output.png", "delay_ms": 50}, + {"type": "wait_file", "path": "/tmp/out.png", "timeout_ms": 5000}, + {"type": "sleep", "ms": 500} + ] + } + +Example macro step: + + - backend: gui_macro + action: replay + params: + macro_file: macros/compiled/export_png.json + layout_strict: false # if true, fail when screen res changes +""" + +from __future__ import annotations + +import json +import time +from pathlib import Path + +from cli_anything.openclaw.backends.base import Backend, BackendContext, StepResult +from cli_anything.openclaw.core.macro_model import MacroStep, substitute + + +class GUIMacroBackend(Backend): + """Replay precompiled GUI automation sequences.""" + + name = "gui_macro" + priority = 80 + + def execute(self, step: MacroStep, params: dict, context: BackendContext) -> StepResult: + t0 = time.time() + action = step.action + step_params = substitute(step.params, params) + + if context.dry_run: + return StepResult( + success=True, + output={"dry_run": True, "action": action}, + backend_used=self.name, + duration_ms=(time.time() - t0) * 1000, + ) + + if action != "replay": + return StepResult( + success=False, + error=f"GUIMacroBackend: unknown action '{action}'. Expected 'replay'.", + backend_used=self.name, + duration_ms=(time.time() - t0) * 1000, + ) + + return self._replay(step_params, context, t0) + + def is_available(self) -> bool: + """Available when at least one automation library is present.""" + for lib in ("pyautogui", "pynput"): + try: + __import__(lib) + return True + except ImportError: + pass + return False + + def _replay(self, p: dict, context: BackendContext, t0: float) -> StepResult: + """Load and replay a compiled macro file.""" + macro_file = p.get("macro_file", "") + if not macro_file: + return StepResult( + success=False, + error="GUIMacroBackend.replay: 'macro_file' param is required.", + backend_used=self.name, + duration_ms=(time.time() - t0) * 1000, + ) + + macro_path = Path(macro_file) + if not macro_path.is_file(): + return StepResult( + success=False, + error=f"GUIMacroBackend: compiled macro not found: {macro_file}", + backend_used=self.name, + duration_ms=(time.time() - t0) * 1000, + ) + + try: + with open(macro_path, encoding="utf-8") as f: + macro_blob = json.load(f) + except Exception as exc: + return StepResult( + success=False, + error=f"GUIMacroBackend: failed to load macro file: {exc}", + backend_used=self.name, + duration_ms=(time.time() - t0) * 1000, + ) + + layout_strict: bool = p.get("layout_strict", False) + if layout_strict: + check = self._check_layout(macro_blob) + if check: + return StepResult( + success=False, + error=f"GUIMacroBackend: layout mismatch — {check}", + backend_used=self.name, + duration_ms=(time.time() - t0) * 1000, + ) + + try: + steps_run = self._execute_steps(macro_blob.get("steps", []), context) + return StepResult( + success=True, + output={"steps_executed": steps_run, "macro_file": macro_file}, + backend_used=self.name, + duration_ms=(time.time() - t0) * 1000, + ) + except Exception as exc: + return StepResult( + success=False, + error=f"GUIMacroBackend.replay: {exc}", + backend_used=self.name, + duration_ms=(time.time() - t0) * 1000, + ) + + def _check_layout(self, macro_blob: dict) -> str: + """Return error string if current screen doesn't match expected.""" + expected_res = macro_blob.get("screen_resolution", "") + if not expected_res: + return "" + try: + import pyautogui + w, h = pyautogui.size() + current_res = f"{w}x{h}" + if current_res != expected_res: + return f"screen is {current_res}, macro expects {expected_res}" + except ImportError: + pass # Can't verify — allow through + return "" + + def _execute_steps(self, steps: list, context: BackendContext) -> int: + """Execute each step in the compiled macro.""" + try: + import pyautogui + has_pyautogui = True + except ImportError: + has_pyautogui = False + + count = 0 + for s in steps: + stype = s.get("type", "") + delay = s.get("delay_ms", 100) / 1000.0 + + if stype == "click": + if not has_pyautogui: + raise ImportError("pyautogui required for click steps. pip install pyautogui") + button = s.get("button", "left") + pyautogui.click(s["x"], s["y"], button=button) + + elif stype == "key": + if not has_pyautogui: + raise ImportError("pyautogui required for key steps. pip install pyautogui") + keys = s.get("keys", "").split("+") + if len(keys) == 1: + pyautogui.press(keys[0]) + else: + pyautogui.hotkey(*keys) + + elif stype == "type": + if not has_pyautogui: + raise ImportError("pyautogui required for type steps. pip install pyautogui") + pyautogui.typewrite(s.get("text", ""), interval=0.03) + + elif stype == "wait_file": + deadline = time.time() + s.get("timeout_ms", 5000) / 1000.0 + path = s.get("path", "") + while time.time() < deadline: + if Path(path).exists(): + break + time.sleep(0.1) + else: + raise TimeoutError(f"wait_file timed out: {path}") + delay = 0 # no additional delay after file wait + + elif stype == "sleep": + time.sleep(s.get("ms", 500) / 1000.0) + delay = 0 + + if delay > 0: + time.sleep(delay) + count += 1 + + return count diff --git a/openclaw-skill/agent-harness/cli_anything/openclaw/backends/native_api.py b/openclaw-skill/agent-harness/cli_anything/openclaw/backends/native_api.py new file mode 100644 index 000000000..8cc2d5b20 --- /dev/null +++ b/openclaw-skill/agent-harness/cli_anything/openclaw/backends/native_api.py @@ -0,0 +1,167 @@ +"""NativeAPIBackend — executes macro steps via subprocess. + +Supports these action types (configured in macro step params): + + action: run_command + params: + command: [inkscape, --export-filename, /tmp/out.png, input.svg] + cwd: /optional/working/dir # optional + env: {KEY: value} # optional extra env vars + capture_stdout: true # store stdout in output.stdout + + action: find_executable + params: + name: inkscape + candidates: [inkscape, inkscape-1.0, /usr/bin/inkscape] + install_hint: "apt install inkscape" +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import time +from typing import Any + +from cli_anything.openclaw.backends.base import Backend, BackendContext, StepResult +from cli_anything.openclaw.core.macro_model import MacroStep, substitute + + +class NativeAPIBackend(Backend): + """Execute a macro step by running an external command.""" + + name = "native_api" + priority = 100 + + def execute(self, step: MacroStep, params: dict, context: BackendContext) -> StepResult: + t0 = time.time() + action = step.action + + if action == "find_executable": + return self._find_executable(step, params, context, t0) + elif action == "run_command": + return self._run_command(step, params, context, t0) + else: + return StepResult( + success=False, + error=f"NativeAPIBackend: unknown action '{action}'.", + backend_used=self.name, + duration_ms=(time.time() - t0) * 1000, + ) + + # ── Actions ────────────────────────────────────────────────────────── + + def _find_executable( + self, step: MacroStep, params: dict, context: BackendContext, t0: float + ) -> StepResult: + """Check that an executable exists; return its path.""" + step_params = substitute(step.params, params) + exe_name = step_params.get("name", "") + candidates: list[str] = step_params.get("candidates", [exe_name] if exe_name else []) + install_hint: str = step_params.get("install_hint", f"Install {exe_name}") + + for candidate in candidates: + found = shutil.which(candidate) + if found: + return StepResult( + success=True, + output={"executable": found, "name": candidate}, + backend_used=self.name, + duration_ms=(time.time() - t0) * 1000, + ) + + return StepResult( + success=False, + error=( + f"Executable not found: {exe_name}. " + f"Tried: {candidates}. " + f"Install with: {install_hint}" + ), + backend_used=self.name, + duration_ms=(time.time() - t0) * 1000, + ) + + def _run_command( + self, step: MacroStep, params: dict, context: BackendContext, t0: float + ) -> StepResult: + """Run an external command.""" + step_params = substitute(step.params, params) + command: list[str] = step_params.get("command", []) + if not command: + return StepResult( + success=False, + error="NativeAPIBackend.run_command: 'command' param is required.", + backend_used=self.name, + duration_ms=(time.time() - t0) * 1000, + ) + + if isinstance(command, str): + import shlex + command = shlex.split(command) + command = [str(c) for c in command] + + cwd: str = step_params.get("cwd", "") + extra_env: dict = step_params.get("env", {}) + capture_stdout: bool = step_params.get("capture_stdout", False) + + env = os.environ.copy() + if extra_env: + env.update({k: str(v) for k, v in extra_env.items()}) + + timeout_s = context.timeout_ms / 1000.0 + + if context.dry_run: + return StepResult( + success=True, + output={"dry_run": True, "command": command}, + backend_used=self.name, + duration_ms=(time.time() - t0) * 1000, + ) + + try: + result = subprocess.run( + command, + capture_output=True, + text=True, + timeout=timeout_s, + cwd=cwd or None, + env=env, + ) + except FileNotFoundError as exc: + return StepResult( + success=False, + error=f"Command not found: {command[0]}. {exc}", + backend_used=self.name, + duration_ms=(time.time() - t0) * 1000, + ) + except subprocess.TimeoutExpired: + return StepResult( + success=False, + error=f"Command timed out after {timeout_s:.0f}s: {command}", + backend_used=self.name, + duration_ms=(time.time() - t0) * 1000, + ) + + duration = (time.time() - t0) * 1000 + if result.returncode != 0: + return StepResult( + success=False, + error=( + f"Command failed (exit {result.returncode}): {command}\n" + f"stderr: {result.stderr.strip()}" + ), + output={"returncode": result.returncode, "stderr": result.stderr}, + backend_used=self.name, + duration_ms=duration, + ) + + output: dict[str, Any] = {"returncode": 0} + if capture_stdout: + output["stdout"] = result.stdout + return StepResult( + success=True, + output=output, + backend_used=self.name, + duration_ms=duration, + ) diff --git a/openclaw-skill/agent-harness/cli_anything/openclaw/backends/recovery.py b/openclaw-skill/agent-harness/cli_anything/openclaw/backends/recovery.py new file mode 100644 index 000000000..8af918e48 --- /dev/null +++ b/openclaw-skill/agent-harness/cli_anything/openclaw/backends/recovery.py @@ -0,0 +1,128 @@ +"""RecoveryBackend — retry and fallback orchestration. + +This backend wraps another backend and retries failed steps with exponential +backoff. It can also fall back to an alternative backend on exhausted retries. + +Example macro step using recovery explicitly: + + - backend: recovery + action: retry_with_fallback + params: + primary_backend: native_api + fallback_backend: file_transform + max_retries: 3 + backoff_ms: [1000, 2000, 5000] + step: + action: run_command + params: + command: [inkscape, --export-filename, ${output}, input.svg] + +The MacroRuntime also uses the RecoveryBackend automatically when a step +specifies retry_max > 0 in the macro definition. +""" + +from __future__ import annotations + +import time + +from cli_anything.openclaw.backends.base import Backend, BackendContext, StepResult +from cli_anything.openclaw.core.macro_model import MacroStep, substitute + + +class RecoveryBackend(Backend): + """Retry and fallback orchestration backend.""" + + name = "recovery" + priority = 10 # lowest — last resort + + def __init__(self, backends: dict[str, "Backend"] | None = None): + """ + Args: + backends: Dict of backend_name -> Backend instance. + Injected by the RoutingEngine at runtime. + """ + self._backends = backends or {} + + def register_backend(self, backend: "Backend") -> None: + self._backends[backend.name] = backend + + def execute(self, step: MacroStep, params: dict, context: BackendContext) -> StepResult: + t0 = time.time() + action = step.action + + if action not in ("retry", "retry_with_fallback"): + return StepResult( + success=False, + error=f"RecoveryBackend: unknown action '{action}'. " + "Use 'retry' or 'retry_with_fallback'.", + backend_used=self.name, + duration_ms=(time.time() - t0) * 1000, + ) + + step_params = substitute(step.params, params) + inner_step_raw = step_params.get("step", {}) + if not inner_step_raw: + return StepResult( + success=False, + error="RecoveryBackend: 'step' param is required.", + backend_used=self.name, + duration_ms=(time.time() - t0) * 1000, + ) + + # Build an inner MacroStep from the nested definition + inner_step = MacroStep( + id=inner_step_raw.get("id", "recovery_inner"), + backend=step_params.get("primary_backend", inner_step_raw.get("backend", "native_api")), + action=inner_step_raw.get("action", ""), + params=inner_step_raw.get("params", {}), + timeout_ms=context.timeout_ms, + ) + + max_retries: int = int(step_params.get("max_retries", step.retry_max or 2)) + backoff_ms: list[int] = step_params.get("backoff_ms", [1000, 2000, 5000]) + fallback_name: str = step_params.get("fallback_backend", "") + + last_result = None + for attempt in range(max_retries + 1): + backend = self._backends.get(inner_step.backend) + if backend is None: + return StepResult( + success=False, + error=f"RecoveryBackend: backend '{inner_step.backend}' not registered.", + backend_used=self.name, + duration_ms=(time.time() - t0) * 1000, + ) + + last_result = backend.execute(inner_step, params, context) + if last_result.success: + last_result.backend_used = f"{self.name}({inner_step.backend}, attempt={attempt + 1})" + return last_result + + # Failed — decide whether to retry or fall back + if attempt < max_retries: + wait = backoff_ms[min(attempt, len(backoff_ms) - 1)] / 1000.0 + time.sleep(wait) + elif fallback_name and fallback_name in self._backends: + # Switch to fallback backend for one final attempt + inner_step = MacroStep( + id=inner_step.id, + backend=fallback_name, + action=inner_step.action, + params=inner_step.params, + timeout_ms=inner_step.timeout_ms, + ) + fallback_result = self._backends[fallback_name].execute(inner_step, params, context) + if fallback_result.success: + fallback_result.backend_used = f"{self.name}(fallback={fallback_name})" + return fallback_result + last_result = fallback_result + + if last_result is None: + last_result = StepResult( + success=False, + error="RecoveryBackend: no attempts made.", + backend_used=self.name, + ) + last_result.duration_ms = (time.time() - t0) * 1000 + last_result.backend_used = self.name + return last_result diff --git a/openclaw-skill/agent-harness/cli_anything/openclaw/backends/semantic_ui.py b/openclaw-skill/agent-harness/cli_anything/openclaw/backends/semantic_ui.py new file mode 100644 index 000000000..46ff8bbe6 --- /dev/null +++ b/openclaw-skill/agent-harness/cli_anything/openclaw/backends/semantic_ui.py @@ -0,0 +1,202 @@ +"""SemanticUIBackend — drive applications via accessibility and keyboard shortcuts. + +This backend provides stubs for AT-SPI (Linux), Windows UIA, and macOS +accessibility APIs. Full implementations require platform-specific libraries +(pyatspi2, pywinauto, pyobjc-framework-Accessibility). + +Example macro step: + + - backend: semantic_ui + action: menu_click + params: + menu_path: [File, Export As, PNG] + + - backend: semantic_ui + action: shortcut + params: + keys: ctrl+shift+e + + - backend: semantic_ui + action: wait_for_window + params: + title_contains: Export + timeout_ms: 5000 +""" + +from __future__ import annotations + +import platform +import time + +from cli_anything.openclaw.backends.base import Backend, BackendContext, StepResult +from cli_anything.openclaw.core.macro_model import MacroStep, substitute + + +class SemanticUIBackend(Backend): + """Drive applications through semantic (accessibility) controls.""" + + name = "semantic_ui" + priority = 50 + + def execute(self, step: MacroStep, params: dict, context: BackendContext) -> StepResult: + t0 = time.time() + action = step.action + step_params = substitute(step.params, params) + + if context.dry_run: + return StepResult( + success=True, + output={"dry_run": True, "action": action}, + backend_used=self.name, + duration_ms=(time.time() - t0) * 1000, + ) + + dispatch = { + "shortcut": self._shortcut, + "menu_click": self._menu_click, + "wait_for_window": self._wait_for_window, + "button_click": self._button_click, + "type_text": self._type_text, + } + + handler = dispatch.get(action) + if handler is None: + return StepResult( + success=False, + error=f"SemanticUIBackend: unknown action '{action}'.", + backend_used=self.name, + duration_ms=(time.time() - t0) * 1000, + ) + + try: + output = handler(step_params) + return StepResult( + success=True, + output=output or {}, + backend_used=self.name, + duration_ms=(time.time() - t0) * 1000, + ) + except NotImplementedError as exc: + return StepResult( + success=False, + error=str(exc), + backend_used=self.name, + duration_ms=(time.time() - t0) * 1000, + ) + except Exception as exc: + return StepResult( + success=False, + error=f"SemanticUIBackend.{action}: {exc}", + backend_used=self.name, + duration_ms=(time.time() - t0) * 1000, + ) + + def is_available(self) -> bool: + # Available when at least one accessibility library can be found + if platform.system() == "Linux": + try: + import pyatspi # noqa: F401 + return True + except ImportError: + pass + elif platform.system() == "Windows": + try: + import pywinauto # noqa: F401 + return True + except ImportError: + pass + elif platform.system() == "Darwin": + try: + import ApplicationServices # noqa: F401 + return True + except ImportError: + pass + return False + + # ── Actions ────────────────────────────────────────────────────────── + + def _shortcut(self, p: dict) -> dict: + """Send a keyboard shortcut to the focused window.""" + keys: str = p.get("keys", "") + if not keys: + raise ValueError("shortcut action requires 'keys' param.") + + sys = platform.system() + if sys == "Linux": + return self._shortcut_xdotool(keys) + elif sys == "Windows": + return self._shortcut_win32(keys) + elif sys == "Darwin": + return self._shortcut_macos(keys) + raise NotImplementedError( + f"SemanticUIBackend.shortcut: not yet implemented for platform {sys}. " + "Consider using native_api backend instead." + ) + + def _shortcut_xdotool(self, keys: str) -> dict: + """Use xdotool to send keys on Linux.""" + import shutil + if not shutil.which("xdotool"): + raise NotImplementedError( + "xdotool not found. Install with: apt install xdotool" + ) + import subprocess + # Convert from ctrl+shift+e → ctrl+shift+e (xdotool format) + xdg_keys = keys.replace("+", " ") + subprocess.run(["xdotool", "key", xdg_keys], check=True) + return {"keys": keys, "method": "xdotool"} + + def _shortcut_win32(self, keys: str) -> dict: + raise NotImplementedError( + "SemanticUIBackend.shortcut: Windows requires pywinauto. " + "pip install pywinauto" + ) + + def _shortcut_macos(self, keys: str) -> dict: + raise NotImplementedError( + "SemanticUIBackend.shortcut: macOS requires pyobjc. " + "pip install pyobjc-framework-Quartz" + ) + + def _menu_click(self, p: dict) -> dict: + """Click a menu item by path.""" + menu_path: list = p.get("menu_path", []) + raise NotImplementedError( + f"SemanticUIBackend.menu_click: not yet implemented. " + f"Menu path: {menu_path}. " + "Use native_api or file_transform backends instead when possible." + ) + + def _wait_for_window(self, p: dict) -> dict: + """Wait for a window matching title criteria.""" + title_contains: str = p.get("title_contains", "") + timeout_ms: int = int(p.get("timeout_ms", 5000)) + raise NotImplementedError( + f"SemanticUIBackend.wait_for_window: not yet implemented. " + f"Looking for: '{title_contains}'. " + "Consider using postconditions (file_exists) to detect completion." + ) + + def _button_click(self, p: dict) -> dict: + """Click a UI button by label.""" + raise NotImplementedError( + f"SemanticUIBackend.button_click: not yet implemented. " + "Use native_api or file_transform backends instead when possible." + ) + + def _type_text(self, p: dict) -> dict: + """Type text into the focused input field.""" + text: str = p.get("text", "") + if not text: + raise ValueError("type_text action requires 'text' param.") + sys = platform.system() + if sys == "Linux": + import shutil + if not shutil.which("xdotool"): + raise NotImplementedError("xdotool not found. apt install xdotool") + import subprocess + subprocess.run(["xdotool", "type", "--clearmodifiers", text], check=True) + return {"text": text, "method": "xdotool"} + raise NotImplementedError( + f"SemanticUIBackend.type_text: not yet implemented for {sys}." + ) diff --git a/openclaw-skill/agent-harness/cli_anything/openclaw/core/__init__.py b/openclaw-skill/agent-harness/cli_anything/openclaw/core/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/openclaw-skill/agent-harness/cli_anything/openclaw/core/macro_model.py b/openclaw-skill/agent-harness/cli_anything/openclaw/core/macro_model.py new file mode 100644 index 000000000..b9b2a6edf --- /dev/null +++ b/openclaw-skill/agent-harness/cli_anything/openclaw/core/macro_model.py @@ -0,0 +1,342 @@ +"""Macro data model — parse and validate YAML macro definitions. + +A macro definition file (YAML) describes a reusable, parameterized workflow +that the MacroRuntime can execute against any backend. + +Example (minimal): + + name: export_file + version: "1.0" + description: Export a file using the target app's CLI. + + parameters: + output: + type: string + required: true + example: /tmp/out.png + + steps: + - backend: native_api + action: run_command + params: + command: [echo, "exported", "${output}"] + + postconditions: + - file_exists: ${output} +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Optional + +try: + import yaml +except ImportError as e: + raise ImportError("PyYAML is required: pip install PyYAML") from e + + +# ── Dataclasses ────────────────────────────────────────────────────────────── + +@dataclass +class MacroParameter: + name: str + type: str = "string" # string | integer | boolean | list | dict + required: bool = False + default: Any = None + description: str = "" + example: Any = None + enum: Optional[list] = None + min: Optional[float] = None + max: Optional[float] = None + + def validate_value(self, value: Any) -> list[str]: + """Return list of validation error strings (empty if valid).""" + errors: list[str] = [] + if value is None: + if self.required: + errors.append(f"Parameter '{self.name}' is required.") + return errors + + if self.type == "integer": + if not isinstance(value, int): + try: + value = int(value) + except (ValueError, TypeError): + errors.append(f"Parameter '{self.name}' must be an integer.") + return errors + if self.min is not None and value < self.min: + errors.append(f"Parameter '{self.name}' must be >= {self.min}.") + if self.max is not None and value > self.max: + errors.append(f"Parameter '{self.name}' must be <= {self.max}.") + + if self.enum and value not in self.enum: + errors.append( + f"Parameter '{self.name}' must be one of {self.enum}, got {value!r}." + ) + return errors + + +@dataclass +class MacroStep: + backend: str # native_api | file_transform | semantic_ui | gui_macro | recovery + action: str # backend-specific action name + id: str = "" + params: dict = field(default_factory=dict) + timeout_ms: int = 30_000 + on_failure: str = "fail" # fail | skip | continue + retry_max: int = 0 + retry_backoff_ms: list[int] = field(default_factory=lambda: [1000]) + + def to_dict(self) -> dict: + return { + "id": self.id, + "backend": self.backend, + "action": self.action, + "params": self.params, + "timeout_ms": self.timeout_ms, + "on_failure": self.on_failure, + "retry_max": self.retry_max, + "retry_backoff_ms": self.retry_backoff_ms, + } + + +@dataclass +class MacroCondition: + """A single pre- or post-condition check. + + Supported types (derived from the YAML key): + file_exists: + file_size_gt: [, ] + process_running: + env_var: + always: true | false + """ + type: str + args: Any # depends on type + + def to_dict(self) -> dict: + return {"type": self.type, "args": self.args} + + @classmethod + def from_dict(cls, d: dict) -> "MacroCondition": + """Parse a condition dict like {file_exists: /tmp/out.png}.""" + if not isinstance(d, dict) or len(d) != 1: + raise ValueError(f"Condition must be a single-key dict, got: {d!r}") + ctype, args = next(iter(d.items())) + return cls(type=ctype, args=args) + + +@dataclass +class MacroOutput: + name: str + description: str = "" + path: Optional[str] = None # raw template string (may contain ${...}) + value: Optional[Any] = None + + def to_dict(self) -> dict: + return { + "name": self.name, + "description": self.description, + "path": self.path, + "value": self.value, + } + + +@dataclass +class MacroDefinition: + name: str + version: str = "1.0" + description: str = "" + parameters: dict[str, MacroParameter] = field(default_factory=dict) + preconditions: list[MacroCondition] = field(default_factory=list) + steps: list[MacroStep] = field(default_factory=list) + postconditions: list[MacroCondition] = field(default_factory=list) + outputs: list[MacroOutput] = field(default_factory=list) + tags: list[str] = field(default_factory=list) + composable: bool = False + agent_hints: dict = field(default_factory=dict) + source_path: str = "" # absolute path to the .yaml file + + # ── Validation ──────────────────────────────────────────────────── + + def validate(self) -> list[str]: + """Structural validation — returns list of error strings.""" + errors: list[str] = [] + if not self.name: + errors.append("Macro name is required.") + if not self.steps: + errors.append(f"Macro '{self.name}' has no steps.") + valid_backends = {"native_api", "file_transform", "semantic_ui", "gui_macro", "recovery"} + for i, step in enumerate(self.steps): + if step.backend not in valid_backends: + errors.append( + f"Step {i} has unknown backend '{step.backend}'. " + f"Valid: {sorted(valid_backends)}" + ) + if not step.action: + errors.append(f"Step {i} (backend={step.backend}) is missing 'action'.") + for pname, pspec in self.parameters.items(): + if pspec.type not in ("string", "integer", "boolean", "list", "dict", "float"): + errors.append(f"Parameter '{pname}' has unknown type '{pspec.type}'.") + return errors + + def validate_params(self, params: dict) -> list[str]: + """Validate runtime parameter values against schema.""" + errors: list[str] = [] + for pname, pspec in self.parameters.items(): + value = params.get(pname, pspec.default) + errors.extend(pspec.validate_value(value)) + return errors + + def resolve_params(self, params: dict) -> dict: + """Return params with defaults filled in.""" + resolved = {} + for pname, pspec in self.parameters.items(): + resolved[pname] = params.get(pname, pspec.default) + # Pass through any extra params not in schema + for k, v in params.items(): + if k not in resolved: + resolved[k] = v + return resolved + + # ── Serialisation ───────────────────────────────────────────────── + + def to_dict(self) -> dict: + return { + "name": self.name, + "version": self.version, + "description": self.description, + "parameters": { + n: { + "type": p.type, + "required": p.required, + "default": p.default, + "description": p.description, + "example": p.example, + "enum": p.enum, + } + for n, p in self.parameters.items() + }, + "preconditions": [c.to_dict() for c in self.preconditions], + "steps": [s.to_dict() for s in self.steps], + "postconditions": [c.to_dict() for c in self.postconditions], + "outputs": [o.to_dict() for o in self.outputs], + "tags": self.tags, + "composable": self.composable, + "agent_hints": self.agent_hints, + "source_path": self.source_path, + } + + +# ── Substitution ───────────────────────────────────────────────────────────── + +_SUBST_RE = re.compile(r"\$\{([^}]+)\}") + + +def substitute(template: Any, params: dict) -> Any: + """Replace ${key} placeholders in strings (and nested structures). + + Works recursively on strings, lists, and dicts. + Leaves non-string types (int, bool, None) untouched. + """ + if isinstance(template, str): + def _replace(m: re.Match) -> str: + key = m.group(1).strip() + val = params.get(key) + return str(val) if val is not None else m.group(0) + return _SUBST_RE.sub(_replace, template) + if isinstance(template, list): + return [substitute(item, params) for item in template] + if isinstance(template, dict): + return {k: substitute(v, params) for k, v in template.items()} + return template + + +# ── YAML loader ─────────────────────────────────────────────────────────────── + +def _parse_parameter(name: str, raw: dict) -> MacroParameter: + return MacroParameter( + name=name, + type=raw.get("type", "string"), + required=raw.get("required", False), + default=raw.get("default"), + description=raw.get("description", ""), + example=raw.get("example"), + enum=raw.get("enum"), + min=raw.get("min"), + max=raw.get("max"), + ) + + +def _parse_step(i: int, raw: dict) -> MacroStep: + retry = raw.get("retry_policy", {}) or {} + return MacroStep( + id=raw.get("id", f"step_{i}"), + backend=raw.get("backend", "native_api"), + action=raw.get("action", ""), + params=raw.get("params", {}), + timeout_ms=int(raw.get("timeout_ms", raw.get("timeout", "30s") + .replace("s", "000") if isinstance(raw.get("timeout"), str) + else raw.get("timeout_ms", 30_000))), + on_failure=raw.get("on_failure", "fail"), + retry_max=retry.get("max_retries", raw.get("retry_max", 0)), + retry_backoff_ms=retry.get("backoff_ms", [1000]), + ) + + +def _parse_condition(raw: Any) -> MacroCondition: + if isinstance(raw, dict): + return MacroCondition.from_dict(raw) + raise ValueError(f"Cannot parse condition from {raw!r}") + + +def _parse_output(raw: dict) -> MacroOutput: + return MacroOutput( + name=raw.get("name", ""), + description=raw.get("description", ""), + path=raw.get("path"), + value=raw.get("value"), + ) + + +def load_from_yaml(path: str) -> MacroDefinition: + """Load and parse a macro definition from a YAML file.""" + p = Path(path) + if not p.is_file(): + raise FileNotFoundError(f"Macro file not found: {path}") + with open(p, encoding="utf-8") as f: + raw = yaml.safe_load(f) + if not isinstance(raw, dict): + raise ValueError(f"Macro YAML must be a mapping, got {type(raw).__name__}: {path}") + + parameters: dict[str, MacroParameter] = {} + for pname, praw in (raw.get("parameters") or {}).items(): + if isinstance(praw, dict): + parameters[pname] = _parse_parameter(pname, praw) + else: + # shorthand: parameter_name: string + parameters[pname] = MacroParameter(name=pname, type=str(praw)) + + steps = [_parse_step(i, s) for i, s in enumerate(raw.get("steps") or [])] + preconditions = [_parse_condition(c) for c in (raw.get("preconditions") or [])] + postconditions = [_parse_condition(c) for c in (raw.get("postconditions") or [])] + outputs = [_parse_output(o) for o in (raw.get("outputs") or [])] + + macro = MacroDefinition( + name=raw.get("name", p.stem), + version=str(raw.get("version", "1.0")), + description=raw.get("description", ""), + parameters=parameters, + preconditions=preconditions, + steps=steps, + postconditions=postconditions, + outputs=outputs, + tags=raw.get("tags", []), + composable=raw.get("composable", False), + agent_hints=raw.get("agent_hints", {}), + source_path=str(p.resolve()), + ) + return macro diff --git a/openclaw-skill/agent-harness/cli_anything/openclaw/core/registry.py b/openclaw-skill/agent-harness/cli_anything/openclaw/core/registry.py new file mode 100644 index 000000000..8735af349 --- /dev/null +++ b/openclaw-skill/agent-harness/cli_anything/openclaw/core/registry.py @@ -0,0 +1,164 @@ +"""MacroRegistry — discovers and loads macro definitions from a directory. + +The registry scans a macros/ directory (and subdirectories) for *.yaml files, +optionally guided by a manifest.yaml index. + +Usage: + from cli_anything.openclaw.core.registry import MacroRegistry + registry = MacroRegistry("/path/to/macros") + macro = registry.load("export_file") + all_macros = registry.list_all() +""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import Optional + +try: + import yaml +except ImportError as e: + raise ImportError("PyYAML is required: pip install PyYAML") from e + +from cli_anything.openclaw.core.macro_model import MacroDefinition, load_from_yaml + + +class MacroRegistry: + """Discovers and caches macro definitions from a macros/ directory.""" + + def __init__(self, macros_dir: Optional[str] = None): + """ + Args: + macros_dir: Path to the directory containing macro YAML files. + Defaults to the macros/ directory bundled with the package. + """ + if macros_dir is None: + macros_dir = str(Path(__file__).resolve().parent.parent / "macro_definitions") + self.macros_dir = Path(macros_dir) + self._cache: dict[str, MacroDefinition] = {} + self._scanned = False + + # ── Internal scan ──────────────────────────────────────────────────── + + def _scan(self) -> None: + """Scan macros_dir and populate the cache.""" + if self._scanned: + return + + if not self.macros_dir.is_dir(): + self._scanned = True + return + + # Try manifest.yaml first (explicit ordered index) + manifest_path = self.macros_dir / "manifest.yaml" + if manifest_path.is_file(): + self._load_from_manifest(manifest_path) + else: + # Fallback: scan all *.yaml files recursively (except manifest.yaml) + for yaml_path in sorted(self.macros_dir.rglob("*.yaml")): + if yaml_path.name == "manifest.yaml": + continue + self._load_file(yaml_path) + + self._scanned = True + + def _load_from_manifest(self, manifest_path: Path) -> None: + """Load macros listed in manifest.yaml.""" + with open(manifest_path, encoding="utf-8") as f: + manifest = yaml.safe_load(f) or {} + + macros_list = manifest.get("macros", []) + for entry in macros_list: + if isinstance(entry, dict): + rel_path = entry.get("path") + else: + rel_path = str(entry) + if not rel_path: + continue + yaml_path = self.macros_dir / rel_path + if yaml_path.is_file(): + self._load_file(yaml_path) + + # Also scan for any yaml files NOT in the manifest (permissive) + listed_names = {m.name for m in self._cache.values()} + for yaml_path in sorted(self.macros_dir.rglob("*.yaml")): + if yaml_path.name == "manifest.yaml": + continue + try: + # Quick peek to get the name without full parse + with open(yaml_path, encoding="utf-8") as f: + raw = yaml.safe_load(f) or {} + name = raw.get("name", yaml_path.stem) + if name not in listed_names: + self._load_file(yaml_path) + except Exception: + pass + + def _load_file(self, yaml_path: Path) -> Optional[MacroDefinition]: + """Parse one yaml file and cache the result.""" + try: + macro = load_from_yaml(str(yaml_path)) + self._cache[macro.name] = macro + return macro + except Exception as exc: + # Log but don't crash — bad macros should not block the registry + import sys + print(f"[registry] Warning: failed to load {yaml_path}: {exc}", file=sys.stderr) + return None + + # ── Public API ─────────────────────────────────────────────────────── + + def load(self, name: str) -> MacroDefinition: + """Load a macro by name. + + Raises: + KeyError: if the macro is not found. + """ + self._scan() + if name not in self._cache: + available = sorted(self._cache.keys()) + raise KeyError( + f"Macro '{name}' not found. Available: {available}" + ) + return self._cache[name] + + def list_all(self) -> list[MacroDefinition]: + """Return all loaded macro definitions, sorted by name.""" + self._scan() + return sorted(self._cache.values(), key=lambda m: m.name) + + def list_names(self) -> list[str]: + """Return all macro names, sorted.""" + self._scan() + return sorted(self._cache.keys()) + + def reload(self, name: Optional[str] = None) -> None: + """Force reload from disk. + + Args: + name: If given, reload just that macro file. + If None, rescan the entire directory. + """ + if name is None: + self._cache.clear() + self._scanned = False + self._scan() + elif name in self._cache: + path = self._cache[name].source_path + if path and Path(path).is_file(): + self._load_file(Path(path)) + + def register(self, macro: MacroDefinition) -> None: + """Programmatically register a macro (e.g. from tests).""" + self._cache[macro.name] = macro + self._scanned = True # Don't re-scan over in-memory registrations + + def info(self) -> dict: + """Return registry metadata.""" + self._scan() + return { + "macros_dir": str(self.macros_dir), + "total": len(self._cache), + "names": self.list_names(), + } diff --git a/openclaw-skill/agent-harness/cli_anything/openclaw/core/routing.py b/openclaw-skill/agent-harness/cli_anything/openclaw/core/routing.py new file mode 100644 index 000000000..ccac3b0ce --- /dev/null +++ b/openclaw-skill/agent-harness/cli_anything/openclaw/core/routing.py @@ -0,0 +1,113 @@ +"""RoutingEngine — select the best backend for each macro step. + +Priority order (higher = preferred): + native_api 100 + gui_macro 80 + file_transform 70 + semantic_ui 50 + recovery 10 (only via explicit backend: recovery, or auto-retry) + +The router respects the step's explicit `backend:` field. +It then checks availability; if the primary is unavailable it walks down +the priority list. +""" + +from __future__ import annotations + +from cli_anything.openclaw.backends.base import Backend, BackendContext, StepResult +from cli_anything.openclaw.backends.native_api import NativeAPIBackend +from cli_anything.openclaw.backends.file_transform import FileTransformBackend +from cli_anything.openclaw.backends.semantic_ui import SemanticUIBackend +from cli_anything.openclaw.backends.gui_macro import GUIMacroBackend +from cli_anything.openclaw.backends.recovery import RecoveryBackend +from cli_anything.openclaw.core.macro_model import MacroStep + + +_BACKEND_PRIORITY: dict[str, int] = { + "native_api": 100, + "gui_macro": 80, + "file_transform": 70, + "semantic_ui": 50, + "recovery": 10, +} + + +class RoutingEngine: + """Selects and manages execution backends for macro steps.""" + + def __init__(self): + self._recovery = RecoveryBackend() + self._backends: dict[str, Backend] = { + "native_api": NativeAPIBackend(), + "file_transform": FileTransformBackend(), + "semantic_ui": SemanticUIBackend(), + "gui_macro": GUIMacroBackend(), + "recovery": self._recovery, + } + # Wire recovery with all other backends so it can delegate + for b in self._backends.values(): + self._recovery.register_backend(b) + + def select(self, step: MacroStep) -> Backend: + """Return the best available backend for the given step. + + Respects step.backend if set; falls back down the priority list + if that backend is unavailable. + + Raises: + RuntimeError: if no backend is available. + """ + requested = step.backend + # Try the requested backend first + if requested in self._backends: + b = self._backends[requested] + if b.is_available(): + return b + + # Walk by priority (descending) for a fallback + for name in sorted(_BACKEND_PRIORITY, key=lambda k: -_BACKEND_PRIORITY[k]): + if name == "recovery": + continue # Never auto-fall-through to recovery + b = self._backends.get(name) + if b and b.is_available(): + return b + + raise RuntimeError( + f"No backend available for step '{step.id}' " + f"(requested: '{requested}'). " + "Check that required tools are installed." + ) + + def execute_step( + self, + step: MacroStep, + params: dict, + context: BackendContext, + ) -> StepResult: + """Route and execute a step, applying retry logic if configured.""" + backend = self.select(step) + + if step.retry_max <= 0: + return backend.execute(step, params, context) + + # Retry with backoff + last_result: StepResult | None = None + backoff = step.retry_backoff_ms or [1000] + for attempt in range(step.retry_max + 1): + last_result = backend.execute(step, params, context) + if last_result.success: + return last_result + if attempt < step.retry_max: + import time + wait = backoff[min(attempt, len(backoff) - 1)] / 1000.0 + time.sleep(wait) + + assert last_result is not None + return last_result + + def describe(self) -> dict: + """Return a description of all registered backends and their status.""" + return { + name: b.describe() + for name, b in self._backends.items() + } diff --git a/openclaw-skill/agent-harness/cli_anything/openclaw/core/runtime.py b/openclaw-skill/agent-harness/cli_anything/openclaw/core/runtime.py new file mode 100644 index 000000000..fb76a9ba7 --- /dev/null +++ b/openclaw-skill/agent-harness/cli_anything/openclaw/core/runtime.py @@ -0,0 +1,306 @@ +"""MacroRuntime — orchestrates the full macro execution lifecycle. + +Lifecycle for execute(macro_name, params): + + 1. Load macro definition from registry + 2. Resolve + validate parameters (fill defaults, type-check) + 3. Check preconditions + 4. For each step: + a. substitute ${params} into step.params + b. route to backend + c. execute (with retry if configured) + d. handle on_failure = fail | skip | continue + 5. Check postconditions + 6. Collect declared outputs + 7. Record telemetry in session + 8. Return ExecutionResult +""" + +from __future__ import annotations + +import os +import time +from dataclasses import dataclass, field +from typing import Any, Optional + +from cli_anything.openclaw.core.macro_model import ( + MacroCondition, + MacroDefinition, + MacroStep, + substitute, +) +from cli_anything.openclaw.core.registry import MacroRegistry +from cli_anything.openclaw.core.routing import RoutingEngine +from cli_anything.openclaw.core.session import ExecutionSession, RunRecord +from cli_anything.openclaw.backends.base import BackendContext, StepResult + + +# ── Result types ───────────────────────────────────────────────────────────── + +@dataclass +class ExecutionResult: + success: bool + macro_name: str + output: dict = field(default_factory=dict) + error: str = "" + step_results: list[StepResult] = field(default_factory=list) + telemetry: dict = field(default_factory=dict) + + def to_dict(self) -> dict: + return { + "success": self.success, + "macro_name": self.macro_name, + "output": self.output, + "error": self.error, + "telemetry": self.telemetry, + "steps": [s.to_dict() for s in self.step_results], + } + + +# ── Condition checker ──────────────────────────────────────────────────────── + +def _check_condition(cond: MacroCondition, resolved_params: dict) -> Optional[str]: + """Evaluate one condition. + + Returns None if the condition passes, or an error string if it fails. + """ + ctype = cond.type + args = substitute(cond.args, resolved_params) + + if ctype == "file_exists": + path = str(args) + if not os.path.exists(path): + return f"file_exists: '{path}' not found." + return None + + elif ctype == "file_size_gt": + if not isinstance(args, (list, tuple)) or len(args) < 2: + return f"file_size_gt: expected [path, min_bytes], got {args!r}" + path, min_bytes = str(args[0]), int(args[1]) + if not os.path.exists(path): + return f"file_size_gt: '{path}' not found." + size = os.path.getsize(path) + if size <= min_bytes: + return f"file_size_gt: '{path}' is {size} bytes, expected > {min_bytes}." + return None + + elif ctype == "process_running": + name = str(args) + # Try pgrep first, then psutil + import shutil + import subprocess + if shutil.which("pgrep"): + r = subprocess.run(["pgrep", "-x", name], capture_output=True) + if r.returncode == 0: + return None + return f"process_running: '{name}' not found (pgrep)." + try: + import psutil + for proc in psutil.process_iter(["name"]): + if proc.info["name"] == name: + return None + return f"process_running: '{name}' not found." + except ImportError: + # Can't verify — let it pass with a warning + return None + + elif ctype == "env_var": + name = str(args) + if name not in os.environ: + return f"env_var: '{name}' is not set in the environment." + return None + + elif ctype == "always": + if str(args).lower() in ("false", "0", "no"): + return "always: false condition." + return None + + else: + # Unknown condition type — warn but don't block + return None + + +# ── Runtime ────────────────────────────────────────────────────────────────── + +class MacroRuntime: + """Executes macros end-to-end.""" + + def __init__( + self, + registry: Optional[MacroRegistry] = None, + routing_engine: Optional[RoutingEngine] = None, + session: Optional[ExecutionSession] = None, + ): + self.registry = registry or MacroRegistry() + self.routing = routing_engine or RoutingEngine() + self.session = session or ExecutionSession() + + # ── Public API ─────────────────────────────────────────────────────── + + def execute( + self, + macro_name: str, + params: dict, + dry_run: bool = False, + ) -> ExecutionResult: + """Execute a macro by name with the given parameters. + + Args: + macro_name: Name of the macro to execute. + params: Input parameters (raw, may be strings from CLI). + dry_run: If True, skip all side effects and return simulated success. + + Returns: + ExecutionResult with success status, outputs, and telemetry. + """ + t0 = time.time() + + # 1. Load macro + try: + macro = self.registry.load(macro_name) + except KeyError as exc: + return ExecutionResult( + success=False, macro_name=macro_name, error=str(exc) + ) + + # 2. Resolve + validate params + resolved = macro.resolve_params(params) + param_errors = macro.validate_params(resolved) + if param_errors: + return ExecutionResult( + success=False, + macro_name=macro_name, + error="Parameter validation failed:\n" + "\n".join(f" - {e}" for e in param_errors), + ) + + # 3. Check preconditions + precond_errors = self.check_conditions(macro.preconditions, resolved) + if precond_errors: + return ExecutionResult( + success=False, + macro_name=macro_name, + error="Preconditions not met:\n" + "\n".join(f" - {e}" for e in precond_errors), + ) + + # 4. Execute steps + step_results: list[StepResult] = [] + context = BackendContext( + params=resolved, + previous_results=step_results, + dry_run=dry_run, + ) + + aborted = False + abort_error = "" + for step in macro.steps: + context.timeout_ms = step.timeout_ms + try: + result = self.routing.execute_step(step, resolved, context) + except Exception as exc: + result = StepResult( + success=False, + error=f"Unhandled exception in step '{step.id}': {exc}", + backend_used=step.backend, + ) + + step_results.append(result) + + if not result.success: + if step.on_failure == "fail": + aborted = True + abort_error = f"Step '{step.id}' failed: {result.error}" + break + elif step.on_failure == "skip": + continue + # on_failure == "continue" — keep going regardless + + # 5. Check postconditions (skip if already failed) + postcond_errors: list[str] = [] + if not aborted: + postcond_errors = self.check_conditions(macro.postconditions, resolved) + + success = not aborted and not postcond_errors + + # 6. Collect outputs + output = self._collect_outputs(macro, resolved, step_results) if success else {} + + # 7. Build error string + error = "" + if aborted: + error = abort_error + elif postcond_errors: + error = "Postconditions failed:\n" + "\n".join(f" - {e}" for e in postcond_errors) + + # 8. Telemetry + duration_ms = (time.time() - t0) * 1000 + backends_used = list({r.backend_used for r in step_results if r.backend_used}) + telemetry = { + "duration_ms": duration_ms, + "steps_total": len(macro.steps), + "steps_run": len(step_results), + "backends_used": backends_used, + "dry_run": dry_run, + } + + # 9. Record in session + record = RunRecord( + macro_name=macro_name, + params=params, + success=success, + output=output, + error=error, + duration_ms=duration_ms, + backends_used=backends_used, + steps_run=len(step_results), + ) + self.session.record(record) + + return ExecutionResult( + success=success, + macro_name=macro_name, + output=output, + error=error, + step_results=step_results, + telemetry=telemetry, + ) + + def check_conditions( + self, + conditions: list[MacroCondition], + params: dict, + ) -> list[str]: + """Evaluate a list of conditions; return list of failure messages.""" + errors: list[str] = [] + for cond in conditions: + err = _check_condition(cond, params) + if err: + errors.append(err) + return errors + + def validate_macro(self, macro_name: str) -> list[str]: + """Load and structurally validate a macro; return error list.""" + try: + macro = self.registry.load(macro_name) + except KeyError as exc: + return [str(exc)] + return macro.validate() + + # ── Helpers ────────────────────────────────────────────────────────── + + def _collect_outputs( + self, + macro: MacroDefinition, + params: dict, + step_results: list[StepResult], + ) -> dict: + """Resolve declared macro outputs into a concrete dict.""" + out: dict[str, Any] = {} + for output_spec in macro.outputs: + name = output_spec.name + if output_spec.path: + out[name] = substitute(output_spec.path, params) + elif output_spec.value is not None: + out[name] = substitute(output_spec.value, params) + # Always include combined step outputs under 'steps' + out["_steps"] = [r.output for r in step_results] + return out diff --git a/openclaw-skill/agent-harness/cli_anything/openclaw/core/session.py b/openclaw-skill/agent-harness/cli_anything/openclaw/core/session.py new file mode 100644 index 000000000..5c7f38a1f --- /dev/null +++ b/openclaw-skill/agent-harness/cli_anything/openclaw/core/session.py @@ -0,0 +1,186 @@ +"""ExecutionSession — tracks macro run history and telemetry. + +Persists run records to ~/.openclaw-macro/sessions/ so agents can inspect +what was run, what succeeded, and what the outputs were. +""" + +from __future__ import annotations + +import json +import os +import time +from pathlib import Path +from typing import Optional + +SESSION_DIR = Path.home() / ".openclaw-macro" / "sessions" +MAX_HISTORY = 200 + + +def _locked_save_json(path: str, data, **dump_kwargs) -> None: + """Atomically write JSON with exclusive file locking.""" + try: + f = open(path, "r+") + except FileNotFoundError: + os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True) + f = open(path, "w") + with f: + _locked = False + try: + import fcntl + fcntl.flock(f.fileno(), fcntl.LOCK_EX) + _locked = True + except (ImportError, OSError): + pass + try: + f.seek(0) + f.truncate() + json.dump(data, f, **dump_kwargs) + f.flush() + finally: + if _locked: + import fcntl + fcntl.flock(f.fileno(), fcntl.LOCK_UN) + + +class RunRecord: + """A single macro execution record.""" + + def __init__( + self, + macro_name: str, + params: dict, + success: bool, + output: dict, + error: str, + duration_ms: float, + backends_used: list[str], + steps_run: int, + timestamp: Optional[float] = None, + ): + self.macro_name = macro_name + self.params = params + self.success = success + self.output = output + self.error = error + self.duration_ms = duration_ms + self.backends_used = backends_used + self.steps_run = steps_run + self.timestamp = timestamp or time.time() + + def to_dict(self) -> dict: + return { + "macro_name": self.macro_name, + "params": self.params, + "success": self.success, + "output": self.output, + "error": self.error, + "duration_ms": self.duration_ms, + "backends_used": self.backends_used, + "steps_run": self.steps_run, + "timestamp": self.timestamp, + } + + @classmethod + def from_dict(cls, d: dict) -> "RunRecord": + return cls( + macro_name=d.get("macro_name", ""), + params=d.get("params", {}), + success=d.get("success", False), + output=d.get("output", {}), + error=d.get("error", ""), + duration_ms=d.get("duration_ms", 0), + backends_used=d.get("backends_used", []), + steps_run=d.get("steps_run", 0), + timestamp=d.get("timestamp"), + ) + + +class ExecutionSession: + """Tracks macro run history for the current session.""" + + def __init__(self, session_id: Optional[str] = None): + self.session_id = session_id or f"session_{int(time.time())}" + self._history: list[RunRecord] = [] + + # ── Record management ───────────────────────────────────────────── + + def record(self, run: RunRecord) -> None: + """Add a run record to history.""" + self._history.append(run) + if len(self._history) > MAX_HISTORY: + self._history = self._history[-MAX_HISTORY:] + + def last(self) -> Optional[RunRecord]: + """Return the most recent run record.""" + return self._history[-1] if self._history else None + + def history(self, limit: int = 20) -> list[RunRecord]: + """Return recent run records, newest first.""" + return list(reversed(self._history[-limit:])) + + def stats(self) -> dict: + """Return aggregate statistics for this session.""" + total = len(self._history) + if total == 0: + return {"total": 0, "success_rate": 0.0, "avg_duration_ms": 0.0} + successes = sum(1 for r in self._history if r.success) + avg_dur = sum(r.duration_ms for r in self._history) / total + return { + "total": total, + "success": successes, + "failure": total - successes, + "success_rate": successes / total, + "avg_duration_ms": avg_dur, + } + + def status(self) -> dict: + return { + "session_id": self.session_id, + "runs": len(self._history), + **self.stats(), + } + + # ── Persistence ─────────────────────────────────────────────────── + + def save(self) -> str: + """Persist session to disk. Returns the file path.""" + SESSION_DIR.mkdir(parents=True, exist_ok=True) + path = str(SESSION_DIR / f"{self.session_id}.json") + data = { + "session_id": self.session_id, + "timestamp": time.time(), + "history": [r.to_dict() for r in self._history], + } + _locked_save_json(path, data, indent=2, sort_keys=True) + return path + + @classmethod + def load(cls, session_id: str) -> Optional["ExecutionSession"]: + """Load a session from disk.""" + path = SESSION_DIR / f"{session_id}.json" + if not path.is_file(): + return None + with open(path, encoding="utf-8") as f: + data = json.load(f) + session = cls(session_id=data.get("session_id", session_id)) + session._history = [RunRecord.from_dict(r) for r in data.get("history", [])] + return session + + @classmethod + def list_sessions(cls) -> list[dict]: + """List all saved sessions (metadata only).""" + SESSION_DIR.mkdir(parents=True, exist_ok=True) + sessions = [] + for p in SESSION_DIR.glob("*.json"): + try: + with open(p, encoding="utf-8") as f: + d = json.load(f) + sessions.append({ + "session_id": d.get("session_id"), + "timestamp": d.get("timestamp", 0), + "runs": len(d.get("history", [])), + }) + except Exception: + continue + sessions.sort(key=lambda s: s.get("timestamp", 0), reverse=True) + return sessions diff --git a/openclaw-skill/agent-harness/cli_anything/openclaw/macro_definitions/examples/export_file.yaml b/openclaw-skill/agent-harness/cli_anything/openclaw/macro_definitions/examples/export_file.yaml new file mode 100644 index 000000000..24ec1a8e4 --- /dev/null +++ b/openclaw-skill/agent-harness/cli_anything/openclaw/macro_definitions/examples/export_file.yaml @@ -0,0 +1,63 @@ +name: export_file +version: "1.0" +description: > + Export a file from the target application using its native CLI. + Demonstrates the native_api backend with a run_command action. + Replace the command with your actual application's export command. +tags: [export, native_api, example] + +parameters: + output: + type: string + required: true + description: Destination file path for the export. + example: /tmp/exported.txt + input: + type: string + required: false + default: "" + description: Source file to export from (optional, app-dependent). + format: + type: string + required: false + default: plain + description: Output format. + enum: [plain, json, csv] + +preconditions: + - file_exists: /bin/echo # sanity check: a real macro would check the app binary + +steps: + - id: step_export + backend: native_api + action: run_command + params: + # Replace this with your real export command, e.g.: + # command: [inkscape, --export-filename, "${output}", "${input}"] + command: [echo, "Exported to ${output} (format=${format})"] + capture_stdout: true + timeout_ms: 30000 + on_failure: fail + + - id: step_write_output + backend: file_transform + action: text_replace + params: + input_file: /dev/null + output_file: ${output} + find: "" + replace: "" + on_failure: skip # Writing the marker file is best-effort + +postconditions: [] + +outputs: + - name: exported_file + path: ${output} + description: Path where the export was written. + +agent_hints: + danger_level: safe + side_effects: [creates_file] + reversible: true + estimated_duration_ms: 2000 diff --git a/openclaw-skill/agent-harness/cli_anything/openclaw/macro_definitions/examples/transform_json.yaml b/openclaw-skill/agent-harness/cli_anything/openclaw/macro_definitions/examples/transform_json.yaml new file mode 100644 index 000000000..7741a339e --- /dev/null +++ b/openclaw-skill/agent-harness/cli_anything/openclaw/macro_definitions/examples/transform_json.yaml @@ -0,0 +1,52 @@ +name: transform_json +version: "1.0" +description: > + Read a JSON file, set a nested key to a new value, and write it back. + Demonstrates the file_transform backend with json_set action. +tags: [json, file_transform, example] + +parameters: + file: + type: string + required: true + description: Path to the JSON file to transform. + example: /tmp/config.json + key: + type: string + required: true + description: Dot-separated key path to set (e.g. settings.theme). + example: settings.theme + value: + type: string + required: true + description: Value to write at the key path. + example: dark + +preconditions: + - file_exists: ${file} + +steps: + - id: step_set_key + backend: file_transform + action: json_set + params: + input_file: ${file} + output_file: ${file} + path: ${key} + value: ${value} + timeout_ms: 10000 + on_failure: fail + +postconditions: + - file_exists: ${file} + +outputs: + - name: modified_file + path: ${file} + description: Path to the modified JSON file. + +agent_hints: + danger_level: moderate + side_effects: [modifies_file] + reversible: false + estimated_duration_ms: 100 diff --git a/openclaw-skill/agent-harness/cli_anything/openclaw/macro_definitions/examples/undo_last.yaml b/openclaw-skill/agent-harness/cli_anything/openclaw/macro_definitions/examples/undo_last.yaml new file mode 100644 index 000000000..1475b58b3 --- /dev/null +++ b/openclaw-skill/agent-harness/cli_anything/openclaw/macro_definitions/examples/undo_last.yaml @@ -0,0 +1,37 @@ +name: undo_last +version: "1.0" +description: > + Trigger an undo operation in the target application using a keyboard shortcut + (Ctrl+Z). This macro demonstrates the semantic_ui backend. + On Linux, requires xdotool. On macOS/Windows, adapt to your platform. +tags: [undo, semantic_ui, example] + +parameters: + app_name: + type: string + required: false + default: "" + description: Name of the target application process (used to verify it is running). + example: inkscape + +preconditions: + - always: true # process check is optional in this example + +steps: + - id: step_undo + backend: semantic_ui + action: shortcut + params: + keys: ctrl+z + timeout_ms: 5000 + on_failure: fail + +postconditions: [] + +outputs: [] + +agent_hints: + danger_level: safe + side_effects: [modifies_app_state] + reversible: true # can be redone with ctrl+y + estimated_duration_ms: 500 diff --git a/openclaw-skill/agent-harness/cli_anything/openclaw/macro_definitions/manifest.yaml b/openclaw-skill/agent-harness/cli_anything/openclaw/macro_definitions/manifest.yaml new file mode 100644 index 000000000..0b5e0db21 --- /dev/null +++ b/openclaw-skill/agent-harness/cli_anything/openclaw/macro_definitions/manifest.yaml @@ -0,0 +1,10 @@ +macros: + - name: export_file + path: examples/export_file.yaml + version: "1.0" + - name: undo_last + path: examples/undo_last.yaml + version: "1.0" + - name: transform_json + path: examples/transform_json.yaml + version: "1.0" diff --git a/openclaw-skill/agent-harness/cli_anything/openclaw/openclaw_cli.py b/openclaw-skill/agent-harness/cli_anything/openclaw/openclaw_cli.py new file mode 100644 index 000000000..1dba50274 --- /dev/null +++ b/openclaw-skill/agent-harness/cli_anything/openclaw/openclaw_cli.py @@ -0,0 +1,572 @@ +#!/usr/bin/env python3 +"""OpenClaw CLI — agent-callable interface for the Macro System. + +This CLI is the L6 "Unified CLI Entry" in the OpenClaw Macro System. +It provides a stable, machine-readable interface for AI agents and +power users to invoke macros without touching the GUI. + +Usage (one-shot): + cli-anything-openclaw macro run export_file --param output=/tmp/out.png --json + cli-anything-openclaw macro list --json + cli-anything-openclaw macro info export_file --json + +Usage (REPL): + cli-anything-openclaw # enters interactive REPL + cli-anything-openclaw repl +""" + +import sys +import os +import json +import click +from typing import Optional + +from cli_anything.openclaw.core.registry import MacroRegistry +from cli_anything.openclaw.core.runtime import MacroRuntime +from cli_anything.openclaw.core.session import ExecutionSession + +# ── Global state ───────────────────────────────────────────────────────────── + +_json_output = False +_repl_mode = False +_dry_run = False + +_session: Optional[ExecutionSession] = None +_runtime: Optional[MacroRuntime] = None + + +def get_runtime() -> MacroRuntime: + global _runtime, _session + if _runtime is None: + _session = _session or ExecutionSession() + _runtime = MacroRuntime(session=_session) + return _runtime + + +def get_session() -> ExecutionSession: + global _session + if _session is None: + _session = ExecutionSession() + return _session + + +# ── Output helpers ──────────────────────────────────────────────────────────── + +def output(data, message: str = ""): + """Print result: JSON in --json mode, human-readable otherwise.""" + if _json_output: + click.echo(json.dumps(data, indent=2, default=str)) + else: + if message: + click.echo(message) + _print_value(data) + + +def _print_value(val, indent: int = 0): + prefix = " " * indent + if isinstance(val, dict): + for k, v in val.items(): + if isinstance(v, (dict, list)): + click.echo(f"{prefix}{k}:") + _print_value(v, indent + 1) + else: + click.echo(f"{prefix}{k}: {v}") + elif isinstance(val, list): + for i, item in enumerate(val): + if isinstance(item, dict): + click.echo(f"{prefix}[{i}]") + _print_value(item, indent + 1) + else: + click.echo(f"{prefix}- {item}") + else: + click.echo(f"{prefix}{val}") + + +def handle_error(func): + """Decorator: consistent error handling across commands.""" + import functools + + @functools.wraps(func) + def wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except KeyError as e: + msg = str(e).strip("'\"") + if _json_output: + click.echo(json.dumps({"error": msg, "type": "not_found"})) + else: + click.echo(f"Error: {msg}", err=True) + if not _repl_mode: + sys.exit(1) + except FileNotFoundError as e: + if _json_output: + click.echo(json.dumps({"error": str(e), "type": "file_not_found"})) + else: + click.echo(f"Error: {e}", err=True) + if not _repl_mode: + sys.exit(1) + except Exception as e: + if _json_output: + click.echo(json.dumps({"error": str(e), "type": type(e).__name__})) + else: + click.echo(f"Error: {e}", err=True) + if not _repl_mode: + sys.exit(1) + + return wrapper + + +# ── Parameter parsing ───────────────────────────────────────────────────────── + +def _parse_params(param_tuples: tuple) -> dict: + """Convert --param key=value tuples to a dict.""" + result = {} + for pair in param_tuples: + if "=" in pair: + k, v = pair.split("=", 1) + result[k.strip()] = v.strip() + else: + click.echo(f"Warning: --param '{pair}' ignored (expected key=value format).", err=True) + return result + + +# ── Main CLI group ──────────────────────────────────────────────────────────── + +@click.group(invoke_without_command=True) +@click.option("--json", "json_flag", is_flag=True, help="Machine-readable JSON output.") +@click.option("--dry-run", "dry_run_flag", is_flag=True, + help="Simulate execution without side effects.") +@click.option("--session-id", default=None, help="Resume or create a named session.") +@click.pass_context +def cli(ctx, json_flag, dry_run_flag, session_id): + """OpenClaw Macro System — run GUI workflows as CLI commands. + + \b + Quick start: + cli-anything-openclaw macro list + cli-anything-openclaw macro info + cli-anything-openclaw macro run --param key=value + + Enter interactive REPL by running without arguments. + """ + global _json_output, _dry_run, _session + + _json_output = json_flag + _dry_run = dry_run_flag + + if session_id: + loaded = ExecutionSession.load(session_id) + _session = loaded or ExecutionSession(session_id=session_id) + + ctx.ensure_object(dict) + + if ctx.invoked_subcommand is None: + ctx.invoke(repl) + + +# ── macro group ────────────────────────────────────────────────────────────── + +@cli.group() +def macro(): + """Macro management and execution.""" + + +@macro.command("run") +@click.argument("name") +@click.option("--param", "-p", multiple=True, + help="Macro parameter in key=value format. Repeat for multiple.") +@handle_error +def macro_run(name, param): + """Execute a macro by name. + + \b + Example: + macro run export_file --param output=/tmp/out.txt + macro run export_file -p output=/tmp/out.txt -p format=plain --json + """ + params = _parse_params(param) + runtime = get_runtime() + result = runtime.execute(name, params, dry_run=_dry_run) + + if _json_output: + output(result.to_dict()) + else: + if result.success: + click.echo(f"✓ Macro '{name}' completed successfully.") + if result.output: + for k, v in result.output.items(): + if not k.startswith("_"): + click.echo(f" {k}: {v}") + else: + click.echo(f"✗ Macro '{name}' failed.", err=True) + click.echo(f" {result.error}", err=True) + if result.telemetry: + click.echo( + f" [{result.telemetry.get('duration_ms', 0):.0f}ms, " + f"backends: {', '.join(result.telemetry.get('backends_used', []))}]" + ) + if not result.success and not _repl_mode: + sys.exit(1) + + +@macro.command("list") +@handle_error +def macro_list(): + """List all available macros.""" + runtime = get_runtime() + macros = runtime.registry.list_all() + + if _json_output: + output([{ + "name": m.name, + "version": m.version, + "description": m.description, + "tags": m.tags, + "parameters": list(m.parameters.keys()), + } for m in macros]) + else: + if not macros: + click.echo("No macros found.") + return + click.echo(f"Available macros ({len(macros)}):\n") + for m in macros: + tags = f" [{', '.join(m.tags)}]" if m.tags else "" + click.echo(f" {m.name:<30} {m.description}{tags}") + + +@macro.command("info") +@click.argument("name") +@handle_error +def macro_info(name): + """Show full details for a macro (schema, parameters, steps).""" + runtime = get_runtime() + m = runtime.registry.load(name) + + if _json_output: + output(m.to_dict()) + else: + click.echo(f"\nMacro: {m.name} (v{m.version})") + click.echo(f" {m.description}\n") + + if m.parameters: + click.echo("Parameters:") + for pname, pspec in m.parameters.items(): + req = "(required)" if pspec.required else f"(default: {pspec.default!r})" + click.echo(f" --param {pname}=<{pspec.type}> {req}") + if pspec.description: + click.echo(f" {pspec.description}") + + if m.preconditions: + click.echo(f"\nPreconditions ({len(m.preconditions)}):") + for c in m.preconditions: + click.echo(f" {c.type}: {c.args}") + + if m.steps: + click.echo(f"\nSteps ({len(m.steps)}):") + for s in m.steps: + click.echo(f" [{s.id}] backend={s.backend} action={s.action}") + + if m.postconditions: + click.echo(f"\nPostconditions ({len(m.postconditions)}):") + for c in m.postconditions: + click.echo(f" {c.type}: {c.args}") + + if m.outputs: + click.echo(f"\nOutputs:") + for o in m.outputs: + click.echo(f" {o.name}: {o.path or o.value}") + + if m.agent_hints: + click.echo(f"\nAgent hints: {m.agent_hints}") + click.echo() + + +@macro.command("validate") +@click.argument("name", required=False) +@handle_error +def macro_validate(name): + """Validate macro definition(s). Pass a name or omit to validate all.""" + runtime = get_runtime() + + if name: + names = [name] + else: + names = runtime.registry.list_names() + + results = {} + for n in names: + errors = runtime.validate_macro(n) + results[n] = errors + + if _json_output: + output({n: {"valid": len(e) == 0, "errors": e} for n, e in results.items()}) + else: + all_ok = True + for n, errors in results.items(): + if errors: + all_ok = False + click.echo(f"✗ {n}:") + for err in errors: + click.echo(f" - {err}", err=True) + else: + click.echo(f"✓ {n}") + if all_ok: + click.echo("\nAll macros valid.") + else: + if not _repl_mode: + sys.exit(1) + + +@macro.command("dry-run") +@click.argument("name") +@click.option("--param", "-p", multiple=True, help="Parameter in key=value format.") +@handle_error +def macro_dry_run(name, param): + """Simulate macro execution without any side effects.""" + params = _parse_params(param) + runtime = get_runtime() + result = runtime.execute(name, params, dry_run=True) + + if _json_output: + output(result.to_dict()) + else: + click.echo(f"[dry-run] Macro '{name}'") + if result.success: + click.echo(" Would execute successfully.") + click.echo(f" Steps: {len(result.step_results)}") + else: + click.echo(f" Would fail: {result.error}", err=True) + + +@macro.command("define") +@click.argument("name") +@click.option("--output", "-o", default=None, help="Write YAML to this file path.") +@handle_error +def macro_define(name, output): + """Scaffold a new macro YAML definition.""" + import textwrap + template = textwrap.dedent(f"""\ + name: {name} + version: "1.0" + description: "Describe what this macro does." + tags: [] + + parameters: + # Add your parameters here + # output: + # type: string + # required: true + # description: Output file path + # example: /tmp/result.txt + + preconditions: + # Conditions that must be true before execution + # - file_exists: /path/to/input + # - process_running: my-app + + steps: + - id: step_1 + backend: native_api # or: file_transform, semantic_ui, gui_macro + action: run_command + params: + command: [echo, "Hello from {name}"] + timeout_ms: 30000 + on_failure: fail # or: skip, continue + + postconditions: + # Conditions verified after execution + # - file_exists: ${{output}} + + outputs: + # Named outputs the agent can use + # - name: result_file + # path: ${{output}} + + agent_hints: + danger_level: safe # safe | moderate | dangerous + side_effects: [] + reversible: true + """) + if output: + from pathlib import Path + p = Path(output) + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(template, encoding="utf-8") + if _json_output: + click.echo(json.dumps({"created": str(p.resolve())})) + else: + click.echo(f"✓ Macro scaffold written to: {p.resolve()}") + else: + click.echo(template) + + +# ── session group ───────────────────────────────────────────────────────────── + +@cli.group() +def session(): + """Session management and run history.""" + + +@session.command("status") +@handle_error +def session_status(): + """Show current session status and statistics.""" + sess = get_session() + data = sess.status() + output(data, "Session status:") + + +@session.command("history") +@click.option("--limit", default=10, show_default=True, help="Number of records to show.") +@handle_error +def session_history(limit): + """Show recent macro execution history.""" + sess = get_session() + records = sess.history(limit=limit) + + if _json_output: + output([r.to_dict() for r in records]) + else: + if not records: + click.echo("No runs recorded in this session.") + return + click.echo(f"Recent runs ({len(records)}):\n") + for r in records: + status = "✓" if r.success else "✗" + import datetime + ts = datetime.datetime.fromtimestamp(r.timestamp).strftime("%H:%M:%S") + click.echo(f" {status} [{ts}] {r.macro_name} ({r.duration_ms:.0f}ms)") + if not r.success: + click.echo(f" Error: {r.error}", err=True) + + +@session.command("save") +@handle_error +def session_save(): + """Persist current session to disk.""" + sess = get_session() + path = sess.save() + output({"saved": path, "session_id": sess.session_id}, + f"Session saved: {path}") + + +@session.command("list") +@handle_error +def session_list(): + """List all saved sessions.""" + sessions = ExecutionSession.list_sessions() + if _json_output: + output(sessions) + else: + if not sessions: + click.echo("No saved sessions.") + return + click.echo("Saved sessions:\n") + for s in sessions: + import datetime + ts = datetime.datetime.fromtimestamp(s.get("timestamp", 0)).strftime("%Y-%m-%d %H:%M:%S") + click.echo(f" {s['session_id']} ({s['runs']} runs) {ts}") + + +# ── backends command ────────────────────────────────────────────────────────── + +@cli.command() +@handle_error +def backends(): + """Show available execution backends and their status.""" + runtime = get_runtime() + data = runtime.routing.describe() + if _json_output: + output(data) + else: + click.echo("Execution backends:\n") + for name, info in sorted(data.items(), key=lambda x: -x[1].get("priority", 0)): + status = "✓" if info.get("available") else "✗" + click.echo( + f" {status} {name:<20} priority={info.get('priority', '?'):<5}" + f" available={info.get('available')}" + ) + + +# ── repl command ────────────────────────────────────────────────────────────── + +@cli.command() +@click.pass_context +def repl(ctx): + """Enter the interactive REPL (default when no command given).""" + global _repl_mode + _repl_mode = True + + from cli_anything.openclaw.utils.repl_skin import ReplSkin + skin = ReplSkin("openclaw", version="1.0.0") + skin.print_banner() + + runtime = get_runtime() + + # Show quick summary on startup + macros = runtime.registry.list_all() + skin.info(f"{len(macros)} macros loaded. Type 'macro list' to see them.") + skin.info("Type 'help' for commands, 'quit' to exit.\n") + + pt_session = skin.create_prompt_session() + session_obj = get_session() + + while True: + try: + line = skin.get_input( + pt_session, + context=f"{session_obj.session_id[:12]}", + ) + except (EOFError, KeyboardInterrupt): + skin.print_goodbye() + break + + if not line: + continue + if line.lower() in ("quit", "exit", "q"): + skin.print_goodbye() + break + if line.lower() in ("help", "?"): + skin.help({ + "macro list": "List all available macros", + "macro info ": "Show macro schema", + "macro run [--param k=v ...]": "Execute a macro", + "macro dry-run ": "Simulate without side effects", + "macro validate [name]": "Validate macro definitions", + "macro define ": "Scaffold a new macro YAML", + "session status": "Show session statistics", + "session history": "Show recent runs", + "backends": "Show backend availability", + "quit": "Exit the REPL", + }) + continue + + # Parse and dispatch via Click's standalone_mode=False + import shlex + try: + args = shlex.split(line) + except ValueError as e: + skin.error(f"Parse error: {e}") + continue + + try: + ctx_obj = cli.make_context( + "cli-anything-openclaw", + args, + standalone_mode=False, + parent=ctx, + ) + with ctx_obj: + cli.invoke(ctx_obj) + except SystemExit: + pass + except click.ClickException as e: + skin.error(str(e)) + except Exception as e: + skin.error(str(e)) + + +# ── Entry point ─────────────────────────────────────────────────────────────── + +if __name__ == "__main__": + cli() diff --git a/openclaw-skill/agent-harness/cli_anything/openclaw/skills/SKILL.md b/openclaw-skill/agent-harness/cli_anything/openclaw/skills/SKILL.md new file mode 100644 index 000000000..c5e09d1b6 --- /dev/null +++ b/openclaw-skill/agent-harness/cli_anything/openclaw/skills/SKILL.md @@ -0,0 +1,220 @@ +--- +name: cli-anything-openclaw +description: > + Use when the agent wants to define, list, inspect, or execute GUI macros + via the OpenClaw Macro System CLI. Macros are parameterized, CLI-callable + workflows — the agent invokes `macro run ` and the system handles + backend routing (plugin, file transform, accessibility, compiled GUI replay). +--- + +# OpenClaw Macro System CLI + +## What It Is + +The OpenClaw Macro System converts valuable GUI workflows into parameterized, +CLI-callable macros. Agents **never touch the GUI directly** — they call macros +through this stable CLI, and the runtime routes execution to the best available +backend (native plugin/API, file transformation, semantic UI control, or +precompiled GUI macro replay). + +## Installation + +```bash +cd openclaw-skill/agent-harness +pip install -e . +``` + +**Requirements:** Python 3.10+, PyYAML, click, prompt-toolkit. + +## Quick Start (for agents) + +```bash +# 1. See what macros are available +cli-anything-openclaw macro list --json + +# 2. Inspect a macro's parameters +cli-anything-openclaw macro info export_file --json + +# 3. Dry-run to check params without side effects +cli-anything-openclaw --dry-run macro run export_file \ + --param output=/tmp/test.txt --json + +# 4. Execute a macro +cli-anything-openclaw macro run export_file \ + --param output=/tmp/result.txt --json + +# 5. See what backends are available +cli-anything-openclaw backends --json +``` + +## Command Reference + +### Global Flags + +| Flag | Description | +|------|-------------| +| `--json` | Machine-readable JSON output on stdout | +| `--dry-run` | Simulate all steps, skip side effects | +| `--session-id ` | Resume or create a named session | + +### `macro` group + +| Command | Description | +|---------|-------------| +| `macro list` | List all available macros | +| `macro info ` | Show macro schema (parameters, steps, conditions) | +| `macro run --param k=v` | Execute a macro | +| `macro dry-run --param k=v` | Simulate without side effects | +| `macro validate [name]` | Structural validation | +| `macro define ` | Scaffold a new macro YAML | + +### `session` group + +| Command | Description | +|---------|-------------| +| `session status` | Show session statistics | +| `session history` | Show recent run history | +| `session save` | Persist session to disk | +| `session list` | List all saved sessions | + +### `backends` + +```bash +cli-anything-openclaw backends --json +# Shows: native_api, file_transform, semantic_ui, gui_macro, recovery +# and whether each is available in the current environment. +``` + +## Macro Parameters + +Pass parameters with `--param key=value`. Repeat for multiple: + +```bash +cli-anything-openclaw macro run transform_json \ + --param file=/path/to/data.json \ + --param key=settings.theme \ + --param value=dark \ + --json +``` + +## Output Format (--json) + +All commands output JSON when `--json` is set: + +```json +{ + "success": true, + "macro_name": "export_file", + "output": { + "exported_file": "/tmp/result.txt" + }, + "error": "", + "telemetry": { + "duration_ms": 312, + "steps_total": 2, + "steps_run": 2, + "backends_used": ["native_api"], + "dry_run": false + } +} +``` + +On failure (`"success": false`), read the `"error"` field for the reason. +Exit code is 1 on failure. + +## Execution Backends + +Backends are selected automatically based on the macro step definition: + +| Backend | Triggered by | Use case | +|---------|-------------|----------| +| `native_api` | `backend: native_api` | Subprocess / shell command | +| `file_transform` | `backend: file_transform` | XML, JSON, text file editing | +| `semantic_ui` | `backend: semantic_ui` | Accessibility / keyboard shortcuts | +| `gui_macro` | `backend: gui_macro` | Precompiled coordinate replay | +| `recovery` | `backend: recovery` | Retry / fallback orchestration | + +## Writing Macros + +Macros are YAML files in `cli_anything/openclaw/macro_definitions/`. +Scaffold one with: + +```bash +cli-anything-openclaw macro define my_macro --output \ + cli_anything/openclaw/macro_definitions/examples/my_macro.yaml +``` + +Minimal schema: + +```yaml +name: my_macro +version: "1.0" +description: What this macro does. + +parameters: + output: + type: string + required: true + description: Where to write results. + example: /tmp/result.txt + +preconditions: + - file_exists: /path/to/input + +steps: + - id: step1 + backend: native_api + action: run_command + params: + command: [my-app, --export, "${output}"] + timeout_ms: 30000 + on_failure: fail # or: skip, continue + +postconditions: + - file_exists: ${output} + - file_size_gt: [${output}, 100] + +outputs: + - name: result_file + path: ${output} + +agent_hints: + danger_level: safe # safe | moderate | dangerous + side_effects: [creates_file] + reversible: true +``` + +## Agent Usage Rules + +1. **Always use `--json`** for programmatic output. +2. **Use `--dry-run` to validate params** before executing side-effectful macros. +3. **Check `success` field** — do not assume success from exit code alone. +4. **Read `error` field** when `success` is false — it explains what failed. +5. **Use `macro info ` to discover params** before calling `macro run`. +6. **Use absolute paths** for all file parameters. + +## Example Workflow + +```bash +# Step 1: What's available? +cli-anything-openclaw macro list --json + +# Step 2: What params does transform_json need? +cli-anything-openclaw macro info transform_json --json + +# Step 3: Test safely +cli-anything-openclaw --dry-run macro run transform_json \ + --param file=/tmp/config.json \ + --param key=theme \ + --param value=dark --json + +# Step 4: Execute for real +cli-anything-openclaw macro run transform_json \ + --param file=/tmp/config.json \ + --param key=theme \ + --param value=dark --json +``` + +## Version + +1.0.0 diff --git a/openclaw-skill/agent-harness/cli_anything/openclaw/tests/__init__.py b/openclaw-skill/agent-harness/cli_anything/openclaw/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/openclaw-skill/agent-harness/cli_anything/openclaw/tests/test_core.py b/openclaw-skill/agent-harness/cli_anything/openclaw/tests/test_core.py new file mode 100644 index 000000000..e68b018dd --- /dev/null +++ b/openclaw-skill/agent-harness/cli_anything/openclaw/tests/test_core.py @@ -0,0 +1,595 @@ +"""Unit tests for OpenClaw Macro System core modules. + +Covers: MacroDefinition, MacroRegistry, MacroRuntime, backends, routing. +All tests use synthetic data and do not require external software. +""" + +import json +import os +import sys +import textwrap +import tempfile +from pathlib import Path + +import pytest + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +SIMPLE_MACRO_YAML = textwrap.dedent("""\ + name: test_macro + version: "1.0" + description: A test macro. + parameters: + output: + type: string + required: true + description: Output path + count: + type: integer + required: false + default: 1 + min: 1 + max: 100 + steps: + - id: step1 + backend: native_api + action: run_command + params: + command: [echo, hello] + postconditions: [] +""") + + +def write_macro(tmp_path: Path, name: str, content: str) -> Path: + p = tmp_path / f"{name}.yaml" + p.write_text(content, encoding="utf-8") + return p + + +# ── macro_model tests ───────────────────────────────────────────────────────── + +class TestMacroModel: + def test_load_from_yaml(self, tmp_path): + from cli_anything.openclaw.core.macro_model import load_from_yaml + p = write_macro(tmp_path, "test_macro", SIMPLE_MACRO_YAML) + m = load_from_yaml(str(p)) + assert m.name == "test_macro" + assert m.version == "1.0" + assert "output" in m.parameters + assert len(m.steps) == 1 + assert m.steps[0].backend == "native_api" + + def test_load_missing_file(self): + from cli_anything.openclaw.core.macro_model import load_from_yaml + with pytest.raises(FileNotFoundError): + load_from_yaml("/nonexistent/path.yaml") + + def test_validate_params_required(self, tmp_path): + from cli_anything.openclaw.core.macro_model import load_from_yaml + p = write_macro(tmp_path, "test_macro", SIMPLE_MACRO_YAML) + m = load_from_yaml(str(p)) + errors = m.validate_params({}) + assert any("output" in e for e in errors) + + def test_validate_params_type_error(self, tmp_path): + from cli_anything.openclaw.core.macro_model import load_from_yaml + p = write_macro(tmp_path, "test_macro", SIMPLE_MACRO_YAML) + m = load_from_yaml(str(p)) + errors = m.validate_params({"output": "/tmp/x", "count": "not_an_int"}) + assert any("count" in e for e in errors) + + def test_validate_params_range(self, tmp_path): + from cli_anything.openclaw.core.macro_model import load_from_yaml + p = write_macro(tmp_path, "test_macro", SIMPLE_MACRO_YAML) + m = load_from_yaml(str(p)) + errors = m.validate_params({"output": "/tmp/x", "count": 200}) + assert any("count" in e for e in errors) + + def test_validate_params_ok(self, tmp_path): + from cli_anything.openclaw.core.macro_model import load_from_yaml + p = write_macro(tmp_path, "test_macro", SIMPLE_MACRO_YAML) + m = load_from_yaml(str(p)) + errors = m.validate_params({"output": "/tmp/x"}) + assert errors == [] + + def test_resolve_params_defaults(self, tmp_path): + from cli_anything.openclaw.core.macro_model import load_from_yaml + p = write_macro(tmp_path, "test_macro", SIMPLE_MACRO_YAML) + m = load_from_yaml(str(p)) + resolved = m.resolve_params({"output": "/tmp/x"}) + assert resolved["count"] == 1 + + def test_structural_validation_no_steps(self, tmp_path): + from cli_anything.openclaw.core.macro_model import load_from_yaml + yaml_content = "name: bad\nsteps: []\n" + p = write_macro(tmp_path, "bad", yaml_content) + m = load_from_yaml(str(p)) + errors = m.validate() + assert any("steps" in e for e in errors) + + def test_structural_validation_bad_backend(self, tmp_path): + from cli_anything.openclaw.core.macro_model import load_from_yaml + yaml_content = textwrap.dedent("""\ + name: bad + steps: + - id: x + backend: fake_backend + action: do_thing + """) + p = write_macro(tmp_path, "bad_backend", yaml_content) + m = load_from_yaml(str(p)) + errors = m.validate() + assert any("fake_backend" in e for e in errors) + + def test_to_dict(self, tmp_path): + from cli_anything.openclaw.core.macro_model import load_from_yaml + p = write_macro(tmp_path, "test_macro", SIMPLE_MACRO_YAML) + m = load_from_yaml(str(p)) + d = m.to_dict() + assert d["name"] == "test_macro" + assert "parameters" in d + assert "steps" in d + + +class TestSubstitute: + def test_string_substitution(self): + from cli_anything.openclaw.core.macro_model import substitute + result = substitute("hello ${name}", {"name": "world"}) + assert result == "hello world" + + def test_nested_list(self): + from cli_anything.openclaw.core.macro_model import substitute + result = substitute(["echo", "${output}"], {"output": "/tmp/x"}) + assert result == ["echo", "/tmp/x"] + + def test_nested_dict(self): + from cli_anything.openclaw.core.macro_model import substitute + result = substitute({"path": "${output}", "other": 42}, {"output": "/out"}) + assert result["path"] == "/out" + assert result["other"] == 42 + + def test_missing_key_left_as_is(self): + from cli_anything.openclaw.core.macro_model import substitute + result = substitute("${missing}", {}) + assert result == "${missing}" + + +# ── MacroRegistry tests ─────────────────────────────────────────────────────── + +class TestMacroRegistry: + def test_load_macro(self, tmp_path): + from cli_anything.openclaw.core.registry import MacroRegistry + write_macro(tmp_path, "test_macro", SIMPLE_MACRO_YAML) + reg = MacroRegistry(str(tmp_path)) + m = reg.load("test_macro") + assert m.name == "test_macro" + + def test_load_missing_raises(self, tmp_path): + from cli_anything.openclaw.core.registry import MacroRegistry + reg = MacroRegistry(str(tmp_path)) + with pytest.raises(KeyError): + reg.load("nonexistent_macro") + + def test_list_all(self, tmp_path): + from cli_anything.openclaw.core.registry import MacroRegistry + write_macro(tmp_path, "test_macro", SIMPLE_MACRO_YAML) + write_macro(tmp_path, "another", SIMPLE_MACRO_YAML.replace("test_macro", "another")) + reg = MacroRegistry(str(tmp_path)) + names = reg.list_names() + assert "test_macro" in names + assert "another" in names + + def test_manifest_index(self, tmp_path): + from cli_anything.openclaw.core.registry import MacroRegistry + sub = tmp_path / "sub" + sub.mkdir() + write_macro(sub, "alpha", SIMPLE_MACRO_YAML.replace("test_macro", "alpha")) + manifest = tmp_path / "manifest.yaml" + manifest.write_text("macros:\n - name: alpha\n path: sub/alpha.yaml\n") + reg = MacroRegistry(str(tmp_path)) + m = reg.load("alpha") + assert m.name == "alpha" + + def test_register_programmatic(self): + from cli_anything.openclaw.core.registry import MacroRegistry + from cli_anything.openclaw.core.macro_model import MacroDefinition, MacroStep + reg = MacroRegistry("/nonexistent") + macro = MacroDefinition( + name="inline_macro", + steps=[MacroStep(backend="native_api", action="run_command")], + ) + reg.register(macro) + assert reg.load("inline_macro").name == "inline_macro" + + def test_info(self, tmp_path): + from cli_anything.openclaw.core.registry import MacroRegistry + write_macro(tmp_path, "test_macro", SIMPLE_MACRO_YAML) + reg = MacroRegistry(str(tmp_path)) + info = reg.info() + assert info["total"] >= 1 + assert "macros_dir" in info + + +# ── Backend tests ───────────────────────────────────────────────────────────── + +class TestNativeAPIBackend: + def _make_context(self, params=None): + from cli_anything.openclaw.backends.base import BackendContext + return BackendContext(params=params or {}) + + def _make_step(self, action, step_params): + from cli_anything.openclaw.core.macro_model import MacroStep + return MacroStep(id="test", backend="native_api", action=action, params=step_params) + + def test_run_command_success(self): + from cli_anything.openclaw.backends.native_api import NativeAPIBackend + b = NativeAPIBackend() + step = self._make_step("run_command", {"command": ["echo", "hello"]}) + result = b.execute(step, {}, self._make_context()) + assert result.success + + def test_run_command_fails_bad_exit(self): + from cli_anything.openclaw.backends.native_api import NativeAPIBackend + b = NativeAPIBackend() + step = self._make_step("run_command", {"command": ["false"]}) + result = b.execute(step, {}, self._make_context()) + assert not result.success + assert "exit" in result.error.lower() or "failed" in result.error.lower() + + def test_run_command_not_found(self): + from cli_anything.openclaw.backends.native_api import NativeAPIBackend + b = NativeAPIBackend() + step = self._make_step("run_command", {"command": ["__nonexistent_cmd__"]}) + result = b.execute(step, {}, self._make_context()) + assert not result.success + + def test_find_executable_found(self): + from cli_anything.openclaw.backends.native_api import NativeAPIBackend + b = NativeAPIBackend() + step = self._make_step("find_executable", {"name": "echo"}) + result = b.execute(step, {}, self._make_context()) + assert result.success + assert "executable" in result.output + + def test_find_executable_missing(self): + from cli_anything.openclaw.backends.native_api import NativeAPIBackend + b = NativeAPIBackend() + step = self._make_step("find_executable", { + "name": "__nonexistent__", + "install_hint": "brew install nonexistent" + }) + result = b.execute(step, {}, self._make_context()) + assert not result.success + assert "brew install" in result.error + + def test_dry_run_does_not_execute(self): + from cli_anything.openclaw.backends.native_api import NativeAPIBackend + from cli_anything.openclaw.backends.base import BackendContext + b = NativeAPIBackend() + step = self._make_step("run_command", {"command": ["false"]}) + ctx = BackendContext(params={}, dry_run=True) + result = b.execute(step, {}, ctx) + assert result.success + assert result.output.get("dry_run") + + def test_param_substitution_in_command(self): + from cli_anything.openclaw.backends.native_api import NativeAPIBackend + b = NativeAPIBackend() + step = self._make_step("run_command", {"command": ["echo", "${msg}"]}) + result = b.execute(step, {"msg": "substituted"}, self._make_context({"msg": "substituted"})) + assert result.success + + def test_unknown_action(self): + from cli_anything.openclaw.backends.native_api import NativeAPIBackend + b = NativeAPIBackend() + step = self._make_step("unknown_action", {}) + result = b.execute(step, {}, self._make_context()) + assert not result.success + assert "unknown action" in result.error.lower() + + +class TestFileTransformBackend: + def _make_context(self): + from cli_anything.openclaw.backends.base import BackendContext + return BackendContext(params={}) + + def test_json_set_and_get(self, tmp_path): + from cli_anything.openclaw.backends.file_transform import FileTransformBackend + from cli_anything.openclaw.core.macro_model import MacroStep + b = FileTransformBackend() + ctx = self._make_context() + + json_file = tmp_path / "data.json" + json_file.write_text('{"a": 1}', encoding="utf-8") + + step = MacroStep(id="set", backend="file_transform", action="json_set", params={ + "input_file": str(json_file), + "output_file": str(json_file), + "path": "settings.theme", + "value": "dark", + }) + result = b.execute(step, {}, ctx) + assert result.success + + import json + data = json.loads(json_file.read_text()) + assert data["settings"]["theme"] == "dark" + + def test_text_replace(self, tmp_path): + from cli_anything.openclaw.backends.file_transform import FileTransformBackend + from cli_anything.openclaw.core.macro_model import MacroStep + b = FileTransformBackend() + ctx = self._make_context() + + txt_file = tmp_path / "config.ini" + txt_file.write_text("theme=default\nsize=10\n", encoding="utf-8") + + step = MacroStep(id="replace", backend="file_transform", action="text_replace", params={ + "input_file": str(txt_file), + "output_file": str(txt_file), + "find": "theme=default", + "replace": "theme=dark", + }) + result = b.execute(step, {}, ctx) + assert result.success + assert "theme=dark" in txt_file.read_text() + assert result.output["replacements"] == 1 + + def test_copy_file(self, tmp_path): + from cli_anything.openclaw.backends.file_transform import FileTransformBackend + from cli_anything.openclaw.core.macro_model import MacroStep + b = FileTransformBackend() + ctx = self._make_context() + + src = tmp_path / "src.txt" + dst = tmp_path / "dst.txt" + src.write_text("content", encoding="utf-8") + + step = MacroStep(id="copy", backend="file_transform", action="copy_file", params={ + "src": str(src), + "dst": str(dst), + }) + result = b.execute(step, {}, ctx) + assert result.success + assert dst.read_text() == "content" + + def test_unknown_action(self): + from cli_anything.openclaw.backends.file_transform import FileTransformBackend + from cli_anything.openclaw.core.macro_model import MacroStep + b = FileTransformBackend() + step = MacroStep(id="x", backend="file_transform", action="unknown_op", params={}) + result = b.execute(step, {}, self._make_context()) + assert not result.success + + +class TestStepResult: + def test_to_dict(self): + from cli_anything.openclaw.backends.base import StepResult + r = StepResult(success=True, output={"key": "val"}, backend_used="native_api") + d = r.to_dict() + assert d["success"] is True + assert d["output"]["key"] == "val" + assert d["backend_used"] == "native_api" + + +# ── Routing tests ───────────────────────────────────────────────────────────── + +class TestRoutingEngine: + def test_select_native_api(self): + from cli_anything.openclaw.core.routing import RoutingEngine + from cli_anything.openclaw.core.macro_model import MacroStep + engine = RoutingEngine() + step = MacroStep(id="x", backend="native_api", action="run_command") + backend = engine.select(step) + assert backend.name == "native_api" + + def test_select_file_transform(self): + from cli_anything.openclaw.core.routing import RoutingEngine + from cli_anything.openclaw.core.macro_model import MacroStep + engine = RoutingEngine() + step = MacroStep(id="x", backend="file_transform", action="json_set") + backend = engine.select(step) + assert backend.name == "file_transform" + + def test_describe(self): + from cli_anything.openclaw.core.routing import RoutingEngine + engine = RoutingEngine() + desc = engine.describe() + assert "native_api" in desc + assert "file_transform" in desc + assert "recovery" in desc + + def test_execute_step_native_api(self): + from cli_anything.openclaw.core.routing import RoutingEngine + from cli_anything.openclaw.core.macro_model import MacroStep + from cli_anything.openclaw.backends.base import BackendContext + engine = RoutingEngine() + step = MacroStep(id="x", backend="native_api", action="run_command", + params={"command": ["echo", "hello"]}) + ctx = BackendContext(params={}) + result = engine.execute_step(step, {}, ctx) + assert result.success + + +# ── Runtime tests ───────────────────────────────────────────────────────────── + +class TestMacroRuntime: + def _make_runtime(self, tmp_path): + from cli_anything.openclaw.core.registry import MacroRegistry + from cli_anything.openclaw.core.runtime import MacroRuntime + # Register a real macro that just echoes + yaml_content = textwrap.dedent("""\ + name: echo_macro + parameters: + msg: + type: string + required: false + default: hello + steps: + - id: step1 + backend: native_api + action: run_command + params: + command: [echo, "${msg}"] + """) + write_macro(tmp_path, "echo_macro", yaml_content) + reg = MacroRegistry(str(tmp_path)) + return MacroRuntime(registry=reg) + + def test_execute_success(self, tmp_path): + rt = self._make_runtime(tmp_path) + result = rt.execute("echo_macro", {"msg": "test"}) + assert result.success + assert result.telemetry["steps_run"] == 1 + + def test_execute_unknown_macro(self, tmp_path): + rt = self._make_runtime(tmp_path) + result = rt.execute("nonexistent_macro", {}) + assert not result.success + assert "not found" in result.error.lower() + + def test_execute_param_validation_failure(self, tmp_path): + from cli_anything.openclaw.core.registry import MacroRegistry + from cli_anything.openclaw.core.runtime import MacroRuntime + yaml_content = textwrap.dedent("""\ + name: required_param_macro + parameters: + output: + type: string + required: true + steps: + - id: s1 + backend: native_api + action: run_command + params: + command: [echo, "${output}"] + """) + write_macro(tmp_path, "required_param_macro", yaml_content) + reg = MacroRegistry(str(tmp_path)) + rt = MacroRuntime(registry=reg) + result = rt.execute("required_param_macro", {}) + assert not result.success + assert "output" in result.error + + def test_precondition_failure(self, tmp_path): + from cli_anything.openclaw.core.registry import MacroRegistry + from cli_anything.openclaw.core.runtime import MacroRuntime + yaml_content = textwrap.dedent("""\ + name: precond_macro + preconditions: + - file_exists: /nonexistent_file_xyz_abc + steps: + - id: s1 + backend: native_api + action: run_command + params: + command: [echo, ok] + """) + write_macro(tmp_path, "precond_macro", yaml_content) + reg = MacroRegistry(str(tmp_path)) + rt = MacroRuntime(registry=reg) + result = rt.execute("precond_macro", {}) + assert not result.success + assert "precondition" in result.error.lower() + + def test_postcondition_failure(self, tmp_path): + from cli_anything.openclaw.core.registry import MacroRegistry + from cli_anything.openclaw.core.runtime import MacroRuntime + yaml_content = textwrap.dedent("""\ + name: postcond_macro + steps: + - id: s1 + backend: native_api + action: run_command + params: + command: [echo, ok] + postconditions: + - file_exists: /nonexistent_output_xyz + """) + write_macro(tmp_path, "postcond_macro", yaml_content) + reg = MacroRegistry(str(tmp_path)) + rt = MacroRuntime(registry=reg) + result = rt.execute("postcond_macro", {}) + assert not result.success + assert "postcondition" in result.error.lower() + + def test_dry_run(self, tmp_path): + rt = self._make_runtime(tmp_path) + result = rt.execute("echo_macro", {}, dry_run=True) + assert result.success + assert result.telemetry["dry_run"] is True + + def test_session_records_run(self, tmp_path): + rt = self._make_runtime(tmp_path) + rt.execute("echo_macro", {}) + last = rt.session.last() + assert last is not None + assert last.macro_name == "echo_macro" + + def test_validate_macro(self, tmp_path): + rt = self._make_runtime(tmp_path) + errors = rt.validate_macro("echo_macro") + assert errors == [] + + def test_on_failure_skip(self, tmp_path): + from cli_anything.openclaw.core.registry import MacroRegistry + from cli_anything.openclaw.core.runtime import MacroRuntime + yaml_content = textwrap.dedent("""\ + name: skip_macro + steps: + - id: bad_step + backend: native_api + action: run_command + params: + command: [false] + on_failure: skip + - id: good_step + backend: native_api + action: run_command + params: + command: [echo, reached] + """) + write_macro(tmp_path, "skip_macro", yaml_content) + reg = MacroRegistry(str(tmp_path)) + rt = MacroRuntime(registry=reg) + result = rt.execute("skip_macro", {}) + # Should succeed because bad step was skipped + assert result.success + assert result.telemetry["steps_run"] == 2 + + +# ── Session tests ───────────────────────────────────────────────────────────── + +class TestExecutionSession: + def test_record_and_retrieve(self): + from cli_anything.openclaw.core.session import ExecutionSession, RunRecord + sess = ExecutionSession(session_id="test_sess") + rec = RunRecord("m1", {}, True, {}, "", 100.0, ["native_api"], 1) + sess.record(rec) + assert sess.last().macro_name == "m1" + assert len(sess.history()) == 1 + + def test_stats(self): + from cli_anything.openclaw.core.session import ExecutionSession, RunRecord + sess = ExecutionSession() + sess.record(RunRecord("m1", {}, True, {}, "", 100.0, [], 1)) + sess.record(RunRecord("m2", {}, False, {}, "err", 50.0, [], 0)) + stats = sess.stats() + assert stats["total"] == 2 + assert stats["success"] == 1 + assert stats["success_rate"] == 0.5 + + def test_save_and_load(self, tmp_path, monkeypatch): + from cli_anything.openclaw.core import session as sess_mod + import cli_anything.openclaw.core.session as sess_module + # Redirect SESSION_DIR to tmp_path + monkeypatch.setattr(sess_module, "SESSION_DIR", tmp_path) + from cli_anything.openclaw.core.session import ExecutionSession, RunRecord + sess = ExecutionSession(session_id="save_test") + sess.record(RunRecord("m1", {"k": "v"}, True, {}, "", 200.0, ["native_api"], 1)) + sess.save() + + loaded = ExecutionSession.load("save_test") + assert loaded is not None + assert loaded.session_id == "save_test" + assert loaded.last().macro_name == "m1" diff --git a/openclaw-skill/agent-harness/cli_anything/openclaw/tests/test_full_e2e.py b/openclaw-skill/agent-harness/cli_anything/openclaw/tests/test_full_e2e.py new file mode 100644 index 000000000..d7cde9a94 --- /dev/null +++ b/openclaw-skill/agent-harness/cli_anything/openclaw/tests/test_full_e2e.py @@ -0,0 +1,369 @@ +"""End-to-end integration tests for the OpenClaw Macro System. + +Tests the full lifecycle: macro discovery → runtime execution → CLI subprocess. +Uses real file I/O and subprocess calls (echo, cat, etc.) as the "target apps". +""" + +import json +import os +import subprocess +import sys +import tempfile +import textwrap +from pathlib import Path + +import pytest + + +# ── CLI resolver (same pattern as in HARNESS.md) ───────────────────────────── + +def _resolve_cli(name: str) -> list[str]: + """Resolve installed CLI command; falls back to python -m for dev.""" + import shutil + force = os.environ.get("CLI_ANYTHING_FORCE_INSTALLED", "").strip() == "1" + path = shutil.which(name) + if path: + print(f"[_resolve_cli] Using installed command: {path}") + return [path] + if force: + raise RuntimeError(f"{name} not found in PATH. Install with: pip install -e .") + module = "cli_anything.openclaw.openclaw_cli" + print(f"[_resolve_cli] Falling back to: {sys.executable} -m {module}") + return [sys.executable, "-m", "cli_anything.openclaw"] + + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +def write_macro(tmp_path: Path, name: str, content: str) -> Path: + p = tmp_path / f"{name}.yaml" + p.write_text(content, encoding="utf-8") + return p + + +# ── E2E: File transform workflow ────────────────────────────────────────────── + +class TestFileTransformE2E: + def test_json_set_and_verify(self, tmp_path): + """Write a JSON file, transform it, verify the result.""" + from cli_anything.openclaw.core.macro_model import MacroDefinition, MacroStep, MacroCondition, MacroOutput + from cli_anything.openclaw.core.registry import MacroRegistry + from cli_anything.openclaw.core.runtime import MacroRuntime + + json_file = tmp_path / "settings.json" + json_file.write_text('{"version": 1}', encoding="utf-8") + + yaml_content = textwrap.dedent(f"""\ + name: set_json_key + parameters: + file: + type: string + required: true + key: + type: string + required: true + value: + type: string + required: true + preconditions: + - file_exists: ${{file}} + steps: + - id: transform + backend: file_transform + action: json_set + params: + input_file: ${{file}} + output_file: ${{file}} + path: ${{key}} + value: ${{value}} + postconditions: + - file_exists: ${{file}} + outputs: + - name: modified_file + path: ${{file}} + """) + write_macro(tmp_path, "set_json_key", yaml_content) + + reg = MacroRegistry(str(tmp_path)) + rt = MacroRuntime(registry=reg) + result = rt.execute("set_json_key", { + "file": str(json_file), + "key": "config.theme", + "value": "dark", + }) + + assert result.success, f"Failed: {result.error}" + data = json.loads(json_file.read_text()) + assert data["config"]["theme"] == "dark" + print(f"\n Modified JSON: {json_file} → {json.dumps(data)}") + + +class TestNativeAPIE2E: + def test_run_real_command_and_capture(self, tmp_path): + """Run a real shell command and capture its stdout.""" + from cli_anything.openclaw.core.macro_model import MacroDefinition + from cli_anything.openclaw.core.registry import MacroRegistry + from cli_anything.openclaw.core.runtime import MacroRuntime + + output_file = tmp_path / "result.txt" + yaml_content = textwrap.dedent(f"""\ + name: capture_date + parameters: + output: + type: string + required: true + steps: + - id: run_date + backend: native_api + action: run_command + params: + command: [date, "+%Y-%m-%d"] + capture_stdout: true + postconditions: [] + outputs: + - name: output_file + path: ${{output}} + """) + write_macro(tmp_path, "capture_date", yaml_content) + + reg = MacroRegistry(str(tmp_path)) + rt = MacroRuntime(registry=reg) + result = rt.execute("capture_date", {"output": str(output_file)}) + + assert result.success, f"Failed: {result.error}" + # Step output contains stdout + steps_output = result.output.get("_steps", []) + assert steps_output, "Expected _steps in output" + stdout = steps_output[0].get("stdout", "") + # Date format YYYY-MM-DD + import re + assert re.match(r"\d{4}-\d{2}-\d{2}", stdout.strip()), f"Unexpected stdout: {stdout!r}" + print(f"\n Captured date: {stdout.strip()}") + + def test_step_failure_aborts_macro(self, tmp_path): + """A failing step with on_failure=fail should abort the macro.""" + from cli_anything.openclaw.core.registry import MacroRegistry + from cli_anything.openclaw.core.runtime import MacroRuntime + + yaml_content = textwrap.dedent("""\ + name: fail_abort + steps: + - id: bad + backend: native_api + action: run_command + params: + command: [false] + on_failure: fail + - id: good + backend: native_api + action: run_command + params: + command: [echo, should_not_run] + """) + write_macro(tmp_path, "fail_abort", yaml_content) + reg = MacroRegistry(str(tmp_path)) + rt = MacroRuntime(registry=reg) + result = rt.execute("fail_abort", {}) + + assert not result.success + # Only the first step should have run + assert result.telemetry["steps_run"] == 1 + print(f"\n Correctly aborted after first step: {result.error}") + + def test_step_failure_skip_continues(self, tmp_path): + """A failing step with on_failure=skip should allow the macro to continue.""" + from cli_anything.openclaw.core.registry import MacroRegistry + from cli_anything.openclaw.core.runtime import MacroRuntime + + yaml_content = textwrap.dedent("""\ + name: fail_skip + steps: + - id: bad + backend: native_api + action: run_command + params: + command: [false] + on_failure: skip + - id: good + backend: native_api + action: run_command + params: + command: [echo, still_ran] + """) + write_macro(tmp_path, "fail_skip", yaml_content) + reg = MacroRegistry(str(tmp_path)) + rt = MacroRuntime(registry=reg) + result = rt.execute("fail_skip", {}) + + assert result.success + assert result.telemetry["steps_run"] == 2 + print(f"\n Macro succeeded despite skipped step") + + +class TestPostconditionE2E: + def test_postcondition_file_exists_passes(self, tmp_path): + from cli_anything.openclaw.core.registry import MacroRegistry + from cli_anything.openclaw.core.runtime import MacroRuntime + + output_file = tmp_path / "output.txt" + yaml_content = textwrap.dedent(f"""\ + name: write_and_verify + parameters: + output: + type: string + required: true + steps: + - id: write + backend: file_transform + action: text_replace + params: + input_file: /dev/null + output_file: ${{output}} + find: "" + replace: "" + postconditions: + - file_exists: ${{output}} + """) + write_macro(tmp_path, "write_and_verify", yaml_content) + reg = MacroRegistry(str(tmp_path)) + rt = MacroRuntime(registry=reg) + result = rt.execute("write_and_verify", {"output": str(output_file)}) + assert result.success, f"Failed: {result.error}" + print(f"\n Output file: {output_file} ({output_file.stat().st_size} bytes)") + + def test_postcondition_file_size_gt(self, tmp_path): + from cli_anything.openclaw.core.registry import MacroRegistry + from cli_anything.openclaw.core.runtime import MacroRuntime + + output_file = tmp_path / "output.txt" + output_file.write_text("x" * 200, encoding="utf-8") + + yaml_content = textwrap.dedent(f"""\ + name: size_check + parameters: + output: + type: string + required: true + steps: + - id: noop + backend: native_api + action: run_command + params: + command: [echo, noop] + postconditions: + - file_size_gt: + - ${{output}} + - 100 + """) + write_macro(tmp_path, "size_check", yaml_content) + reg = MacroRegistry(str(tmp_path)) + rt = MacroRuntime(registry=reg) + result = rt.execute("size_check", {"output": str(output_file)}) + assert result.success, f"Failed: {result.error}" + print(f"\n File size: {output_file.stat().st_size} bytes") + + +# ── CLI subprocess tests ────────────────────────────────────────────────────── + +class TestCLISubprocess: + CLI_BASE = _resolve_cli("cli-anything-openclaw") + + def _run(self, args: list[str], check: bool = True) -> subprocess.CompletedProcess: + return subprocess.run( + self.CLI_BASE + args, + capture_output=True, + text=True, + check=check, + ) + + def test_help(self): + result = self._run(["--help"]) + assert result.returncode == 0 + assert "macro" in result.stdout.lower() + + def test_macro_list_json(self): + result = self._run(["--json", "macro", "list"]) + assert result.returncode == 0 + data = json.loads(result.stdout) + assert isinstance(data, list) + print(f"\n macros: {[m['name'] for m in data]}") + + def test_macro_info_json(self): + # Info on a bundled example macro + result = self._run(["--json", "macro", "info", "export_file"]) + assert result.returncode == 0, f"stderr: {result.stderr}" + data = json.loads(result.stdout) + assert data["name"] == "export_file" + assert "parameters" in data + print(f"\n Macro info: {data['name']} v{data['version']}") + + def test_macro_validate_all(self): + result = self._run(["--json", "macro", "validate"]) + assert result.returncode == 0 + data = json.loads(result.stdout) + assert isinstance(data, dict) + for name, info in data.items(): + assert info["valid"], f"Macro {name} failed: {info['errors']}" + print(f"\n Validated {len(data)} macros, all valid") + + def test_macro_dry_run_json(self): + result = self._run([ + "--json", "--dry-run", + "macro", "run", "export_file", + "--param", "output=/tmp/test_openclaw_e2e.txt", + ]) + assert result.returncode == 0, f"stderr: {result.stderr}" + data = json.loads(result.stdout) + assert data["success"] is True + assert data["telemetry"]["dry_run"] is True + print(f"\n Dry run result: {data['success']}") + + def test_backends_json(self): + result = self._run(["--json", "backends"]) + assert result.returncode == 0 + data = json.loads(result.stdout) + assert "native_api" in data + assert "file_transform" in data + print(f"\n Backends: {list(data.keys())}") + + def test_session_status_json(self): + result = self._run(["--json", "session", "status"]) + assert result.returncode == 0 + data = json.loads(result.stdout) + assert "session_id" in data + print(f"\n Session: {data['session_id']}") + + def test_macro_run_json_transform_workflow(self, tmp_path): + """Full E2E: create a JSON file, run transform_json macro, verify output.""" + json_file = tmp_path / "data.json" + json_file.write_text('{"name": "test"}', encoding="utf-8") + + result = self._run([ + "--json", + "macro", "run", "transform_json", + "--param", f"file={json_file}", + "--param", "key=config.mode", + "--param", "value=production", + ]) + print(f"\n CLI stdout: {result.stdout[:200]}") + print(f" CLI stderr: {result.stderr[:200]}") + + assert result.returncode == 0, f"CLI failed:\n{result.stderr}" + data = json.loads(result.stdout) + assert data["success"] is True, f"Macro failed: {data.get('error')}" + + # Verify file was actually modified + modified = json.loads(json_file.read_text()) + assert modified["config"]["mode"] == "production" + print(f"\n Modified JSON: {modified}") + print(f" File: {json_file} ({json_file.stat().st_size} bytes)") + + def test_unknown_macro_returns_error_json(self): + result = self._run( + ["--json", "macro", "run", "nonexistent_macro_xyz"], + check=False, + ) + assert result.returncode != 0 + data = json.loads(result.stdout) + assert data["success"] is False + assert "error" in data + print(f"\n Error: {data['error']}") diff --git a/openclaw-skill/agent-harness/cli_anything/openclaw/utils/__init__.py b/openclaw-skill/agent-harness/cli_anything/openclaw/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/openclaw-skill/agent-harness/cli_anything/openclaw/utils/repl_skin.py b/openclaw-skill/agent-harness/cli_anything/openclaw/utils/repl_skin.py new file mode 100644 index 000000000..c7312348a --- /dev/null +++ b/openclaw-skill/agent-harness/cli_anything/openclaw/utils/repl_skin.py @@ -0,0 +1,521 @@ +"""cli-anything REPL Skin — Unified terminal interface for all CLI harnesses. + +Copy this file into your CLI package at: + cli_anything//utils/repl_skin.py + +Usage: + from cli_anything..utils.repl_skin import ReplSkin + + skin = ReplSkin("shotcut", version="1.0.0") + skin.print_banner() # auto-detects skills/SKILL.md inside the package + prompt_text = skin.prompt(project_name="my_video.mlt", modified=True) + skin.success("Project saved") + skin.error("File not found") + skin.warning("Unsaved changes") + skin.info("Processing 24 clips...") + skin.status("Track 1", "3 clips, 00:02:30") + skin.table(headers, rows) + skin.print_goodbye() +""" + +import os +import sys + +# ── ANSI color codes (no external deps for core styling) ────────────── + +_RESET = "\033[0m" +_BOLD = "\033[1m" +_DIM = "\033[2m" +_ITALIC = "\033[3m" +_UNDERLINE = "\033[4m" + +# Brand colors +_CYAN = "\033[38;5;80m" # cli-anything brand cyan +_CYAN_BG = "\033[48;5;80m" +_WHITE = "\033[97m" +_GRAY = "\033[38;5;245m" +_DARK_GRAY = "\033[38;5;240m" +_LIGHT_GRAY = "\033[38;5;250m" + +# Software accent colors — each software gets a unique accent +_ACCENT_COLORS = { + "gimp": "\033[38;5;214m", # warm orange + "blender": "\033[38;5;208m", # deep orange + "inkscape": "\033[38;5;39m", # bright blue + "audacity": "\033[38;5;33m", # navy blue + "libreoffice": "\033[38;5;40m", # green + "obs_studio": "\033[38;5;55m", # purple + "kdenlive": "\033[38;5;69m", # slate blue + "shotcut": "\033[38;5;35m", # teal green +} +_DEFAULT_ACCENT = "\033[38;5;75m" # default sky blue + +# Status colors +_GREEN = "\033[38;5;78m" +_YELLOW = "\033[38;5;220m" +_RED = "\033[38;5;196m" +_BLUE = "\033[38;5;75m" +_MAGENTA = "\033[38;5;176m" + +# ── Brand icon ──────────────────────────────────────────────────────── + +# The cli-anything icon: a small colored diamond/chevron mark +_ICON = f"{_CYAN}{_BOLD}◆{_RESET}" +_ICON_SMALL = f"{_CYAN}▸{_RESET}" + +# ── Box drawing characters ──────────────────────────────────────────── + +_H_LINE = "─" +_V_LINE = "│" +_TL = "╭" +_TR = "╮" +_BL = "╰" +_BR = "╯" +_T_DOWN = "┬" +_T_UP = "┴" +_T_RIGHT = "├" +_T_LEFT = "┤" +_CROSS = "┼" + + +def _strip_ansi(text: str) -> str: + """Remove ANSI escape codes for length calculation.""" + import re + return re.sub(r"\033\[[^m]*m", "", text) + + +def _visible_len(text: str) -> int: + """Get visible length of text (excluding ANSI codes).""" + return len(_strip_ansi(text)) + + +class ReplSkin: + """Unified REPL skin for cli-anything CLIs. + + Provides consistent branding, prompts, and message formatting + across all CLI harnesses built with the cli-anything methodology. + """ + + def __init__(self, software: str, version: str = "1.0.0", + history_file: str | None = None, skill_path: str | None = None): + """Initialize the REPL skin. + + Args: + software: Software name (e.g., "gimp", "shotcut", "blender"). + version: CLI version string. + history_file: Path for persistent command history. + Defaults to ~/.cli-anything-/history + skill_path: Path to the SKILL.md file for agent discovery. + Auto-detected from the package's skills/ directory if not provided. + Displayed in banner for AI agents to know where to read skill info. + """ + self.software = software.lower().replace("-", "_") + self.display_name = software.replace("_", " ").title() + self.version = version + + # Auto-detect skill path from package layout: + # cli_anything//utils/repl_skin.py (this file) + # cli_anything//skills/SKILL.md (target) + if skill_path is None: + from pathlib import Path + _auto = Path(__file__).resolve().parent.parent / "skills" / "SKILL.md" + if _auto.is_file(): + skill_path = str(_auto) + self.skill_path = skill_path + self.accent = _ACCENT_COLORS.get(self.software, _DEFAULT_ACCENT) + + # History file + if history_file is None: + from pathlib import Path + hist_dir = Path.home() / f".cli-anything-{self.software}" + hist_dir.mkdir(parents=True, exist_ok=True) + self.history_file = str(hist_dir / "history") + else: + self.history_file = history_file + + # Detect terminal capabilities + self._color = self._detect_color_support() + + def _detect_color_support(self) -> bool: + """Check if terminal supports color.""" + if os.environ.get("NO_COLOR"): + return False + if os.environ.get("CLI_ANYTHING_NO_COLOR"): + return False + if not hasattr(sys.stdout, "isatty"): + return False + return sys.stdout.isatty() + + def _c(self, code: str, text: str) -> str: + """Apply color code if colors are supported.""" + if not self._color: + return text + return f"{code}{text}{_RESET}" + + # ── Banner ──────────────────────────────────────────────────────── + + def print_banner(self): + """Print the startup banner with branding.""" + inner = 54 + + def _box_line(content: str) -> str: + """Wrap content in box drawing, padding to inner width.""" + pad = inner - _visible_len(content) + vl = self._c(_DARK_GRAY, _V_LINE) + return f"{vl}{content}{' ' * max(0, pad)}{vl}" + + top = self._c(_DARK_GRAY, f"{_TL}{_H_LINE * inner}{_TR}") + bot = self._c(_DARK_GRAY, f"{_BL}{_H_LINE * inner}{_BR}") + + # Title: ◆ cli-anything · Shotcut + icon = self._c(_CYAN + _BOLD, "◆") + brand = self._c(_CYAN + _BOLD, "cli-anything") + dot = self._c(_DARK_GRAY, "·") + name = self._c(self.accent + _BOLD, self.display_name) + title = f" {icon} {brand} {dot} {name}" + + ver = f" {self._c(_DARK_GRAY, f' v{self.version}')}" + tip = f" {self._c(_DARK_GRAY, ' Type help for commands, quit to exit')}" + empty = "" + + # Skill path for agent discovery + skill_line = None + if self.skill_path: + skill_icon = self._c(_MAGENTA, "◇") + skill_label = self._c(_DARK_GRAY, " Skill:") + skill_path_display = self._c(_LIGHT_GRAY, self.skill_path) + skill_line = f" {skill_icon} {skill_label} {skill_path_display}" + + print(top) + print(_box_line(title)) + print(_box_line(ver)) + if skill_line: + print(_box_line(skill_line)) + print(_box_line(empty)) + print(_box_line(tip)) + print(bot) + print() + + # ── Prompt ──────────────────────────────────────────────────────── + + def prompt(self, project_name: str = "", modified: bool = False, + context: str = "") -> str: + """Build a styled prompt string for prompt_toolkit or input(). + + Args: + project_name: Current project name (empty if none open). + modified: Whether the project has unsaved changes. + context: Optional extra context to show in prompt. + + Returns: + Formatted prompt string. + """ + parts = [] + + # Icon + if self._color: + parts.append(f"{_CYAN}◆{_RESET} ") + else: + parts.append("> ") + + # Software name + parts.append(self._c(self.accent + _BOLD, self.software)) + + # Project context + if project_name or context: + ctx = context or project_name + mod = "*" if modified else "" + parts.append(f" {self._c(_DARK_GRAY, '[')}") + parts.append(self._c(_LIGHT_GRAY, f"{ctx}{mod}")) + parts.append(self._c(_DARK_GRAY, ']')) + + parts.append(self._c(_GRAY, " ❯ ")) + + return "".join(parts) + + def prompt_tokens(self, project_name: str = "", modified: bool = False, + context: str = ""): + """Build prompt_toolkit formatted text tokens for the prompt. + + Use with prompt_toolkit's FormattedText for proper ANSI handling. + + Returns: + list of (style, text) tuples for prompt_toolkit. + """ + accent_hex = _ANSI_256_TO_HEX.get(self.accent, "#5fafff") + tokens = [] + + tokens.append(("class:icon", "◆ ")) + tokens.append(("class:software", self.software)) + + if project_name or context: + ctx = context or project_name + mod = "*" if modified else "" + tokens.append(("class:bracket", " [")) + tokens.append(("class:context", f"{ctx}{mod}")) + tokens.append(("class:bracket", "]")) + + tokens.append(("class:arrow", " ❯ ")) + + return tokens + + def get_prompt_style(self): + """Get a prompt_toolkit Style object matching the skin. + + Returns: + prompt_toolkit.styles.Style + """ + try: + from prompt_toolkit.styles import Style + except ImportError: + return None + + accent_hex = _ANSI_256_TO_HEX.get(self.accent, "#5fafff") + + return Style.from_dict({ + "icon": "#5fdfdf bold", # cyan brand color + "software": f"{accent_hex} bold", + "bracket": "#585858", + "context": "#bcbcbc", + "arrow": "#808080", + # Completion menu + "completion-menu.completion": "bg:#303030 #bcbcbc", + "completion-menu.completion.current": f"bg:{accent_hex} #000000", + "completion-menu.meta.completion": "bg:#303030 #808080", + "completion-menu.meta.completion.current": f"bg:{accent_hex} #000000", + # Auto-suggest + "auto-suggest": "#585858", + # Bottom toolbar + "bottom-toolbar": "bg:#1c1c1c #808080", + "bottom-toolbar.text": "#808080", + }) + + # ── Messages ────────────────────────────────────────────────────── + + def success(self, message: str): + """Print a success message with green checkmark.""" + icon = self._c(_GREEN + _BOLD, "✓") + print(f" {icon} {self._c(_GREEN, message)}") + + def error(self, message: str): + """Print an error message with red cross.""" + icon = self._c(_RED + _BOLD, "✗") + print(f" {icon} {self._c(_RED, message)}", file=sys.stderr) + + def warning(self, message: str): + """Print a warning message with yellow triangle.""" + icon = self._c(_YELLOW + _BOLD, "⚠") + print(f" {icon} {self._c(_YELLOW, message)}") + + def info(self, message: str): + """Print an info message with blue dot.""" + icon = self._c(_BLUE, "●") + print(f" {icon} {self._c(_LIGHT_GRAY, message)}") + + def hint(self, message: str): + """Print a subtle hint message.""" + print(f" {self._c(_DARK_GRAY, message)}") + + def section(self, title: str): + """Print a section header.""" + print() + print(f" {self._c(self.accent + _BOLD, title)}") + print(f" {self._c(_DARK_GRAY, _H_LINE * len(title))}") + + # ── Status display ──────────────────────────────────────────────── + + def status(self, label: str, value: str): + """Print a key-value status line.""" + lbl = self._c(_GRAY, f" {label}:") + val = self._c(_WHITE, f" {value}") + print(f"{lbl}{val}") + + def status_block(self, items: dict[str, str], title: str = ""): + """Print a block of status key-value pairs. + + Args: + items: Dict of label -> value pairs. + title: Optional title for the block. + """ + if title: + self.section(title) + + max_key = max(len(k) for k in items) if items else 0 + for label, value in items.items(): + lbl = self._c(_GRAY, f" {label:<{max_key}}") + val = self._c(_WHITE, f" {value}") + print(f"{lbl}{val}") + + def progress(self, current: int, total: int, label: str = ""): + """Print a simple progress indicator. + + Args: + current: Current step number. + total: Total number of steps. + label: Optional label for the progress. + """ + pct = int(current / total * 100) if total > 0 else 0 + bar_width = 20 + filled = int(bar_width * current / total) if total > 0 else 0 + bar = "█" * filled + "░" * (bar_width - filled) + text = f" {self._c(_CYAN, bar)} {self._c(_GRAY, f'{pct:3d}%')}" + if label: + text += f" {self._c(_LIGHT_GRAY, label)}" + print(text) + + # ── Table display ───────────────────────────────────────────────── + + def table(self, headers: list[str], rows: list[list[str]], + max_col_width: int = 40): + """Print a formatted table with box-drawing characters. + + Args: + headers: Column header strings. + rows: List of rows, each a list of cell strings. + max_col_width: Maximum column width before truncation. + """ + if not headers: + return + + # Calculate column widths + col_widths = [min(len(h), max_col_width) for h in headers] + for row in rows: + for i, cell in enumerate(row): + if i < len(col_widths): + col_widths[i] = min( + max(col_widths[i], len(str(cell))), max_col_width + ) + + def pad(text: str, width: int) -> str: + t = str(text)[:width] + return t + " " * (width - len(t)) + + # Header + header_cells = [ + self._c(_CYAN + _BOLD, pad(h, col_widths[i])) + for i, h in enumerate(headers) + ] + sep = self._c(_DARK_GRAY, f" {_V_LINE} ") + header_line = f" {sep.join(header_cells)}" + print(header_line) + + # Separator + sep_parts = [self._c(_DARK_GRAY, _H_LINE * w) for w in col_widths] + sep_line = self._c(_DARK_GRAY, f" {'───'.join([_H_LINE * w for w in col_widths])}") + print(sep_line) + + # Rows + for row in rows: + cells = [] + for i, cell in enumerate(row): + if i < len(col_widths): + cells.append(self._c(_LIGHT_GRAY, pad(str(cell), col_widths[i]))) + row_sep = self._c(_DARK_GRAY, f" {_V_LINE} ") + print(f" {row_sep.join(cells)}") + + # ── Help display ────────────────────────────────────────────────── + + def help(self, commands: dict[str, str]): + """Print a formatted help listing. + + Args: + commands: Dict of command -> description pairs. + """ + self.section("Commands") + max_cmd = max(len(c) for c in commands) if commands else 0 + for cmd, desc in commands.items(): + cmd_styled = self._c(self.accent, f" {cmd:<{max_cmd}}") + desc_styled = self._c(_GRAY, f" {desc}") + print(f"{cmd_styled}{desc_styled}") + print() + + # ── Goodbye ─────────────────────────────────────────────────────── + + def print_goodbye(self): + """Print a styled goodbye message.""" + print(f"\n {_ICON_SMALL} {self._c(_GRAY, 'Goodbye!')}\n") + + # ── Prompt toolkit session factory ──────────────────────────────── + + def create_prompt_session(self): + """Create a prompt_toolkit PromptSession with skin styling. + + Returns: + A configured PromptSession, or None if prompt_toolkit unavailable. + """ + try: + from prompt_toolkit import PromptSession + from prompt_toolkit.history import FileHistory + from prompt_toolkit.auto_suggest import AutoSuggestFromHistory + from prompt_toolkit.formatted_text import FormattedText + + style = self.get_prompt_style() + + session = PromptSession( + history=FileHistory(self.history_file), + auto_suggest=AutoSuggestFromHistory(), + style=style, + enable_history_search=True, + ) + return session + except ImportError: + return None + + def get_input(self, pt_session, project_name: str = "", + modified: bool = False, context: str = "") -> str: + """Get input from user using prompt_toolkit or fallback. + + Args: + pt_session: A prompt_toolkit PromptSession (or None). + project_name: Current project name. + modified: Whether project has unsaved changes. + context: Optional context string. + + Returns: + User input string (stripped). + """ + if pt_session is not None: + from prompt_toolkit.formatted_text import FormattedText + tokens = self.prompt_tokens(project_name, modified, context) + return pt_session.prompt(FormattedText(tokens)).strip() + else: + raw_prompt = self.prompt(project_name, modified, context) + return input(raw_prompt).strip() + + # ── Toolbar builder ─────────────────────────────────────────────── + + def bottom_toolbar(self, items: dict[str, str]): + """Create a bottom toolbar callback for prompt_toolkit. + + Args: + items: Dict of label -> value pairs to show in toolbar. + + Returns: + A callable that returns FormattedText for the toolbar. + """ + def toolbar(): + from prompt_toolkit.formatted_text import FormattedText + parts = [] + for i, (k, v) in enumerate(items.items()): + if i > 0: + parts.append(("class:bottom-toolbar.text", " │ ")) + parts.append(("class:bottom-toolbar.text", f" {k}: ")) + parts.append(("class:bottom-toolbar", v)) + return FormattedText(parts) + return toolbar + + +# ── ANSI 256-color to hex mapping (for prompt_toolkit styles) ───────── + +_ANSI_256_TO_HEX = { + "\033[38;5;33m": "#0087ff", # audacity navy blue + "\033[38;5;35m": "#00af5f", # shotcut teal + "\033[38;5;39m": "#00afff", # inkscape bright blue + "\033[38;5;40m": "#00d700", # libreoffice green + "\033[38;5;55m": "#5f00af", # obs purple + "\033[38;5;69m": "#5f87ff", # kdenlive slate blue + "\033[38;5;75m": "#5fafff", # default sky blue + "\033[38;5;80m": "#5fd7d7", # brand cyan + "\033[38;5;208m": "#ff8700", # blender deep orange + "\033[38;5;214m": "#ffaf00", # gimp warm orange +} diff --git a/openclaw-skill/agent-harness/setup.py b/openclaw-skill/agent-harness/setup.py new file mode 100644 index 000000000..69c962550 --- /dev/null +++ b/openclaw-skill/agent-harness/setup.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +""" +setup.py for cli-anything-openclaw + +Install with: pip install -e . +""" + +from setuptools import setup, find_namespace_packages + +with open("cli_anything/openclaw/README.md", "r", encoding="utf-8") as fh: + long_description = fh.read() + +setup( + name="cli-anything-openclaw", + version="1.0.0", + author="cli-anything contributors", + author_email="", + description=( + "OpenClaw Macro System — A layered CLI that converts GUI workflows into " + "parameterized, agent-callable macros. Requires: PyYAML, click, prompt-toolkit." + ), + long_description=long_description, + long_description_content_type="text/markdown", + url="https://github.com/HKUDS/CLI-Anything", + packages=find_namespace_packages(include=["cli_anything.*"]), + classifiers=[ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Topic :: Software Development :: Libraries :: Python Modules", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + ], + python_requires=">=3.10", + install_requires=[ + "click>=8.0.0", + "prompt-toolkit>=3.0.0", + "PyYAML>=6.0", + ], + extras_require={ + "dev": [ + "pytest>=7.0.0", + "pytest-cov>=4.0.0", + ], + }, + entry_points={ + "console_scripts": [ + "cli-anything-openclaw=cli_anything.openclaw.openclaw_cli:cli", + ], + }, + package_data={ + "cli_anything.openclaw": ["skills/*.md", "macro_definitions/*.yaml", "macro_definitions/examples/*.yaml"], + }, + include_package_data=True, + zip_safe=False, +) From d6002a1f4b2e93355ab77e475f8cef00136fdba1 Mon Sep 17 00:00:00 2001 From: haorui-harry <2224882012@qq.com> Date: Fri, 17 Apr 2026 16:28:04 +0800 Subject: [PATCH 11/22] feat(openclaw): add OPENCLAW.md SOP and registry.json entry --- openclaw-skill/agent-harness/OPENCLAW.md | 185 +++++++++++++++++++++++ registry.json | 19 +++ 2 files changed, 204 insertions(+) create mode 100644 openclaw-skill/agent-harness/OPENCLAW.md diff --git a/openclaw-skill/agent-harness/OPENCLAW.md b/openclaw-skill/agent-harness/OPENCLAW.md new file mode 100644 index 000000000..4cfddd711 --- /dev/null +++ b/openclaw-skill/agent-harness/OPENCLAW.md @@ -0,0 +1,185 @@ +# OpenClaw Macro System — Agent Harness SOP + +## What Is This? + +**OpenClaw Macro System** is a layered CLI that turns valuable GUI workflows into +parameterized, agent-callable macros. The agent sends one command: + +```bash +cli-anything-openclaw macro run export_png --param output=/tmp/out.png --json +``` + +The system handles everything else: parameter validation, precondition checks, +backend selection, step execution, postcondition verification, and structured +result output. The agent never touches the GUI directly. + +## Architecture + +``` +Agent + └─▶ cli-anything-openclaw macro run --param k=v --json (L6: CLI) + │ + MacroRuntime (L5) + │ 1. Validate params against MacroDefinition schema + │ 2. Check preconditions (file_exists, process_running, …) + │ 3. For each step: + │ RoutingEngine → select backend by priority (L3) + │ Backend.execute(step, resolved_params) (L2) + │ 4. Check postconditions + │ 5. Collect declared outputs + │ 6. Record telemetry in ExecutionSession + └─▶ { success, output, error, telemetry } +``` + +## Layer Mapping + +| Layer | Name | Implementation | +|-------|------|---------------| +| L7 | Agent Task Interface | Caller (OpenClaw or any agent) | +| L6 | Unified CLI Entry | `openclaw_cli.py` — Click CLI | +| L5 | Macro Execution Runtime | `core/runtime.py` | +| L4 | Parameterized Macro Model | `core/macro_model.py` + `macro_definitions/*.yaml` | +| L3 | Backend Routing Engine | `core/routing.py` | +| L2 | Execution Backends | `backends/` (5 backends) | +| L1 | Target Application | Any GUI-first or closed-source app | + +## Execution Backends + +| Backend | Priority | Trigger | Use case | +|---------|----------|---------|----------| +| `native_api` | 100 | `backend: native_api` | subprocess / shell commands | +| `gui_macro` | 80 | `backend: gui_macro` | precompiled coordinate replay (pyautogui) | +| `file_transform` | 70 | `backend: file_transform` | XML, JSON, text file editing | +| `semantic_ui` | 50 | `backend: semantic_ui` | accessibility API + keyboard (xdotool) | +| `recovery` | 10 | `backend: recovery` | retry + fallback orchestration | + +The RoutingEngine respects the step's explicit `backend:` field; if that backend +is unavailable it walks down the priority list. + +## Macro Definition Format + +Macros live in `cli_anything/openclaw/macro_definitions/` as YAML files: + +```yaml +name: export_png +version: "1.0" +description: Export the active diagram to PNG. + +parameters: + output: + type: string + required: true + example: /tmp/diagram.png + +preconditions: + - process_running: draw.io + - file_exists: /path/to/input.drawio + +steps: + - id: export + backend: native_api + action: run_command + params: + command: [draw.io, --export, --output, "${output}", input.drawio] + timeout_ms: 30000 + on_failure: fail # or: skip | continue + +postconditions: + - file_exists: ${output} + - file_size_gt: + - ${output} + - 100 + +outputs: + - name: exported_file + path: ${output} + +agent_hints: + danger_level: safe + side_effects: [creates_file] + reversible: true +``` + +### Supported Condition Types + +| Type | Args | Checks | +|------|------|--------| +| `file_exists` | path | `os.path.exists(path)` | +| `file_size_gt` | [path, min_bytes] | `os.stat(path).st_size > min_bytes` | +| `process_running` | name | `pgrep -x name` or psutil | +| `env_var` | name | `name in os.environ` | +| `always` | true/false | constant pass/fail | + +## Package Layout + +``` +openclaw-skill/ +└── agent-harness/ + ├── setup.py entry_point: cli-anything-openclaw + └── cli_anything/openclaw/ + ├── openclaw_cli.py Main Click CLI + ├── macro_definitions/ YAML macro registry + │ ├── manifest.yaml + │ └── examples/ + │ ├── export_file.yaml + │ ├── transform_json.yaml + │ └── undo_last.yaml + ├── core/ + │ ├── macro_model.py MacroDefinition + YAML loader + │ ├── registry.py MacroRegistry + │ ├── routing.py RoutingEngine + │ ├── runtime.py MacroRuntime (full lifecycle) + │ └── session.py ExecutionSession + telemetry + ├── backends/ + │ ├── base.py Backend ABC + StepResult + │ ├── native_api.py subprocess backend + │ ├── file_transform.py XML/JSON/text backend + │ ├── semantic_ui.py accessibility backend + │ ├── gui_macro.py compiled replay backend + │ └── recovery.py retry/fallback backend + ├── skills/SKILL.md Agent-readable skill definition + ├── utils/repl_skin.py Unified REPL skin (cli-anything standard) + └── tests/ + ├── test_core.py Unit tests (49 tests, no external deps) + └── test_full_e2e.py E2E + CLI subprocess tests (15 tests) +``` + +## Installation + +```bash +cd openclaw-skill/agent-harness +pip install -e . +``` + +**Runtime dependencies:** Python 3.10+, PyYAML, click, prompt-toolkit. + +**Optional (for specific backends):** +- `xdotool` — semantic_ui backend on Linux +- `pyautogui` — gui_macro backend +- `psutil` — richer process_running checks + +## Running Tests + +```bash +cd openclaw-skill/agent-harness +python3 -m pytest cli_anything/openclaw/tests/ -v -s +# 64 passed +``` + +## Key Design Decisions + +**Why YAML macros, not Python?** YAML macros are readable by agents without +running code, inspectable via `macro info`, and editable without touching the +harness source. + +**Why 5 backends?** Real GUI applications expose many different control +surfaces. The routing engine picks the most reliable one available — the agent +doesn't need to know which one ran. + +**Why preconditions and postconditions?** Agents operate in environments where +state is uncertain. Failing loudly before execution (preconditions) and +verifying after (postconditions) catches problems the agent can act on. + +**Why `on_failure: skip | continue`?** Some macro steps are best-effort (e.g., +confirming a dialog that may or may not appear). Skipping lets the macro +continue to the real work. diff --git a/registry.json b/registry.json index 4c48bdb72..26757b799 100644 --- a/registry.json +++ b/registry.json @@ -896,6 +896,25 @@ "url": "https://github.com/achiya-automation" } ] + }, + { + "name": "openclaw-macro", + "display_name": "OpenClaw Macro System", + "version": "1.0.0", + "description": "Layered CLI that converts GUI workflows into parameterized, agent-callable macros — with backend routing across native APIs, file transforms, accessibility controls, and compiled GUI replay", + "requires": null, + "homepage": "https://github.com/HKUDS/CLI-Anything", + "source_url": null, + "install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=openclaw-skill/agent-harness", + "entry_point": "cli-anything-openclaw", + "skill_md": "openclaw-skill/agent-harness/cli_anything/openclaw/skills/SKILL.md", + "category": "automation", + "contributors": [ + { + "name": "haorui-harry", + "url": "https://github.com/haorui-harry" + } + ] } ] } From 13904df1aa5586637047fa40ac38c479451edcb6 Mon Sep 17 00:00:00 2001 From: yuhao Date: Fri, 17 Apr 2026 15:46:59 +0000 Subject: [PATCH 12/22] update: sync public registry metadata --- public_registry.json | 56 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/public_registry.json b/public_registry.json index dec2f6965..d742682d1 100644 --- a/public_registry.json +++ b/public_registry.json @@ -18,6 +18,7 @@ "npm_package": "@larksuite/cli", "install_cmd": "npm install -g @larksuite/cli", "npx_cmd": "npx @larksuite/cli", + "skill_md": "npx skills add larksuite/cli -y -g", "entry_point": "lark-cli", "contributors": [ { @@ -39,6 +40,7 @@ "npm_package": "minimax-cli", "install_cmd": "npm install -g minimax-cli", "npx_cmd": "npx minimax-cli", + "skill_md": "https://platform.minimax.io/docs/token-plan/minimax-cli", "entry_point": "minimax-cli", "contributors": [ { @@ -60,6 +62,7 @@ "npm_package": "@wecom/cli", "install_cmd": "npm install -g @wecom/cli", "npx_cmd": "npx @wecom/cli", + "skill_md": "npx skills add WeComTeam/wecom-cli -y -g", "entry_point": "wecom-cli", "contributors": [ { @@ -81,6 +84,7 @@ "npm_package": "contentful-cli", "install_cmd": "npm install -g contentful-cli", "npx_cmd": "npx contentful-cli", + "skill_md": "https://github.com/contentful/contentful-cli/tree/main/docs", "entry_point": "contentful", "contributors": [ { @@ -102,6 +106,7 @@ "npm_package": "sanity", "install_cmd": "npm install -g sanity", "npx_cmd": "npx sanity@latest", + "skill_md": "https://www.sanity.io/docs/apis-and-sdks/cli", "entry_point": "sanity", "contributors": [ { @@ -123,6 +128,7 @@ "npm_package": "@shopify/cli", "install_cmd": "npm install -g @shopify/cli", "npx_cmd": "npx @shopify/cli@latest", + "skill_md": "https://github.com/Shopify/cli/blob/main/packages/cli/README.md#commands", "entry_point": "shopify", "contributors": [ { @@ -144,6 +150,7 @@ "npm_package": "@sentry/cli", "install_cmd": "npm install -g @sentry/cli", "npx_cmd": "npx @sentry/cli", + "skill_md": "https://docs.sentry.io/cli/", "entry_point": "sentry-cli", "contributors": [ { @@ -166,6 +173,7 @@ "install_cmd": "brew install --cask 1password-cli", "uninstall_cmd": "brew uninstall --cask 1password-cli", "update_cmd": "brew upgrade --cask 1password-cli", + "skill_md": "https://developer.1password.com/docs/cli/get-started/", "entry_point": "op", "contributors": [ { @@ -187,6 +195,7 @@ "install_cmd": "uv tool install git+https://github.com/charles-forsyth/generate-veo-video.git", "uninstall_cmd": "uv tool uninstall generate-veo-video", "update_cmd": "uv tool upgrade generate-veo-video", + "skill_md": "https://github.com/charles-forsyth/generate-veo-video", "entry_point": "generate-veo", "contributors": [ { @@ -195,6 +204,51 @@ } ] }, + { + "name": "suno", + "display_name": "Suno CLI", + "version": "latest", + "description": "CLI for generating music with Suno AI from lyrics and style prompts, with batch generation, status polling, downloads, and automatic MP3 tagging", + "category": "music", + "requires": "Python 3 and a Suno API key from sunoapi.org", + "homepage": "https://github.com/slauger/suno-cli", + "source_url": "https://github.com/slauger/suno-cli", + "package_manager": "pip", + "install_strategy": "command", + "install_cmd": "python3 -m pip install git+https://github.com/slauger/suno-cli.git", + "uninstall_cmd": "python3 -m pip uninstall -y suno-cli", + "update_cmd": "python3 -m pip install --upgrade --force-reinstall git+https://github.com/slauger/suno-cli.git", + "skill_md": "https://github.com/slauger/suno-cli/blob/main/docs/USAGE.md", + "entry_point": "suno", + "contributors": [ + { + "name": "slauger", + "url": "https://github.com/slauger" + } + ] + }, + { + "name": "elevenlabs", + "display_name": "ElevenLabs CLI", + "version": "latest", + "description": "Official ElevenLabs CLI for managing voice agents as code with local configs, templates, auth, push/pull sync, tests, widgets, and branch-aware workflows", + "category": "audio", + "requires": "Node.js and npm, ELEVENLABS_API_KEY or interactive login", + "homepage": "https://github.com/elevenlabs/cli", + "source_url": "https://github.com/elevenlabs/cli", + "package_manager": "npm", + "npm_package": "@elevenlabs/cli", + "install_cmd": "npm install -g @elevenlabs/cli", + "npx_cmd": "npx @elevenlabs/cli@latest", + "skill_md": "https://github.com/elevenlabs/cli", + "entry_point": "elevenlabs", + "contributors": [ + { + "name": "ElevenLabs", + "url": "https://github.com/elevenlabs" + } + ] + }, { "name": "jimeng", "display_name": "Jimeng / Dreamina CLI", @@ -208,6 +262,7 @@ "package_manager": "script", "install_strategy": "command", "install_cmd": "curl -s https://jimeng.jianying.com/cli | bash", + "skill_md": "https://bytedance.larkoffice.com/wiki/FVTwwm0bGiishxkKOoScdHR2nsg", "entry_point": "dreamina", "contributors": [ { @@ -230,6 +285,7 @@ "install_notes": "Bundled within the Obsidian 1.12+ installer. Enable Command line interface in Obsidian settings and follow the registration prompt so `obsidian` is added to PATH.", "uninstall_notes": "Disable Command line interface from Obsidian settings or remove/update the Obsidian installer to stop using the bundled CLI.", "update_notes": "Update the Obsidian installer to a newer 1.12+ release to update the bundled CLI.", + "skill_md": "https://obsidian.md/help/cli", "entry_point": "obsidian", "detect_cmd": "obsidian", "contributors": [ From b2d8b87aba1d892860f7629426496ceb333ff8cc Mon Sep 17 00:00:00 2001 From: yuhao Date: Fri, 17 Apr 2026 15:58:12 +0000 Subject: [PATCH 13/22] add more public CLIs; fully include skills for CLIs --- docs/hub/index-modern.html | 171 ++++++++++++++++++++++++++++++++++-- docs/hub/index.html | 173 +++++++++++++++++++++++++++++++++++-- public_registry.json | 1 + 3 files changed, 331 insertions(+), 14 deletions(-) diff --git a/docs/hub/index-modern.html b/docs/hub/index-modern.html index 0ba4b2a98..8b402aa5c 100644 --- a/docs/hub/index-modern.html +++ b/docs/hub/index-modern.html @@ -1402,6 +1402,7 @@ } .card-links a, + .card-link-btn, .card-contributor a { display: inline-flex; align-items: center; @@ -1417,12 +1418,86 @@ } .card-links a:hover, + .card-link-btn:hover, .card-contributor a:hover { color: var(--text); border-color: var(--border-strong); background: rgba(255,255,255,0.08); } + .card-link-btn { + cursor: pointer; + font: inherit; + } + + .skill-popover { + position: relative; + display: inline-flex; + align-items: center; + } + + .skill-popover-panel { + position: absolute; + left: 0; + top: calc(100% + 0.5rem); + width: min(18.5rem, calc(100vw - 3rem)); + padding: 0.78rem; + border-radius: 18px; + border: 1px solid rgba(130, 149, 180, 0.34); + background: rgba(251, 247, 239, 0.98); + color: #1a2433; + box-shadow: 0 22px 48px rgba(8, 16, 28, 0.22); + opacity: 0; + transform: translateY(-4px); + pointer-events: none; + transition: opacity 0.18s ease, transform 0.18s ease; + z-index: 12; + } + + .skill-popover.is-open .skill-popover-panel { + opacity: 1; + transform: translateY(0); + pointer-events: auto; + } + + .skill-popover-label { + display: block; + margin-bottom: 0.5rem; + font-size: 0.66rem; + font-weight: 700; + letter-spacing: 0.16em; + text-transform: uppercase; + color: #67758c; + } + + .skill-popover-panel code { + display: block; + padding: 0.62rem 0.68rem; + border-radius: 14px; + border: 1px solid rgba(130, 149, 180, 0.25); + background: rgba(226, 232, 240, 0.5); + color: #182130; + font-family: 'IBM Plex Mono', monospace; + font-size: 0.74rem; + line-height: 1.6; + white-space: pre-wrap; + overflow-wrap: anywhere; + word-break: break-word; + } + + .skill-popover-copy { + margin-top: 0.62rem; + background: rgba(255,255,255,0.7); + color: #314155; + border-color: rgba(130, 149, 180, 0.3); + } + + .skill-popover-copy:hover { + color: #182130; + border-color: rgba(49, 65, 85, 0.3); + background: rgba(255,255,255,0.88); + } + .community-badge { display: inline-flex; align-items: center; @@ -2287,13 +2362,38 @@ grid.innerHTML = (deck === 'harness' ? filtered.map(renderHarnessCard) : filtered.map(renderPublicCard)).join(''); } + function isWebUrl(value) { + return /^https?:\/\//i.test(value || ''); + } + + function isRepoSkillPath(value) { + return !/\s/.test(value || '') && /(^\.{0,2}\/|\/|\.md$|\.txt$)/i.test(value || ''); + } + + function renderSkillAction(value) { + if (!value) return ''; + if (isWebUrl(value)) { + return 'Skill'; + } + if (isRepoSkillPath(value)) { + return 'Skill'; + } + return '' + + '' + + '' + + '' + + 'Skill install' + + '' + esc(value) + '' + + '' + + '' + + ''; + } + function renderHarnessCard(c) { const requiresHtml = c.requires ? '
Requires ' + esc(c.requires) + '
' : ''; - const skillLink = c.skill_md - ? 'Skill' - : ''; + const skillLink = renderSkillAction(c.skill_md); const sourceLink = c.source_url ? 'Source' : 'Source'; @@ -2304,12 +2404,12 @@ const isTeam = contributors.length === 1 && contributors[0].name === 'CLI-Anything-Team'; const contributorHtml = contributors.length ? '
' + - contributors.map((ct) => '' + esc(ct.name) + '').join('') + + contributors.map((ct) => '' + esc(ct.name) + '').join(', ') + (isTeam ? '' : 'Community') + '
' : ''; const dateHtml = c.last_modified ? '
Updated ' + esc(c.last_modified) + '
' : ''; - const linksHtml = sourceLink + (skillLink ? skillLink : ''); + const linksHtml = sourceLink + skillLink; return `
@@ -2362,11 +2462,12 @@ const titleHtml = c.homepage ? '' + esc(c.display_name) + EXTERNAL_LINK_SVG + '' : esc(c.display_name); + const skillLink = renderSkillAction(c.skill_md); const sourceLink = c.source_url ? 'Source' : ''; const contributors = c.contributors || []; const contributorHtml = contributors.length ? '
' + - contributors.map((ct) => '' + esc(ct.name) + '').join('') + + contributors.map((ct) => '' + esc(ct.name) + '').join(', ') + '
' : ''; const directCmd = c.install_cmd || publicDirectCmd(c); @@ -2400,7 +2501,7 @@ ${installBlock}
`; @@ -2416,6 +2517,20 @@ }); } + function toggleSkillPopover(trigger) { + const popover = trigger.closest('.skill-popover'); + if (!popover) return; + const shouldOpen = !popover.classList.contains('is-open'); + document.querySelectorAll('.skill-popover.is-open').forEach((node) => { + node.classList.remove('is-open'); + const btn = node.querySelector('.skill-trigger'); + if (btn) btn.setAttribute('aria-expanded', 'false'); + }); + if (!shouldOpen) return; + popover.classList.add('is-open'); + trigger.setAttribute('aria-expanded', 'true'); + } + function esc(s) { if (!s) return ''; const d = document.createElement('div'); @@ -2423,6 +2538,48 @@ return d.innerHTML; } + function escAttr(s) { + if (!s) return ''; + return String(s) + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(//g, '>'); + } + + document.addEventListener('click', (e) => { + const copyBtn = e.target.closest('.skill-popover-copy'); + if (copyBtn) { + e.preventDefault(); + e.stopPropagation(); + copyCmd(copyBtn, copyBtn.dataset.copyValue); + return; + } + + const trigger = e.target.closest('.skill-trigger'); + if (trigger) { + e.preventDefault(); + e.stopPropagation(); + toggleSkillPopover(trigger); + return; + } + + document.querySelectorAll('.skill-popover.is-open').forEach((node) => { + node.classList.remove('is-open'); + const btn = node.querySelector('.skill-trigger'); + if (btn) btn.setAttribute('aria-expanded', 'false'); + }); + }); + + document.addEventListener('keydown', (e) => { + if (e.key !== 'Escape') return; + document.querySelectorAll('.skill-popover.is-open').forEach((node) => { + node.classList.remove('is-open'); + const btn = node.querySelector('.skill-trigger'); + if (btn) btn.setAttribute('aria-expanded', 'false'); + }); + }); + document.getElementById('search-harness').addEventListener('input', (e) => { deckState.harness.query = e.target.value; renderDeck('harness'); diff --git a/docs/hub/index.html b/docs/hub/index.html index 0b1597487..e2ac30086 100644 --- a/docs/hub/index.html +++ b/docs/hub/index.html @@ -846,16 +846,93 @@ gap: 0.6rem; font-size: 0.78rem; flex-wrap: wrap; + align-items: center; } - .card-links a { + .card-links a, + .card-link-btn { color: var(--text-tertiary); text-decoration: none; font-weight: 500; transition: color 0.15s; } - .card-links a:hover { color: var(--text-secondary); } + .card-link-btn { + background: none; + border: 0; + cursor: pointer; + font: inherit; + padding: 0; + } + + .card-links a:hover, + .card-link-btn:hover { color: var(--text-secondary); } + + .skill-popover { + position: relative; + display: inline-flex; + align-items: center; + } + + .skill-popover-panel { + position: absolute; + left: 0; + top: calc(100% + 0.45rem); + width: min(18rem, calc(100vw - 3rem)); + padding: 0.7rem; + border-radius: 10px; + border: 1px solid rgba(148, 163, 184, 0.36); + background: rgba(248, 250, 252, 0.98); + color: #0f172a; + box-shadow: 0 16px 32px rgba(15, 23, 42, 0.24); + opacity: 0; + transform: translateY(-4px); + pointer-events: none; + transition: opacity 0.16s ease, transform 0.16s ease; + z-index: 10; + } + + .skill-popover.is-open .skill-popover-panel { + opacity: 1; + transform: translateY(0); + pointer-events: auto; + } + + .skill-popover-label { + display: block; + font-size: 0.64rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #475569; + margin-bottom: 0.45rem; + } + + .skill-popover-panel code { + display: block; + font-size: 0.75rem; + line-height: 1.55; + white-space: pre-wrap; + overflow-wrap: anywhere; + word-break: break-word; + font-family: 'JetBrains Mono', monospace; + background: rgba(226, 232, 240, 0.7); + border: 1px solid rgba(148, 163, 184, 0.3); + border-radius: 8px; + padding: 0.55rem 0.6rem; + } + + .skill-popover-copy { + margin-top: 0.55rem; + color: #334155; + border-color: rgba(100, 116, 139, 0.34); + background: rgba(255, 255, 255, 0.72); + } + + .skill-popover-copy:hover { + color: #0f172a; + border-color: rgba(51, 65, 85, 0.42); + } .card-contributor { font-size: 0.75rem; @@ -1623,13 +1700,38 @@ } } + function isWebUrl(value) { + return /^https?:\/\//i.test(value || ''); + } + + function isRepoSkillPath(value) { + return !/\s/.test(value || '') && /(^\.{0,2}\/|\/|\.md$|\.txt$)/i.test(value || ''); + } + + function renderSkillAction(value) { + if (!value) return ''; + if (isWebUrl(value)) { + return `Skill`; + } + if (isRepoSkillPath(value)) { + return `Skill`; + } + return ` + + + + Skill install + ${esc(value)} + + + `; + } + function renderHarnessCard(c) { const requiresHtml = c.requires ? `
Requires ${esc(c.requires)}
` : ''; - const skillLink = c.skill_md - ? `Skill` - : ''; + const skillLink = renderSkillAction(c.skill_md); const sourceLink = c.source_url ? `Source` : `Source`; @@ -1664,7 +1766,7 @@ `; @@ -1689,6 +1791,7 @@ const titleHtml = c.homepage ? `${esc(c.display_name)}${EXTERNAL_LINK_SVG}` : esc(c.display_name); + const skillLink = renderSkillAction(c.skill_md); const sourceLink = c.source_url ? `Source` : ''; @@ -1729,7 +1832,7 @@ ${cliHubInstallHtml} ${publicNotesHtml} `; @@ -1742,6 +1845,20 @@ }); } + function toggleSkillPopover(trigger) { + const popover = trigger.closest('.skill-popover'); + if (!popover) return; + const shouldOpen = !popover.classList.contains('is-open'); + document.querySelectorAll('.skill-popover.is-open').forEach((node) => { + node.classList.remove('is-open'); + const btn = node.querySelector('.skill-trigger'); + if (btn) btn.setAttribute('aria-expanded', 'false'); + }); + if (!shouldOpen) return; + popover.classList.add('is-open'); + trigger.setAttribute('aria-expanded', 'true'); + } + function esc(s) { if (!s) return ''; const d = document.createElement('div'); @@ -1749,6 +1866,48 @@ return d.innerHTML; } + function escAttr(s) { + if (!s) return ''; + return String(s) + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(//g, '>'); + } + + document.addEventListener('click', (e) => { + const copyBtn = e.target.closest('.skill-popover-copy'); + if (copyBtn) { + e.preventDefault(); + e.stopPropagation(); + copyCmd(copyBtn, copyBtn.dataset.copyValue); + return; + } + + const trigger = e.target.closest('.skill-trigger'); + if (trigger) { + e.preventDefault(); + e.stopPropagation(); + toggleSkillPopover(trigger); + return; + } + + document.querySelectorAll('.skill-popover.is-open').forEach((node) => { + node.classList.remove('is-open'); + const btn = node.querySelector('.skill-trigger'); + if (btn) btn.setAttribute('aria-expanded', 'false'); + }); + }); + + document.addEventListener('keydown', (e) => { + if (e.key !== 'Escape') return; + document.querySelectorAll('.skill-popover.is-open').forEach((node) => { + node.classList.remove('is-open'); + const btn = node.querySelector('.skill-trigger'); + if (btn) btn.setAttribute('aria-expanded', 'false'); + }); + }); + // Wire up search and sort for each deck document.getElementById('search-harness').addEventListener('input', (e) => { deckState.harness.query = e.target.value; diff --git a/public_registry.json b/public_registry.json index d742682d1..2848116dc 100644 --- a/public_registry.json +++ b/public_registry.json @@ -304,6 +304,7 @@ "requires": "Python >= 3.10", "homepage": "https://github.com/yanmingyu92/py4csr", "source_url": "https://github.com/yanmingyu92/py4csr", + "skill_md": "https://github.com/yanmingyu92/py4csr", "package_manager": "pip", "install_cmd": "pip install py4csr", "entry_point": "tracecsr", From cc8da1f9013838ee81426e828bdea272ec33751c Mon Sep 17 00:00:00 2001 From: yuhao Date: Fri, 17 Apr 2026 17:16:35 +0000 Subject: [PATCH 14/22] correct visit counts merging --- docs/hub/index-modern.html | 54 +++++++++++++++++-------------------- docs/hub/index.html | 55 ++++++++++++++++---------------------- 2 files changed, 48 insertions(+), 61 deletions(-) diff --git a/docs/hub/index-modern.html b/docs/hub/index-modern.html index 8b402aa5c..38ee164e3 100644 --- a/docs/hub/index-modern.html +++ b/docs/hub/index-modern.html @@ -1560,15 +1560,13 @@ } .footer-copy a, - .footer-brand a, - .footer-analytics-link { + .footer-brand a { color: var(--text); text-decoration: none; } .footer-copy a:hover, - .footer-brand a:hover, - .footer-analytics-link:hover { + .footer-brand a:hover { text-decoration: underline; } @@ -2067,7 +2065,7 @@ @@ -2668,42 +2666,40 @@ const UMAMI_API = 'https://api.umami.is/v1'; const UMAMI_KEY = 'api_idAebMhzn6z0hsUQT7BSxRuCK2GUZvRY'; - const WEBSITE_IDS = [ - '07082d05-efd3-4f85-a7a1-b426b0e8bfaa', - 'a076c661-bed1-405c-a522-813794e688b4', - ]; + const HKUDS_WEBSITE_ID = '07082d05-efd3-4f85-a7a1-b426b0e8bfaa'; + const CC_WEBSITE_ID = 'a076c661-bed1-405c-a522-813794e688b4'; const headers = { Accept: 'application/json', 'x-umami-api-key': UMAMI_KEY }; async function loadVisitorStats() { try { const now = Date.now(); - let totalVisits = 0; - let humanCount = 0; - const fetches = WEBSITE_IDS.flatMap((id) => [ - fetch(UMAMI_API + '/websites/' + id + '/stats?startAt=0&endAt=' + now, { headers }), - fetch(UMAMI_API + '/websites/' + id + '/events/series?startAt=0&endAt=' + now + '&unit=year&timezone=UTC', { headers }) + let hkudsHumanVisits = 0; + let ccHumanVisits = 0; + let ccAgentVisits = 0; + const [hkudsStatsResp, ccEventsResp] = await Promise.all([ + fetch(UMAMI_API + '/websites/' + HKUDS_WEBSITE_ID + '/stats?startAt=0&endAt=' + now, { headers }), + fetch(UMAMI_API + '/websites/' + CC_WEBSITE_ID + '/events/series?startAt=0&endAt=' + now + '&unit=year&timezone=UTC', { headers }) ]); - const [statsResp1, eventsResp1, statsResp2, eventsResp2] = await Promise.all(fetches); - for (const resp of [statsResp1, statsResp2]) { - if (resp.ok) { - const stats = await resp.json(); - totalVisits += stats.visits ?? 0; - } + if (hkudsStatsResp.ok) { + const stats = await hkudsStatsResp.json(); + hkudsHumanVisits = stats.visits ?? 0; } - for (const resp of [eventsResp1, eventsResp2]) { - if (resp.ok) { - const events = await resp.json(); - events.forEach((e) => { - if (e.x === 'visit-human') humanCount += e.y || 0; - }); - } + if (ccEventsResp.ok) { + const events = await ccEventsResp.json(); + events.forEach((e) => { + if (e.x === 'visit-human') ccHumanVisits += e.y || 0; + if (e.x === 'visit-agent') ccAgentVisits += e.y || 0; + }); } + const humanCount = hkudsHumanVisits + ccHumanVisits; + const totalVisits = humanCount + ccAgentVisits; + document.getElementById('stat-total').textContent = totalVisits.toLocaleString(); document.getElementById('stat-human').textContent = humanCount.toLocaleString(); - document.getElementById('stat-agent').textContent = Math.max(0, totalVisits - humanCount).toLocaleString(); + document.getElementById('stat-agent').textContent = ccAgentVisits.toLocaleString(); } catch (_) { // Leave fallback dashes. } diff --git a/docs/hub/index.html b/docs/hub/index.html index e2ac30086..073c36102 100644 --- a/docs/hub/index.html +++ b/docs/hub/index.html @@ -1135,16 +1135,9 @@ gap: 0.4rem; font-size: 0.78rem; color: var(--text-tertiary); - text-decoration: none; padding: 0.35rem 0.75rem; border: 1px solid var(--border); border-radius: 999px; - transition: border-color 0.15s, color 0.15s; - } - - .footer-analytics-link:hover { - border-color: var(--text-tertiary); - color: var(--text-secondary); } .footer-analytics-link svg { opacity: 0.6; flex-shrink: 0; } @@ -1376,7 +1369,7 @@ Powered by CLI-Anything @@ -2014,43 +2007,41 @@ // ── Fetch all-time stats from Umami Cloud API (both sites) ── const UMAMI_API = 'https://api.umami.is/v1'; const UMAMI_KEY = 'api_idAebMhzn6z0hsUQT7BSxRuCK2GUZvRY'; - const WEBSITE_IDS = [ - '07082d05-efd3-4f85-a7a1-b426b0e8bfaa', // hkuds.github.io - 'a076c661-bed1-405c-a522-813794e688b4', // clianything.cc - ]; + const HKUDS_WEBSITE_ID = '07082d05-efd3-4f85-a7a1-b426b0e8bfaa'; + const CC_WEBSITE_ID = 'a076c661-bed1-405c-a522-813794e688b4'; const headers = { 'Accept': 'application/json', 'x-umami-api-key': UMAMI_KEY }; async function loadVisitorStats() { try { const now = Date.now(); - let totalVisits = 0, humanCount = 0; + let hkudsHumanVisits = 0; + let ccHumanVisits = 0; + let ccAgentVisits = 0; - // Fetch stats and events from both sites in parallel - const fetches = WEBSITE_IDS.flatMap(id => [ - fetch(`${UMAMI_API}/websites/${id}/stats?startAt=0&endAt=${now}`, { headers }), - fetch(`${UMAMI_API}/websites/${id}/events/series?startAt=0&endAt=${now}&unit=year&timezone=UTC`, { headers }) + const [hkudsStatsResp, ccEventsResp] = await Promise.all([ + fetch(`${UMAMI_API}/websites/${HKUDS_WEBSITE_ID}/stats?startAt=0&endAt=${now}`, { headers }), + fetch(`${UMAMI_API}/websites/${CC_WEBSITE_ID}/events/series?startAt=0&endAt=${now}&unit=year&timezone=UTC`, { headers }) ]); - const [statsResp1, eventsResp1, statsResp2, eventsResp2] = await Promise.all(fetches); - for (const resp of [statsResp1, statsResp2]) { - if (resp.ok) { - const stats = await resp.json(); - totalVisits += stats.visits ?? 0; - } + if (hkudsStatsResp.ok) { + const stats = await hkudsStatsResp.json(); + hkudsHumanVisits = stats.visits ?? 0; } - for (const resp of [eventsResp1, eventsResp2]) { - if (resp.ok) { - const events = await resp.json(); - events.forEach(e => { - if (e.x === 'visit-human') humanCount += e.y || 0; - }); - } + if (ccEventsResp.ok) { + const events = await ccEventsResp.json(); + events.forEach(e => { + if (e.x === 'visit-human') ccHumanVisits += e.y || 0; + if (e.x === 'visit-agent') ccAgentVisits += e.y || 0; + }); } + const humanCount = hkudsHumanVisits + ccHumanVisits; + const totalVisits = humanCount + ccAgentVisits; + document.getElementById('stat-total').textContent = totalVisits.toLocaleString(); document.getElementById('stat-human').textContent = humanCount.toLocaleString(); - document.getElementById('stat-agent').textContent = Math.max(0, totalVisits - humanCount).toLocaleString(); + document.getElementById('stat-agent').textContent = ccAgentVisits.toLocaleString(); } catch (_) { // Stats not available — leave dashes } From 7e61df72e95aefdc7af5cc0d1ddead64e6a8444c Mon Sep 17 00:00:00 2001 From: yuhao Date: Fri, 17 Apr 2026 17:27:04 +0000 Subject: [PATCH 15/22] improve CLI-Hub --- docs/hub/index-modern.html | 46 +++++++++++++++++++++++++++++ docs/hub/index.html | 59 +++++++++++++++++++++++++++++++++++++- 2 files changed, 104 insertions(+), 1 deletion(-) diff --git a/docs/hub/index-modern.html b/docs/hub/index-modern.html index 38ee164e3..a3eb6afe0 100644 --- a/docs/hub/index-modern.html +++ b/docs/hub/index-modern.html @@ -258,6 +258,21 @@ flex-shrink: 0; } + .nav-link-stars { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 2.9rem; + padding: 0.14rem 0.52rem; + border-radius: 999px; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.1); + color: var(--text); + font-size: 0.73rem; + font-variant-numeric: tabular-nums; + line-height: 1.1; + } + .theme-toggle { width: 42px; padding: 0; @@ -1837,6 +1852,7 @@ GitHub + -- Contribute +
OpenClaw openclaw skills install cli-anything-hub @@ -2081,7 +2002,7 @@
@@ -2123,7 +2044,7 @@ }); const REPO = 'https://github.com/HKUDS/CLI-Anything'; - const GITHUB_REPO_API = 'https://api.github.com/repos/HKUDS/CLI-Anything'; + const REPO_SKILLS_SOURCE = 'HKUDS/CLI-Anything'; const REGISTRY_URLS = [ '../../registry.json', 'https://raw.githubusercontent.com/HKUDS/CLI-Anything/main/registry.json' @@ -2164,34 +2085,6 @@ return null; } - function formatCompactCount(value) { - return new Intl.NumberFormat('en', { - notation: 'compact', - maximumFractionDigits: value >= 10000 ? 0 : 1, - }).format(value); - } - - async function loadGitHubStars() { - try { - const resp = await fetch(GITHUB_REPO_API, { - headers: { Accept: 'application/vnd.github+json' } - }); - if (!resp.ok) return; - - const repo = await resp.json(); - const stars = repo.stargazers_count; - if (typeof stars !== 'number') return; - - document.querySelectorAll('[data-github-stars]').forEach((el) => { - el.textContent = formatCompactCount(stars); - el.setAttribute('aria-label', stars.toLocaleString('en-US') + ' GitHub stars'); - el.title = stars.toLocaleString('en-US') + ' GitHub stars'; - }); - } catch (_) { - // Keep fallback placeholder when GitHub API is unavailable. - } - } - async function loadRegistries() { try { const [harnessData, publicData, dates] = await Promise.all([ @@ -2405,38 +2298,48 @@ grid.innerHTML = (deck === 'harness' ? filtered.map(renderHarnessCard) : filtered.map(renderPublicCard)).join(''); } - function isWebUrl(value) { - return /^https?:\/\//i.test(value || ''); + function repoSkillId(skillPath) { + if (!skillPath || skillPath.startsWith('http')) return ''; + const match = skillPath.match(/^skills\/([^/]+)\/SKILL\.md$/); + return match ? match[1] : ''; } - function isRepoSkillPath(value) { - return !/\s/.test(value || '') && /(^\.{0,2}\/|\/|\.md$|\.txt$)/i.test(value || ''); + function normalizeNpxSkillsCmd(cmd) { + if (!cmd) return ''; + const trimmed = cmd.trim(); + if (!trimmed.startsWith('npx skills add ')) return ''; + return trimmed + .replace(/\s+-g\b/g, '') + .replace(/\s+-y\b/g, '') + .trim() + ' -g -y'; } - function renderSkillAction(value) { - if (!value) return ''; - if (isWebUrl(value)) { - return 'Skill'; - } - if (isRepoSkillPath(value)) { - return 'Skill'; - } - return '' + - '' + - '' + - '' + - 'Skill install' + - '' + esc(value) + '' + - '' + - '' + - ''; + function harnessSkillCmd(c) { + const skillId = repoSkillId(c.skill_md); + return skillId ? 'npx skills add ' + REPO_SKILLS_SOURCE + ' --skill ' + skillId + ' -g -y' : ''; + } + + function publicSkillCmd(c) { + return normalizeNpxSkillsCmd(c.skill_md); + } + + function renderCommandStack(steps, sourceLabel) { + return '
' + steps.map((step) => + '
' + + '
' + esc(step.label) + '' + esc(step.badge || sourceLabel) + '
' + + '
' + esc(step.cmd) + '
' + + '
' + + '
' + ).join('') + '
'; } function renderHarnessCard(c) { const requiresHtml = c.requires ? '
Requires ' + esc(c.requires) + '
' : ''; - const skillLink = renderSkillAction(c.skill_md); + const skillLink = c.skill_md + ? 'Skill' + : ''; const sourceLink = c.source_url ? 'Source' : 'Source'; @@ -2447,12 +2350,18 @@ const isTeam = contributors.length === 1 && contributors[0].name === 'CLI-Anything-Team'; const contributorHtml = contributors.length ? '
' + - contributors.map((ct) => '' + esc(ct.name) + '').join(', ') + + contributors.map((ct) => '' + esc(ct.name) + '').join('') + (isTeam ? '' : 'Community') + '
' : ''; const dateHtml = c.last_modified ? '
Updated ' + esc(c.last_modified) + '
' : ''; - const linksHtml = sourceLink + skillLink; + const linksHtml = sourceLink + (skillLink ? skillLink : ''); + const installSteps = [ + { label: 'Step 1 · Install CLI', cmd: 'cli-hub install ' + c.name, badge: 'CLI-Hub' } + ]; + const skillCmd = harnessSkillCmd(c); + if (skillCmd) installSteps.push({ label: 'Step 2 · Install Skill', cmd: skillCmd, badge: 'npx skills' }); + const installBlock = renderCommandStack(installSteps, 'CLI-Hub'); return `
@@ -2469,16 +2378,7 @@ ${dateHtml} ${requiresHtml} -
-
- Install via CLI-Hub - PyPI -
-
pip install cli-anything-hub\ncli-hub install ${esc(c.name)}
-
- -
-
+ ${installBlock} ${installBlock}
`; @@ -2560,20 +2460,6 @@ }); } - function toggleSkillPopover(trigger) { - const popover = trigger.closest('.skill-popover'); - if (!popover) return; - const shouldOpen = !popover.classList.contains('is-open'); - document.querySelectorAll('.skill-popover.is-open').forEach((node) => { - node.classList.remove('is-open'); - const btn = node.querySelector('.skill-trigger'); - if (btn) btn.setAttribute('aria-expanded', 'false'); - }); - if (!shouldOpen) return; - popover.classList.add('is-open'); - trigger.setAttribute('aria-expanded', 'true'); - } - function esc(s) { if (!s) return ''; const d = document.createElement('div'); @@ -2581,48 +2467,6 @@ return d.innerHTML; } - function escAttr(s) { - if (!s) return ''; - return String(s) - .replace(/&/g, '&') - .replace(/"/g, '"') - .replace(//g, '>'); - } - - document.addEventListener('click', (e) => { - const copyBtn = e.target.closest('.skill-popover-copy'); - if (copyBtn) { - e.preventDefault(); - e.stopPropagation(); - copyCmd(copyBtn, copyBtn.dataset.copyValue); - return; - } - - const trigger = e.target.closest('.skill-trigger'); - if (trigger) { - e.preventDefault(); - e.stopPropagation(); - toggleSkillPopover(trigger); - return; - } - - document.querySelectorAll('.skill-popover.is-open').forEach((node) => { - node.classList.remove('is-open'); - const btn = node.querySelector('.skill-trigger'); - if (btn) btn.setAttribute('aria-expanded', 'false'); - }); - }); - - document.addEventListener('keydown', (e) => { - if (e.key !== 'Escape') return; - document.querySelectorAll('.skill-popover.is-open').forEach((node) => { - node.classList.remove('is-open'); - const btn = node.querySelector('.skill-trigger'); - if (btn) btn.setAttribute('aria-expanded', 'false'); - }); - }); - document.getElementById('search-harness').addEventListener('input', (e) => { deckState.harness.query = e.target.value; renderDeck('harness'); @@ -2711,47 +2555,48 @@ const UMAMI_API = 'https://api.umami.is/v1'; const UMAMI_KEY = 'api_idAebMhzn6z0hsUQT7BSxRuCK2GUZvRY'; - const HKUDS_WEBSITE_ID = '07082d05-efd3-4f85-a7a1-b426b0e8bfaa'; - const CC_WEBSITE_ID = 'a076c661-bed1-405c-a522-813794e688b4'; + const WEBSITE_IDS = [ + '07082d05-efd3-4f85-a7a1-b426b0e8bfaa', + 'a076c661-bed1-405c-a522-813794e688b4', + ]; const headers = { Accept: 'application/json', 'x-umami-api-key': UMAMI_KEY }; async function loadVisitorStats() { try { const now = Date.now(); - let hkudsHumanVisits = 0; - let ccHumanVisits = 0; - let ccAgentVisits = 0; - const [hkudsStatsResp, ccEventsResp] = await Promise.all([ - fetch(UMAMI_API + '/websites/' + HKUDS_WEBSITE_ID + '/stats?startAt=0&endAt=' + now, { headers }), - fetch(UMAMI_API + '/websites/' + CC_WEBSITE_ID + '/events/series?startAt=0&endAt=' + now + '&unit=year&timezone=UTC', { headers }) + let totalVisits = 0; + let humanCount = 0; + const fetches = WEBSITE_IDS.flatMap((id) => [ + fetch(UMAMI_API + '/websites/' + id + '/stats?startAt=0&endAt=' + now, { headers }), + fetch(UMAMI_API + '/websites/' + id + '/events/series?startAt=0&endAt=' + now + '&unit=year&timezone=UTC', { headers }) ]); + const [statsResp1, eventsResp1, statsResp2, eventsResp2] = await Promise.all(fetches); - if (hkudsStatsResp.ok) { - const stats = await hkudsStatsResp.json(); - hkudsHumanVisits = stats.visits ?? 0; + for (const resp of [statsResp1, statsResp2]) { + if (resp.ok) { + const stats = await resp.json(); + totalVisits += stats.visits ?? 0; + } } - if (ccEventsResp.ok) { - const events = await ccEventsResp.json(); - events.forEach((e) => { - if (e.x === 'visit-human') ccHumanVisits += e.y || 0; - if (e.x === 'visit-agent') ccAgentVisits += e.y || 0; - }); + for (const resp of [eventsResp1, eventsResp2]) { + if (resp.ok) { + const events = await resp.json(); + events.forEach((e) => { + if (e.x === 'visit-human') humanCount += e.y || 0; + }); + } } - const humanCount = hkudsHumanVisits + ccHumanVisits; - const totalVisits = humanCount + ccAgentVisits; - document.getElementById('stat-total').textContent = totalVisits.toLocaleString(); document.getElementById('stat-human').textContent = humanCount.toLocaleString(); - document.getElementById('stat-agent').textContent = ccAgentVisits.toLocaleString(); + document.getElementById('stat-agent').textContent = Math.max(0, totalVisits - humanCount).toLocaleString(); } catch (_) { // Leave fallback dashes. } } loadVisitorStats(); - loadGitHubStars(); })(); diff --git a/docs/hub/index.html b/docs/hub/index.html index 040c52f9b..9d3415ed3 100644 --- a/docs/hub/index.html +++ b/docs/hub/index.html @@ -17,8 +17,6 @@ --text: #fafafa; --text-secondary: #a1a1aa; --text-tertiary: #71717a; - --hero-neutral-top: #e7ecf2; - --hero-neutral-bottom: #b8c1cd; --accent: #3b82f6; --accent-muted: #2563eb; --green: #22c55e; @@ -39,8 +37,6 @@ --text: #09090b; --text-secondary: #52525b; --text-tertiary: #71717a; - --hero-neutral-top: #7f8894; - --hero-neutral-bottom: #a8b1bc; --accent: #2563eb; --accent-muted: #1d4ed8; --green: #16a34a; @@ -107,21 +103,6 @@ .nav-link svg { width: 15px; height: 15px; flex-shrink: 0; } - .nav-link-stars { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 2.8rem; - padding: 0.1rem 0.45rem; - border-radius: 999px; - border: 1px solid var(--border); - background: var(--surface); - color: var(--text); - font-size: 0.72rem; - font-variant-numeric: tabular-nums; - line-height: 1.1; - } - /* ── Theme toggle ── */ .theme-toggle { display: inline-flex; @@ -167,14 +148,7 @@ } .hero-accent { color: var(--accent); } - .hero-dim { - color: var(--hero-neutral-bottom); - background: linear-gradient(180deg, var(--hero-neutral-top) 0%, var(--hero-neutral-bottom) 100%); - -webkit-background-clip: text; - background-clip: text; - -webkit-text-fill-color: transparent; - opacity: 0.9; - } + .hero-dim { color: var(--text-tertiary); } .hero-tagline { font-size: 1.15rem; @@ -826,6 +800,27 @@ margin-bottom: 0.75rem; } + .card-install-stack { + display: grid; + gap: 0.55rem; + margin-bottom: 0.75rem; + } + + .card-install-copy { + display: grid; + gap: 0.22rem; + flex: 1; + min-width: 0; + } + + .card-install-step { + font-size: 0.68rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-tertiary); + font-weight: 600; + } + .card-install--note { justify-content: flex-start; } @@ -872,93 +867,16 @@ gap: 0.6rem; font-size: 0.78rem; flex-wrap: wrap; - align-items: center; } - .card-links a, - .card-link-btn { + .card-links a { color: var(--text-tertiary); text-decoration: none; font-weight: 500; transition: color 0.15s; } - .card-link-btn { - background: none; - border: 0; - cursor: pointer; - font: inherit; - padding: 0; - } - - .card-links a:hover, - .card-link-btn:hover { color: var(--text-secondary); } - - .skill-popover { - position: relative; - display: inline-flex; - align-items: center; - } - - .skill-popover-panel { - position: absolute; - left: 0; - top: calc(100% + 0.45rem); - width: min(18rem, calc(100vw - 3rem)); - padding: 0.7rem; - border-radius: 10px; - border: 1px solid rgba(148, 163, 184, 0.36); - background: rgba(248, 250, 252, 0.98); - color: #0f172a; - box-shadow: 0 16px 32px rgba(15, 23, 42, 0.24); - opacity: 0; - transform: translateY(-4px); - pointer-events: none; - transition: opacity 0.16s ease, transform 0.16s ease; - z-index: 10; - } - - .skill-popover.is-open .skill-popover-panel { - opacity: 1; - transform: translateY(0); - pointer-events: auto; - } - - .skill-popover-label { - display: block; - font-size: 0.64rem; - font-weight: 700; - letter-spacing: 0.08em; - text-transform: uppercase; - color: #475569; - margin-bottom: 0.45rem; - } - - .skill-popover-panel code { - display: block; - font-size: 0.75rem; - line-height: 1.55; - white-space: pre-wrap; - overflow-wrap: anywhere; - word-break: break-word; - font-family: 'JetBrains Mono', monospace; - background: rgba(226, 232, 240, 0.7); - border: 1px solid rgba(148, 163, 184, 0.3); - border-radius: 8px; - padding: 0.55rem 0.6rem; - } - - .skill-popover-copy { - margin-top: 0.55rem; - color: #334155; - border-color: rgba(100, 116, 139, 0.34); - background: rgba(255, 255, 255, 0.72); - } - - .skill-popover-copy:hover { - color: #0f172a; - border-color: rgba(51, 65, 85, 0.42); - } + .card-links a:hover { color: var(--text-secondary); } .card-contributor { font-size: 0.75rem; @@ -1161,9 +1079,16 @@ gap: 0.4rem; font-size: 0.78rem; color: var(--text-tertiary); + text-decoration: none; padding: 0.35rem 0.75rem; border: 1px solid var(--border); border-radius: 999px; + transition: border-color 0.15s, color 0.15s; + } + + .footer-analytics-link:hover { + border-color: var(--text-tertiary); + color: var(--text-secondary); } .footer-analytics-link svg { opacity: 0.6; flex-shrink: 0; } @@ -1242,13 +1167,12 @@