From dfef24958d00c4a205de3a21ef27e75b6523bbc6 Mon Sep 17 00:00:00 2001
From: aimidi
Date: Thu, 16 Apr 2026 16:54:51 +0800
Subject: [PATCH 1/7] Add Unreal Insights harness
---
.gitignore | 4 +
registry.json | 16 +-
.../agent-harness/UNREALINSIGHTS.md | 72 +++
.../cli_anything/unrealinsights/README.md | 167 +++++
.../cli_anything/unrealinsights/__init__.py | 5 +
.../cli_anything/unrealinsights/__main__.py | 7 +
.../unrealinsights/core/__init__.py | 1 +
.../unrealinsights/core/capture.py | 169 +++++
.../unrealinsights/core/export.py | 259 ++++++++
.../unrealinsights/core/session.py | 38 ++
.../unrealinsights/skills/SKILL.md | 102 +++
.../cli_anything/unrealinsights/tests/TEST.md | 172 ++++++
.../unrealinsights/tests/__init__.py | 1 +
.../unrealinsights/tests/test_core.py | 498 +++++++++++++++
.../unrealinsights/tests/test_full_e2e.py | 177 ++++++
.../unrealinsights/unrealinsights_cli.py | 582 ++++++++++++++++++
.../unrealinsights/utils/__init__.py | 1 +
.../unrealinsights/utils/errors.py | 26 +
.../unrealinsights/utils/output.py | 54 ++
.../unrealinsights/utils/repl_skin.py | 521 ++++++++++++++++
.../utils/unrealinsights_backend.py | 442 +++++++++++++
unrealinsights/agent-harness/setup.py | 41 ++
22 files changed, 3354 insertions(+), 1 deletion(-)
create mode 100644 unrealinsights/agent-harness/UNREALINSIGHTS.md
create mode 100644 unrealinsights/agent-harness/cli_anything/unrealinsights/README.md
create mode 100644 unrealinsights/agent-harness/cli_anything/unrealinsights/__init__.py
create mode 100644 unrealinsights/agent-harness/cli_anything/unrealinsights/__main__.py
create mode 100644 unrealinsights/agent-harness/cli_anything/unrealinsights/core/__init__.py
create mode 100644 unrealinsights/agent-harness/cli_anything/unrealinsights/core/capture.py
create mode 100644 unrealinsights/agent-harness/cli_anything/unrealinsights/core/export.py
create mode 100644 unrealinsights/agent-harness/cli_anything/unrealinsights/core/session.py
create mode 100644 unrealinsights/agent-harness/cli_anything/unrealinsights/skills/SKILL.md
create mode 100644 unrealinsights/agent-harness/cli_anything/unrealinsights/tests/TEST.md
create mode 100644 unrealinsights/agent-harness/cli_anything/unrealinsights/tests/__init__.py
create mode 100644 unrealinsights/agent-harness/cli_anything/unrealinsights/tests/test_core.py
create mode 100644 unrealinsights/agent-harness/cli_anything/unrealinsights/tests/test_full_e2e.py
create mode 100644 unrealinsights/agent-harness/cli_anything/unrealinsights/unrealinsights_cli.py
create mode 100644 unrealinsights/agent-harness/cli_anything/unrealinsights/utils/__init__.py
create mode 100644 unrealinsights/agent-harness/cli_anything/unrealinsights/utils/errors.py
create mode 100644 unrealinsights/agent-harness/cli_anything/unrealinsights/utils/output.py
create mode 100644 unrealinsights/agent-harness/cli_anything/unrealinsights/utils/repl_skin.py
create mode 100644 unrealinsights/agent-harness/cli_anything/unrealinsights/utils/unrealinsights_backend.py
create mode 100644 unrealinsights/agent-harness/setup.py
diff --git a/.gitignore b/.gitignore
index 6eb435674..6c533bfad 100644
--- a/.gitignore
+++ b/.gitignore
@@ -81,6 +81,7 @@
!/rms/
!/renderdoc/
!/cloudcompare/
+!/unrealinsights/
# Step 5: Inside each software dir, ignore everything (including dotfiles)
/gimp/*
@@ -137,6 +138,8 @@
/renderdoc/.*
/cloudcompare/*
/cloudcompare/.*
+/unrealinsights/*
+/unrealinsights/.*
# Step 6: ...except agent-harness/
!/gimp/agent-harness/
@@ -168,6 +171,7 @@
!/rms/agent-harness/
!/renderdoc/agent-harness/
!/cloudcompare/agent-harness/
+!/unrealinsights/agent-harness/
# Step 7: Ignore build artifacts within allowed dirs
**/__pycache__/
diff --git a/registry.json b/registry.json
index b20c870f7..08812d9b5 100644
--- a/registry.json
+++ b/registry.json
@@ -2,7 +2,7 @@
"meta": {
"repo": "https://github.com/HKUDS/CLI-Anything",
"description": "CLI-Hub — Agent-native stateful CLI interfaces for softwares, codebases, and Web Services",
- "updated": "2026-03-26"
+ "updated": "2026-04-15"
},
"clis": [
{
@@ -397,6 +397,20 @@
"contributor": "levishilf",
"contributor_url": "https://github.com/levishilf"
},
+ {
+ "name": "unrealinsights",
+ "display_name": "Unreal Insights",
+ "version": "0.1.0",
+ "description": "Windows-first Unreal trace capture orchestration and headless Timing Insights export workflows",
+ "requires": "Windows + Unreal Engine 5.5+ installation with UnrealInsights.exe",
+ "homepage": "https://dev.epicgames.com/documentation/en-us/unreal-engine/unreal-insights-in-unreal-engine",
+ "install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=unrealinsights/agent-harness",
+ "entry_point": "cli-anything-unrealinsights",
+ "skill_md": "unrealinsights/agent-harness/cli_anything/unrealinsights/skills/SKILL.md",
+ "category": "debugging",
+ "contributor": "AiMiDi",
+ "contributor_url": "https://github.com/AiMiDi"
+ },
{
"name": "videocaptioner",
"display_name": "VideoCaptioner",
diff --git a/unrealinsights/agent-harness/UNREALINSIGHTS.md b/unrealinsights/agent-harness/UNREALINSIGHTS.md
new file mode 100644
index 000000000..d549b74ae
--- /dev/null
+++ b/unrealinsights/agent-harness/UNREALINSIGHTS.md
@@ -0,0 +1,72 @@
+# UNREALINSIGHTS.md - Software-Specific SOP
+
+## About Unreal Insights
+
+Unreal Insights is Epic's trace analysis tool for Unreal Engine performance,
+profiling, timing, and counter data stored in `.utrace` files.
+
+This harness follows the CLI-Anything rule of using the real backend:
+
+- `UnrealInsights.exe` for headless analysis and CSV/TXT export
+- a traced Unreal Engine target executable for capture generation
+
+## Backend Model
+
+### Analysis backend
+
+`UnrealInsights.exe` accepts:
+
+- `-OpenTraceFile=`
+- `-NoUI`
+- `-AutoQuit`
+- `-ABSLOG=`
+- `-ExecOnAnalysisCompleteCmd=`
+
+The command may be:
+
+- a direct exporter command such as `TimingInsights.ExportThreads`
+- `@=` for batch execution
+
+This harness can also ensure an engine-matched analysis backend for custom
+source engines by locating or building `Engine/Binaries/Win64/UnrealInsights.exe`.
+
+### Capture backend
+
+UE targets can be launched with:
+
+- `-trace=`
+- `-tracefile=`
+- optional `-ExecCmds=`
+
+This harness supports two v1 launch shapes:
+
+- explicit target executable path
+- `--project + --engine-root` convenience mode, which resolves `UnrealEditor.exe`
+
+This harness only supports file-mode capture orchestration in v1.
+
+## CLI Coverage Map
+
+| Feature | CLI Command | Status |
+|--------|-------------|--------|
+| Resolve Insights binaries | `backend info` | v1 |
+| Set current trace | `trace set` | v1 |
+| Inspect current trace | `trace info` | v1 |
+| Launch traced target | `capture run` | v1 |
+| Export threads | `export threads` | v1 |
+| Export timers | `export timers` | v1 |
+| Export timing events | `export timing-events` | v1 |
+| Export timer statistics | `export timer-stats` | v1 |
+| Export timer callees | `export timer-callees` | v1 |
+| Export counter list | `export counters` | v1 |
+| Export counter values | `export counter-values` | v1 |
+| Batch response file | `batch run-rsp` | v1 |
+| Control live instances | — | future |
+| Trace store browsing | — | future |
+
+## Current Limitations
+
+- Windows-first discovery only
+- No SessionServices control of already-running UE instances
+- No trace store session enumeration
+- Capture orchestration assumes the target executable accepts standard UE trace flags
diff --git a/unrealinsights/agent-harness/cli_anything/unrealinsights/README.md b/unrealinsights/agent-harness/cli_anything/unrealinsights/README.md
new file mode 100644
index 000000000..96de51ab6
--- /dev/null
+++ b/unrealinsights/agent-harness/cli_anything/unrealinsights/README.md
@@ -0,0 +1,167 @@
+# cli-anything-unrealinsights
+
+Command-line interface for Unreal Insights trace capture and export workflows.
+
+This harness wraps the real Unreal Engine tools:
+
+- `UnrealInsights.exe` for headless `.utrace` analysis and exporters
+- a traced UE/Game executable for file-mode capture generation
+
+## Installation
+
+```bash
+cd unrealinsights/agent-harness
+pip install -e .
+```
+
+## Prerequisites
+
+- Windows
+- Unreal Engine 5.5+ installed with `UnrealInsights.exe`
+- optional `UnrealTraceServer.exe` for backend reporting
+
+You can point the harness at explicit binaries:
+
+```powershell
+$env:UNREALINSIGHTS_EXE='D:\Program Files\Epic Games\UE_5.5\Engine\Binaries\Win64\UnrealInsights.exe'
+$env:UNREAL_TRACE_SERVER_EXE='D:\Program Files\Epic Games\UE_5.5\Engine\Binaries\Win64\UnrealTraceServer.exe'
+```
+
+If those are not set, the harness auto-discovers common UE installs under
+`:\Program Files\Epic Games\UE_*`.
+
+## Quick Start
+
+```powershell
+# Inspect resolved backend binaries
+cli-anything-unrealinsights --json backend info
+
+# Find or build UnrealInsights.exe for a custom engine root
+cli-anything-unrealinsights --json backend ensure-insights `
+ --engine-root 'D:\code\D5\d5render-ue5_3'
+
+# Bind a trace file for the current session
+cli-anything-unrealinsights trace set D:\captures\session.utrace
+
+# Export threads
+cli-anything-unrealinsights --json -t D:\captures\session.utrace export threads D:\out\threads.csv
+
+# Export timer statistics for a region
+cli-anything-unrealinsights --json -t D:\captures\session.utrace export timer-stats `
+ D:\out\timer_stats.csv --threads "GameThread" --timers "*" --region "EXPORT_CAPTURE"
+
+# Execute a response file
+cli-anything-unrealinsights --json -t D:\captures\session.utrace batch run-rsp D:\out\export.rsp
+
+# Launch a traced UE target and wait for completion
+cli-anything-unrealinsights --json capture run `
+ --project 'D:\Projects\MyGame\MyGame.uproject' `
+ --engine-root 'D:\Program Files\Epic Games\UE_5.5' `
+ --output-trace D:\captures\editor_boot.utrace `
+ --channels "default,bookmark" `
+ --exec-cmd "Trace.Bookmark BootStart" `
+ --wait --timeout 300
+
+# Start REPL (default behavior)
+cli-anything-unrealinsights
+```
+
+## Command Groups
+
+- `backend`
+ - `info`
+ - `ensure-insights`
+- `trace`
+ - `set`
+ - `info`
+- `capture`
+ - `run`
+- `export`
+ - `threads`
+ - `timers`
+ - `timing-events`
+ - `timer-stats`
+ - `timer-callees`
+ - `counters`
+ - `counter-values`
+- `batch`
+ - `run-rsp`
+- `repl`
+
+## Global Options
+
+- `--json`: machine-readable output
+- `--debug`: include traceback details in errors
+- `--trace/-t`: current `.utrace` file
+- `--insights-exe`: explicit `UnrealInsights.exe` path
+- `--trace-server-exe`: explicit `UnrealTraceServer.exe` path
+
+## Engine-Matched Insights
+
+If you need an `UnrealInsights.exe` matching a custom source engine, use:
+
+```powershell
+cli-anything-unrealinsights --json backend ensure-insights `
+ --engine-root 'D:\code\D5\d5render-ue5_3'
+```
+
+Behavior:
+
+- looks for `Engine\Binaries\Win64\UnrealInsights.exe` under the given engine root
+- if missing, runs that engine's `Engine\Build\BatchFiles\Build.bat UnrealInsights Win64 Development -WaitMutex`
+- returns the resolved path plus the build log path when a build was attempted
+
+## Capture Convenience Layer
+
+`capture run` supports two launch styles:
+
+```powershell
+# 1. Convenience mode: infer UnrealEditor.exe from engine root
+cli-anything-unrealinsights capture run `
+ --project 'D:\Projects\MyGame\MyGame.uproject' `
+ --engine-root 'D:\Program Files\Epic Games\UE_5.5'
+
+# 2. Explicit mode: provide the exact executable yourself
+cli-anything-unrealinsights capture run `
+ 'D:\Program Files\Epic Games\UE_5.5\Engine\Binaries\Win64\UnrealEditor.exe' `
+ --target-arg 'D:\Projects\MyGame\MyGame.uproject'
+```
+
+Notes:
+
+- `--project` prepends the `.uproject` path to the target command line.
+- `--engine-root` accepts either the UE install root or its `Engine` subdirectory.
+- If `target_exe` is omitted, `capture run` resolves `UnrealEditor.exe` from `--engine-root`.
+- The original explicit `target_exe` path remains supported.
+
+## Export Filters
+
+`timing-events` and `timer-stats` support:
+
+- `--columns`
+- `--threads`
+- `--timers`
+- `--start-time`
+- `--end-time`
+- `--region`
+
+`counter-values` supports:
+
+- `--counter`
+- `--columns`
+- `--start-time`
+- `--end-time`
+- `--region`
+
+## Testing
+
+```bash
+cd unrealinsights/agent-harness
+pytest cli_anything/unrealinsights/tests/test_core.py -v
+pytest cli_anything/unrealinsights/tests/test_full_e2e.py -v -s
+```
+
+Optional environment variables for E2E coverage:
+
+- `UNREALINSIGHTS_TEST_TRACE`
+- `UNREALINSIGHTS_TEST_TARGET_EXE`
diff --git a/unrealinsights/agent-harness/cli_anything/unrealinsights/__init__.py b/unrealinsights/agent-harness/cli_anything/unrealinsights/__init__.py
new file mode 100644
index 000000000..9fb92967c
--- /dev/null
+++ b/unrealinsights/agent-harness/cli_anything/unrealinsights/__init__.py
@@ -0,0 +1,5 @@
+"""cli-anything Unreal Insights harness."""
+
+__all__ = ["__version__"]
+
+__version__ = "0.1.0"
diff --git a/unrealinsights/agent-harness/cli_anything/unrealinsights/__main__.py b/unrealinsights/agent-harness/cli_anything/unrealinsights/__main__.py
new file mode 100644
index 000000000..a58b161ad
--- /dev/null
+++ b/unrealinsights/agent-harness/cli_anything/unrealinsights/__main__.py
@@ -0,0 +1,7 @@
+"""Module entry point for cli_anything.unrealinsights."""
+
+from cli_anything.unrealinsights.unrealinsights_cli import main
+
+
+if __name__ == "__main__":
+ main()
diff --git a/unrealinsights/agent-harness/cli_anything/unrealinsights/core/__init__.py b/unrealinsights/agent-harness/cli_anything/unrealinsights/core/__init__.py
new file mode 100644
index 000000000..5f4b90c4f
--- /dev/null
+++ b/unrealinsights/agent-harness/cli_anything/unrealinsights/core/__init__.py
@@ -0,0 +1 @@
+"""Core helpers for the Unreal Insights harness."""
diff --git a/unrealinsights/agent-harness/cli_anything/unrealinsights/core/capture.py b/unrealinsights/agent-harness/cli_anything/unrealinsights/core/capture.py
new file mode 100644
index 000000000..89e4d3864
--- /dev/null
+++ b/unrealinsights/agent-harness/cli_anything/unrealinsights/core/capture.py
@@ -0,0 +1,169 @@
+"""
+Capture orchestration helpers.
+"""
+
+from __future__ import annotations
+
+import os
+import time
+from pathlib import Path
+from typing import Sequence
+
+from cli_anything.unrealinsights.utils import unrealinsights_backend as backend
+
+DEFAULT_CHANNELS = "default"
+EDITOR_BINARY_NAME = "UnrealEditor.exe"
+
+
+def normalize_trace_output_path(
+ target_exe: str,
+ output_trace: str | None = None,
+ current_trace: str | None = None,
+ cwd: str | None = None,
+) -> str:
+ """Resolve the output trace path for a capture workflow."""
+ if output_trace:
+ path = Path(output_trace).expanduser()
+ elif current_trace:
+ path = Path(current_trace).expanduser()
+ else:
+ base_dir = Path(cwd or os.getcwd()).resolve()
+ timestamp = time.strftime("%Y%m%d-%H%M%S")
+ path = base_dir / f"{Path(target_exe).stem}-{timestamp}.utrace"
+
+ if not path.suffix:
+ path = path.with_suffix(".utrace")
+ return str(path.resolve())
+
+
+def build_exec_cmds_arg(exec_cmds: Sequence[str] | None) -> str | None:
+ if not exec_cmds:
+ return None
+ commands = [cmd.strip() for cmd in exec_cmds if cmd.strip()]
+ return ",".join(commands) if commands else None
+
+
+def resolve_engine_root(engine_root: str) -> str:
+ """Normalize an Unreal Engine installation root."""
+ path = Path(engine_root).expanduser().resolve()
+ root = path.parent if path.name.lower() == "engine" else path
+
+ if not root.exists():
+ raise RuntimeError(f"Engine root not found: {root}")
+ if not (root / "Engine").is_dir():
+ raise RuntimeError(f"Engine root must contain an Engine directory: {root}")
+
+ return str(root)
+
+
+def resolve_editor_target(engine_root: str) -> str:
+ """Resolve UnrealEditor.exe from a UE install root or Engine directory."""
+ root = Path(resolve_engine_root(engine_root))
+ editor = root / "Engine" / "Binaries" / "Win64" / EDITOR_BINARY_NAME
+ if not editor.is_file():
+ raise RuntimeError(f"UnrealEditor.exe not found under engine root: {root}")
+ return str(editor.resolve())
+
+
+def resolve_capture_target(
+ target_exe: str | None,
+ project: str | None = None,
+ engine_root: str | None = None,
+ target_args: Sequence[str] | None = None,
+) -> tuple[str, list[str], dict[str, str | None]]:
+ """Resolve the effective target executable and launch args."""
+ resolved_project = None
+ resolved_engine_root = None
+ launch_args = list(target_args or [])
+
+ if project:
+ project_path = Path(project).expanduser().resolve()
+ if not project_path.is_file():
+ raise RuntimeError(f"Project file not found: {project_path}")
+ resolved_project = str(project_path)
+
+ if target_exe:
+ target_path = Path(target_exe).expanduser().resolve()
+ if not target_path.is_file():
+ raise RuntimeError(f"Target executable not found: {target_path}")
+ resolved_target = str(target_path)
+ else:
+ if not resolved_project:
+ raise RuntimeError("Either target_exe or --project must be provided.")
+ if not engine_root:
+ raise RuntimeError("--engine-root is required when inferring UnrealEditor.exe from --project.")
+ resolved_engine_root = resolve_engine_root(engine_root)
+ resolved_target = resolve_editor_target(resolved_engine_root)
+
+ if engine_root and resolved_engine_root is None:
+ resolved_engine_root = resolve_engine_root(engine_root)
+
+ if resolved_project and resolved_project not in launch_args:
+ launch_args = [resolved_project, *launch_args]
+
+ return resolved_target, launch_args, {
+ "project_path": resolved_project,
+ "engine_root": resolved_engine_root,
+ }
+
+
+def build_capture_command(
+ target_exe: str,
+ output_trace: str,
+ channels: str = DEFAULT_CHANNELS,
+ exec_cmds: Sequence[str] | None = None,
+ target_args: Sequence[str] | None = None,
+) -> list[str]:
+ """Build the traced target command line."""
+ target_path = Path(target_exe).expanduser().resolve()
+ if not target_path.is_file():
+ raise RuntimeError(f"Target executable not found: {target_path}")
+
+ command = [str(target_path)]
+ command.extend(target_args or [])
+ command.append(f"-trace={channels}")
+ command.append(f"-tracefile={Path(output_trace).expanduser().resolve()}")
+
+ exec_arg = build_exec_cmds_arg(exec_cmds)
+ if exec_arg:
+ command.append(f"-ExecCmds={exec_arg}")
+
+ return command
+
+
+def run_capture(
+ target_exe: str,
+ output_trace: str,
+ channels: str = DEFAULT_CHANNELS,
+ exec_cmds: Sequence[str] | None = None,
+ target_args: Sequence[str] | None = None,
+ wait: bool = False,
+ timeout: float | None = None,
+) -> dict[str, object]:
+ """Launch a traced target executable."""
+ backend.ensure_parent_dir(output_trace)
+ command = build_capture_command(
+ target_exe,
+ output_trace=output_trace,
+ channels=channels,
+ exec_cmds=exec_cmds,
+ target_args=target_args,
+ )
+ result = backend.run_process(command, timeout=timeout, wait=wait)
+
+ trace_path = Path(output_trace).expanduser().resolve()
+ trace_exists = trace_path.is_file()
+ trace_size = trace_path.stat().st_size if trace_exists else None
+
+ result.update(
+ {
+ "target_exe": str(Path(target_exe).expanduser().resolve()),
+ "target_args": list(target_args or []),
+ "trace_path": str(trace_path),
+ "channels": channels,
+ "trace_exists": trace_exists,
+ "trace_size": trace_size,
+ "succeeded": bool(trace_exists) if wait else True,
+ }
+ )
+ return result
diff --git a/unrealinsights/agent-harness/cli_anything/unrealinsights/core/export.py b/unrealinsights/agent-harness/cli_anything/unrealinsights/core/export.py
new file mode 100644
index 000000000..c587ab5dc
--- /dev/null
+++ b/unrealinsights/agent-harness/cli_anything/unrealinsights/core/export.py
@@ -0,0 +1,259 @@
+"""
+Exporter command construction and execution helpers.
+"""
+
+from __future__ import annotations
+
+import ctypes
+import os
+import shlex
+from pathlib import Path
+
+from cli_anything.unrealinsights.utils import unrealinsights_backend as backend
+
+EXPORTER_COMMANDS = {
+ "threads": "TimingInsights.ExportThreads",
+ "timers": "TimingInsights.ExportTimers",
+ "timing-events": "TimingInsights.ExportTimingEvents",
+ "timer-stats": "TimingInsights.ExportTimerStatistics",
+ "timer-callees": "TimingInsights.ExportTimerCallees",
+ "counters": "TimingInsights.ExportCounters",
+ "counter-values": "TimingInsights.ExportCounterValues",
+}
+
+
+def _quote(value: str) -> str:
+ return f'"{value}"'
+
+
+def _is_legacy_unrealinsights(version: str | None) -> bool:
+ return bool(version and version.startswith("5.3"))
+
+
+def _windows_short_path(path: Path) -> str | None:
+ if os.name != "nt":
+ return None
+ buffer = ctypes.create_unicode_buffer(32768)
+ result = ctypes.windll.kernel32.GetShortPathNameW(str(path), buffer, len(buffer))
+ if result == 0:
+ return None
+ return buffer.value
+
+
+def _legacy_filename_arg(output_path: str) -> str:
+ path = Path(output_path).expanduser().resolve()
+ path_str = str(path)
+ if " " not in path_str:
+ return path_str
+
+ parent = path.parent
+ short_parent = _windows_short_path(parent)
+ if not short_parent:
+ raise RuntimeError(
+ f"Legacy UnrealInsights export requires a path without spaces or a resolvable short path: {path}"
+ )
+ if " " in path.name:
+ raise RuntimeError(
+ f"Legacy UnrealInsights export does not support spaces in the output filename: {path.name}"
+ )
+ return str(Path(short_parent) / path.name)
+
+
+def build_export_exec_command(
+ exporter: str,
+ output_path: str,
+ *,
+ insights_version: str | None = None,
+ columns: str | None = None,
+ threads: str | None = None,
+ timers: str | None = None,
+ start_time: float | None = None,
+ end_time: float | None = None,
+ region: str | None = None,
+ counter: str | None = None,
+) -> str:
+ """Build a TimingInsights exporter command string."""
+ if exporter not in EXPORTER_COMMANDS:
+ raise RuntimeError(f"Unsupported exporter: {exporter}")
+
+ output_abs = str(Path(output_path).expanduser().resolve())
+ if _is_legacy_unrealinsights(insights_version):
+ filename_token = _legacy_filename_arg(output_abs)
+ else:
+ filename_token = _quote(output_abs)
+
+ parts = [EXPORTER_COMMANDS[exporter], filename_token]
+
+ if counter:
+ parts.append(f"-counter={_quote(counter)}")
+ if columns:
+ parts.append(f"-columns={_quote(columns)}")
+ if threads:
+ parts.append(f"-threads={_quote(threads)}")
+ if timers:
+ parts.append(f"-timers={_quote(timers)}")
+ if start_time is not None:
+ parts.append(f"-startTime={start_time}")
+ if end_time is not None:
+ parts.append(f"-endTime={end_time}")
+ if region:
+ parts.append(f"-region={_quote(region)}")
+
+ return " ".join(parts)
+
+
+def build_rsp_exec_command(rsp_path: str) -> str:
+ """Build the response-file execution token."""
+ return f"@={Path(rsp_path).expanduser().resolve()}"
+
+
+def _path_contains_placeholders(path: Path) -> bool:
+ return "{counter}" in path.name or "{region}" in path.name
+
+
+def collect_materialized_outputs(output_path: str) -> list[str]:
+ """Collect actual output files for a requested exporter path."""
+ path = Path(output_path).expanduser().resolve()
+ if _path_contains_placeholders(path):
+ pattern = path.name.replace("{counter}", "*").replace("{region}", "*")
+ return sorted(str(match.resolve()) for match in path.parent.glob(pattern) if match.is_file())
+ if path.is_file():
+ return [str(path)]
+ return []
+
+
+def _token_output_path(command_line: str) -> str | None:
+ try:
+ tokens = shlex.split(command_line, posix=False)
+ except ValueError:
+ return None
+ if len(tokens) < 2:
+ return None
+ return tokens[1].strip('"')
+
+
+def expected_outputs_from_rsp(rsp_path: str) -> list[str]:
+ """Read a response file and infer output files from each command line."""
+ path = Path(rsp_path).expanduser().resolve()
+ outputs: list[str] = []
+ if not path.is_file():
+ return outputs
+
+ for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
+ stripped = line.strip()
+ if not stripped or stripped.startswith("#"):
+ continue
+ output = _token_output_path(stripped)
+ if output:
+ outputs.append(str(Path(output).expanduser().resolve()))
+ return outputs
+
+
+def default_log_path(reference_path: str, suffix: str = ".insights.log") -> str:
+ path = Path(reference_path).expanduser().resolve()
+ return str(path.with_name(f"{path.stem}{suffix}"))
+
+
+def _execute_insights(
+ insights_exe: str,
+ trace_path: str,
+ exec_command: str,
+ expected_outputs: list[str],
+ log_path: str,
+) -> dict[str, object]:
+ backend.ensure_parent_dir(log_path)
+ for output in expected_outputs:
+ if "{" not in Path(output).name:
+ backend.ensure_parent_dir(output)
+
+ raw_command = backend.build_insights_command_line(insights_exe, trace_path, exec_command, log_path)
+ run_result = backend.run_process(
+ raw_command,
+ wait=True,
+ )
+ log_info = backend.parse_unreal_log(log_path)
+
+ actual_outputs: list[str] = []
+ seen: set[str] = set()
+ for output in expected_outputs:
+ for match in collect_materialized_outputs(output):
+ if match not in seen:
+ actual_outputs.append(match)
+ seen.add(match)
+
+ run_result.update(
+ {
+ "trace_path": str(Path(trace_path).expanduser().resolve()),
+ "exec_command": exec_command,
+ "expected_outputs": expected_outputs,
+ "output_files": actual_outputs,
+ "log_path": log_info["path"],
+ "warnings": log_info["warnings"],
+ "errors": log_info["errors"],
+ "succeeded": (
+ not run_result["timed_out"]
+ and run_result["exit_code"] == 0
+ and len(actual_outputs) > 0
+ ),
+ }
+ )
+ return run_result
+
+
+def execute_export(
+ insights_exe: str,
+ trace_path: str,
+ exporter: str,
+ output_path: str,
+ *,
+ insights_version: str | None = None,
+ columns: str | None = None,
+ threads: str | None = None,
+ timers: str | None = None,
+ start_time: float | None = None,
+ end_time: float | None = None,
+ region: str | None = None,
+ counter: str | None = None,
+ log_path: str | None = None,
+) -> dict[str, object]:
+ """Execute a single TimingInsights exporter."""
+ output_abs = str(Path(output_path).expanduser().resolve())
+ resolved_log_path = log_path or default_log_path(output_abs)
+ exec_command = build_export_exec_command(
+ exporter,
+ output_abs,
+ insights_version=insights_version,
+ columns=columns,
+ threads=threads,
+ timers=timers,
+ start_time=start_time,
+ end_time=end_time,
+ region=region,
+ counter=counter,
+ )
+ return _execute_insights(
+ insights_exe,
+ trace_path,
+ exec_command=exec_command,
+ expected_outputs=[output_abs],
+ log_path=resolved_log_path,
+ )
+
+
+def execute_response_file(
+ insights_exe: str,
+ trace_path: str,
+ rsp_path: str,
+ *,
+ log_path: str | None = None,
+) -> dict[str, object]:
+ """Execute a response file batch export."""
+ rsp_abs = str(Path(rsp_path).expanduser().resolve())
+ resolved_log_path = log_path or default_log_path(rsp_abs)
+ return _execute_insights(
+ insights_exe,
+ trace_path,
+ exec_command=build_rsp_exec_command(rsp_abs),
+ expected_outputs=expected_outputs_from_rsp(rsp_abs),
+ log_path=resolved_log_path,
+ )
diff --git a/unrealinsights/agent-harness/cli_anything/unrealinsights/core/session.py b/unrealinsights/agent-harness/cli_anything/unrealinsights/core/session.py
new file mode 100644
index 000000000..e6de13ae9
--- /dev/null
+++ b/unrealinsights/agent-harness/cli_anything/unrealinsights/core/session.py
@@ -0,0 +1,38 @@
+"""
+In-memory session state for Unreal Insights CLI workflows.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from pathlib import Path
+
+
+@dataclass
+class UnrealInsightsSession:
+ trace_path: str | None = None
+ insights_exe: str | None = None
+ trace_server_exe: str | None = None
+
+ def set_trace(self, trace_path: str | None):
+ self.trace_path = str(Path(trace_path).expanduser().resolve()) if trace_path else None
+
+ def set_insights_exe(self, path: str | None):
+ self.insights_exe = str(Path(path).expanduser().resolve()) if path else None
+
+ def set_trace_server_exe(self, path: str | None):
+ self.trace_server_exe = str(Path(path).expanduser().resolve()) if path else None
+
+ def trace_info(self) -> dict[str, object]:
+ if self.trace_path is None:
+ return {
+ "trace_path": None,
+ "exists": False,
+ }
+
+ path = Path(self.trace_path)
+ return {
+ "trace_path": str(path),
+ "exists": path.is_file(),
+ "file_size": path.stat().st_size if path.is_file() else None,
+ }
diff --git a/unrealinsights/agent-harness/cli_anything/unrealinsights/skills/SKILL.md b/unrealinsights/agent-harness/cli_anything/unrealinsights/skills/SKILL.md
new file mode 100644
index 000000000..8943b48da
--- /dev/null
+++ b/unrealinsights/agent-harness/cli_anything/unrealinsights/skills/SKILL.md
@@ -0,0 +1,102 @@
+---
+name: "cli-anything-unrealinsights"
+description: "Capture Unreal Engine traces to .utrace files and export Unreal Insights timing/counter data in headless mode."
+---
+
+# cli-anything-unrealinsights
+
+Use this CLI when you need agent-friendly access to Unreal Insights trace capture
+and exporter workflows on Windows.
+
+## Prerequisites
+
+- Unreal Engine 5.5+ installed with `UnrealInsights.exe`
+- Windows
+- Optional explicit env vars:
+ - `UNREALINSIGHTS_EXE`
+ - `UNREAL_TRACE_SERVER_EXE`
+ - `UNREALINSIGHTS_TRACE`
+
+## Core Commands
+
+### Backend discovery
+
+```powershell
+cli-anything-unrealinsights --json backend info
+```
+
+To use a source-built engine's matching `UnrealInsights.exe`:
+
+```powershell
+cli-anything-unrealinsights --json backend ensure-insights `
+ --engine-root 'D:\code\D5\d5render-ue5_3'
+```
+
+This first looks for `Engine\Binaries\Win64\UnrealInsights.exe` under the
+specified engine root, then builds it with that engine's `Build.bat` if needed.
+
+### Trace session state
+
+```powershell
+cli-anything-unrealinsights trace set D:\captures\session.utrace
+cli-anything-unrealinsights --json trace info
+```
+
+### Capture orchestration
+
+```powershell
+cli-anything-unrealinsights --json capture run `
+ --project 'D:\Projects\MyGame\MyGame.uproject' `
+ --engine-root 'D:\Program Files\Epic Games\UE_5.5' `
+ --output-trace D:\captures\boot.utrace `
+ --channels "default,bookmark" `
+ --exec-cmd "Trace.Bookmark BootStart" `
+ --wait --timeout 300
+```
+
+You can also keep using the explicit form:
+
+```powershell
+cli-anything-unrealinsights --json capture run `
+ 'D:\Program Files\Epic Games\UE_5.5\Engine\Binaries\Win64\UnrealEditor.exe' `
+ --target-arg 'D:\Projects\MyGame\MyGame.uproject'
+```
+
+### Offline exporters
+
+```powershell
+cli-anything-unrealinsights --json -t D:\captures\session.utrace export threads D:\out\threads.csv
+cli-anything-unrealinsights --json -t D:\captures\session.utrace export timer-stats D:\out\stats.csv --region "EXPORT_CAPTURE"
+cli-anything-unrealinsights --json -t D:\captures\session.utrace export counter-values D:\out\counter_values.csv --counter "*"
+```
+
+### Batch response files
+
+```powershell
+cli-anything-unrealinsights --json -t D:\captures\session.utrace batch run-rsp D:\out\exports.rsp
+```
+
+## JSON Output Guidance
+
+- Prefer `--json` for agent workflows.
+- Export commands return:
+ - `trace_path`
+ - `exec_command`
+ - `output_files`
+ - `log_path`
+ - `exit_code`
+ - `warnings`
+ - `errors`
+ - `succeeded`
+- Capture returns:
+ - `command`
+ - `trace_path`
+ - `trace_exists`
+ - `trace_size`
+ - `pid` or `exit_code`
+
+## Notes
+
+- v1 is Windows-first.
+- v1 supports file-mode capture orchestration only.
+- v1 does not control already-running UE instances or browse trace stores.
diff --git a/unrealinsights/agent-harness/cli_anything/unrealinsights/tests/TEST.md b/unrealinsights/agent-harness/cli_anything/unrealinsights/tests/TEST.md
new file mode 100644
index 000000000..f9492a3ee
--- /dev/null
+++ b/unrealinsights/agent-harness/cli_anything/unrealinsights/tests/TEST.md
@@ -0,0 +1,172 @@
+# TEST.md - Unreal Insights CLI Test Plan
+
+## Test Inventory Plan
+
+- `test_core.py`: 39 unit tests planned
+- `test_full_e2e.py`: 10 E2E tests planned
+
+## Unit Test Plan
+
+### `utils/unrealinsights_backend.py`
+- Validate binary discovery precedence: explicit path, env var, then Windows auto-discovery
+- Validate missing explicit and env paths fail loudly
+- Validate Unreal Insights command construction
+- Validate engine-root binary resolution and build orchestration
+- Planned tests: 13
+
+### `core/capture.py`
+- Validate output trace path normalization
+- Validate traced target command construction
+- Validate `-ExecCmds=` joining semantics
+- Validate `--project + --engine-root` convenience resolution
+- Planned tests: 8
+
+### `core/export.py`
+- Validate exporter command strings for all supported exporters
+- Validate response-file parsing and output inference
+- Validate placeholder-aware output collection
+- Validate legacy UnrealInsights 5.3 export command compatibility
+- Planned tests: 9
+
+### `unrealinsights_cli.py`
+- Validate root and group help
+- Validate JSON error payloads when trace/backend requirements are missing
+- Validate REPL session trace state
+- Validate capture convenience-layer argument handling
+- Planned tests: 7
+
+## E2E Test Plan
+
+### Prerequisites
+- Unreal Engine 5.5+ installed with `UnrealInsights.exe`
+- Optional trace file via `UNREALINSIGHTS_TEST_TRACE`
+- Optional UE/Game executable via `UNREALINSIGHTS_TEST_TARGET_EXE`
+
+### Workflows to validate
+- `backend info` against the local UE install
+- Export threads/timers/timing-events/timer-stats/timer-callees/counters/counter-values from a real `.utrace`
+- Execute a generated response file containing multiple exporter commands
+- Launch a traced target executable in file mode and verify `.utrace` creation
+
+### Output validation
+- All `--json` responses parse correctly
+- Export commands create non-empty output files and surface log paths
+- `batch run-rsp` returns the materialized output list
+- `capture run --wait` returns exit status plus `.utrace` file metadata
+
+## Realistic Workflow Scenarios
+
+### Workflow name: `trace_export_bundle`
+- Simulates: post-capture analysis of a performance trace
+- Operations chained:
+ 1. `trace set`
+ 2. `export threads`
+ 3. `export timer-stats`
+ 4. `export counter-values`
+ 5. `batch run-rsp`
+- Verified:
+ - exporter outputs exist
+ - response-file execution triggers multiple materialized files
+ - JSON payloads include log path and exit code
+
+### Workflow name: `editor_boot_capture`
+- Simulates: launching a traced UE executable and recording startup behavior
+- Operations chained:
+ 1. `capture run`
+ 2. `trace info`
+ 3. optional exporter pass on the produced trace
+- Verified:
+ - traced command line contains `-trace=` and `-tracefile=`
+ - `.utrace` file is created and has size > 0
+
+## Test Results
+
+### Commands run
+
+```bash
+python -m pytest cli_anything/unrealinsights/tests/test_core.py -v --tb=no
+python -m pytest cli_anything/unrealinsights/tests/test_full_e2e.py -v -s --tb=no
+python -m pip install -e .
+cli-anything-unrealinsights --json backend info
+```
+
+### Result summary
+
+- `test_core.py`: 39 passed
+- `test_full_e2e.py`: 1 passed, 9 skipped
+- Manual smoke: installed entrypoint resolved local UE 5.5 binaries successfully
+
+### Coverage notes
+
+- Real export and capture E2E scenarios are env-gated and were skipped because
+ `UNREALINSIGHTS_TEST_TRACE` and `UNREALINSIGHTS_TEST_TARGET_EXE` were not set.
+- The local Windows auto-discovery path was validated against:
+ - `D:\Program Files\Epic Games\UE_5.5\Engine\Binaries\Win64\UnrealInsights.exe`
+ - `D:\Program Files\Epic Games\UE_5.5\Engine\Binaries\Win64\UnrealTraceServer.exe`
+
+### Full pytest output
+
+```text
+============================= test session starts =============================
+platform win32 -- Python 3.11.9, pytest-9.0.3, pluggy-1.6.0 -- C:\Users\aimidi\AppData\Local\Programs\Python\Python311\python.exe
+cachedir: .pytest_cache
+rootdir: D:\code\D5\CLI-Anything-unrealinsights\unrealinsights\agent-harness
+collecting ... collected 39 items
+
+cli_anything/unrealinsights/tests/test_core.py::TestOutputUtils::test_output_json PASSED [ 3%]
+cli_anything/unrealinsights/tests/test_core.py::TestOutputUtils::test_output_table_empty PASSED [ 7%]
+cli_anything/unrealinsights/tests/test_core.py::TestOutputUtils::test_format_size PASSED [ 11%]
+cli_anything/unrealinsights/tests/test_core.py::TestErrorUtils::test_handle_error PASSED [ 14%]
+cli_anything/unrealinsights/tests/test_core.py::TestBackendDiscovery::test_explicit_path_precedence PASSED [ 18%]
+cli_anything/unrealinsights/tests/test_core.py::TestBackendDiscovery::test_env_path_precedence PASSED [ 22%]
+cli_anything/unrealinsights/tests/test_core.py::TestBackendDiscovery::test_auto_discovery PASSED [ 25%]
+cli_anything/unrealinsights/tests/test_core.py::TestBackendDiscovery::test_missing_explicit_path_fails PASSED [ 29%]
+cli_anything/unrealinsights/tests/test_core.py::TestBackendDiscovery::test_build_insights_command PASSED [ 33%]
+cli_anything/unrealinsights/tests/test_core.py::TestCaptureCore::test_normalize_trace_output_path_prefers_explicit PASSED [ 37%]
+cli_anything/unrealinsights/tests/test_core.py::TestCaptureCore::test_build_exec_cmds_arg PASSED [ 40%]
+cli_anything/unrealinsights/tests/test_core.py::TestCaptureCore::test_resolve_engine_root_from_engine_subdir PASSED [ 43%]
+cli_anything/unrealinsights/tests/test_core.py::TestCaptureCore::test_resolve_editor_target PASSED [ 46%]
+cli_anything/unrealinsights/tests/test_core.py::TestCaptureCore::test_resolve_capture_target_from_project_and_engine PASSED [ 50%]
+cli_anything/unrealinsights/tests/test_core.py::TestCaptureCore::test_build_capture_command PASSED [ 53%]
+cli_anything/unrealinsights/tests/test_core.py::TestExportCore::test_build_export_exec_command[threads-TimingInsights.ExportThreads] PASSED [ 56%]
+cli_anything/unrealinsights/tests/test_core.py::TestExportCore::test_build_export_exec_command[timers-TimingInsights.ExportTimers] PASSED [ 59%]
+cli_anything/unrealinsights/tests/test_core.py::TestExportCore::test_build_export_exec_command[timing-events-TimingInsights.ExportTimingEvents] PASSED [ 62%]
+cli_anything/unrealinsights/tests/test_core.py::TestExportCore::test_build_export_exec_command[timer-stats-TimingInsights.ExportTimerStatistics] PASSED [ 65%]
+cli_anything/unrealinsights/tests/test_core.py::TestExportCore::test_build_export_exec_command[timer-callees-TimingInsights.ExportTimerCallees] PASSED [ 68%]
+cli_anything/unrealinsights/tests/test_core.py::TestExportCore::test_build_export_exec_command[counters-TimingInsights.ExportCounters] PASSED [ 71%]
+cli_anything/unrealinsights/tests/test_core.py::TestExportCore::test_build_export_exec_command[counter-values-TimingInsights.ExportCounterValues] PASSED [ 75%]
+cli_anything/unrealinsights/tests/test_core.py::TestExportCore::test_build_rsp_exec_command PASSED [ 78%]
+cli_anything/unrealinsights/tests/test_core.py::TestExportCore::test_expected_outputs_from_rsp PASSED [ 81%]
+cli_anything/unrealinsights/tests/test_core.py::TestExportCore::test_collect_materialized_outputs_placeholder PASSED [ 84%]
+cli_anything/unrealinsights/tests/test_core.py::TestCLIHelp::test_main_help PASSED [ 87%]
+cli_anything/unrealinsights/tests/test_core.py::TestCLIHelp::test_group_help PASSED [ 90%]
+cli_anything/unrealinsights/tests/test_core.py::TestCLIJsonErrors::test_export_threads_requires_trace PASSED [ 93%]
+cli_anything/unrealinsights/tests/test_core.py::TestCLIJsonErrors::test_backend_info_json PASSED [ 96%]
+cli_anything/unrealinsights/tests/test_core.py::TestCLIJsonErrors::test_capture_project_requires_engine_root PASSED [ 96%]
+cli_anything/unrealinsights/tests/test_core.py::TestREPLSessionState::test_trace_set_then_info_in_repl PASSED [ 99%]
+cli_anything/unrealinsights/tests/test_core.py::TestCaptureCLIConvenience::test_capture_run_with_project_and_engine_root PASSED [100%]
+
+============================= 39 passed in 0.52s ==============================
+
+============================= test session starts =============================
+platform win32 -- Python 3.11.9, pytest-9.0.3, pluggy-1.6.0 -- C:\Users\aimidi\AppData\Local\Programs\Python\Python311\python.exe
+cachedir: .pytest_cache
+rootdir: D:\code\D5\CLI-Anything-unrealinsights\unrealinsights\agent-harness
+collecting ... [_resolve_cli] Using installed command: C:\Users\aimidi\AppData\Local\Programs\Python\Python311\Scripts\cli-anything-unrealinsights.EXE
+[_resolve_cli] Using installed command: C:\Users\aimidi\AppData\Local\Programs\Python\Python311\Scripts\cli-anything-unrealinsights.EXE
+[_resolve_cli] Using installed command: C:\Users\aimidi\AppData\Local\Programs\Python\Python311\Scripts\cli-anything-unrealinsights.EXE
+collected 10 items
+
+cli_anything/unrealinsights/tests/test_full_e2e.py::TestCLISmoke::test_backend_info PASSED
+cli_anything/unrealinsights/tests/test_full_e2e.py::TestExportE2E::test_exporter_creates_output[threads-extra_args0] SKIPPED
+cli_anything/unrealinsights/tests/test_full_e2e.py::TestExportE2E::test_exporter_creates_output[timers-extra_args1] SKIPPED
+cli_anything/unrealinsights/tests/test_full_e2e.py::TestExportE2E::test_exporter_creates_output[timing-events-extra_args2] SKIPPED
+cli_anything/unrealinsights/tests/test_full_e2e.py::TestExportE2E::test_exporter_creates_output[timer-stats-extra_args3] SKIPPED
+cli_anything/unrealinsights/tests/test_full_e2e.py::TestExportE2E::test_exporter_creates_output[timer-callees-extra_args4] SKIPPED
+cli_anything/unrealinsights/tests/test_full_e2e.py::TestExportE2E::test_exporter_creates_output[counters-extra_args5] SKIPPED
+cli_anything/unrealinsights/tests/test_full_e2e.py::TestExportE2E::test_exporter_creates_output[counter-values-extra_args6] SKIPPED
+cli_anything/unrealinsights/tests/test_full_e2e.py::TestExportE2E::test_batch_run_rsp SKIPPED
+cli_anything/unrealinsights/tests/test_full_e2e.py::TestCaptureE2E::test_capture_run_wait SKIPPED
+
+======================== 1 passed, 9 skipped in 0.85s =========================
+```
diff --git a/unrealinsights/agent-harness/cli_anything/unrealinsights/tests/__init__.py b/unrealinsights/agent-harness/cli_anything/unrealinsights/tests/__init__.py
new file mode 100644
index 000000000..ed87f3cc2
--- /dev/null
+++ b/unrealinsights/agent-harness/cli_anything/unrealinsights/tests/__init__.py
@@ -0,0 +1 @@
+"""Tests for cli-anything-unrealinsights."""
diff --git a/unrealinsights/agent-harness/cli_anything/unrealinsights/tests/test_core.py b/unrealinsights/agent-harness/cli_anything/unrealinsights/tests/test_core.py
new file mode 100644
index 000000000..061a13746
--- /dev/null
+++ b/unrealinsights/agent-harness/cli_anything/unrealinsights/tests/test_core.py
@@ -0,0 +1,498 @@
+"""
+Unit tests for Unreal Insights harness modules.
+"""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+from unittest.mock import patch
+
+import pytest
+from click.testing import CliRunner
+
+
+class TestOutputUtils:
+ def test_output_json(self):
+ import io
+
+ from cli_anything.unrealinsights.utils.output import output_json
+
+ buf = io.StringIO()
+ output_json({"ok": True, "value": 42}, file=buf)
+ data = json.loads(buf.getvalue())
+ assert data["ok"] is True
+ assert data["value"] == 42
+
+ def test_output_table_empty(self):
+ import io
+
+ from cli_anything.unrealinsights.utils.output import output_table
+
+ buf = io.StringIO()
+ output_table([], ["col"], file=buf)
+ assert "(no data)" in buf.getvalue()
+
+ def test_format_size(self):
+ from cli_anything.unrealinsights.utils.output import format_size
+
+ assert format_size(10) == "10 B"
+ assert "KB" in format_size(4096)
+
+
+class TestErrorUtils:
+ def test_handle_error(self):
+ from cli_anything.unrealinsights.utils.errors import handle_error
+
+ result = handle_error(ValueError("bad"))
+ assert result["error"] == "bad"
+ assert result["type"] == "ValueError"
+
+
+def _make_fake_binary(root: Path, binary_name: str) -> Path:
+ target = root / "UE_5.5" / "Engine" / "Binaries" / "Win64" / binary_name
+ target.parent.mkdir(parents=True, exist_ok=True)
+ target.write_text("fake-binary", encoding="utf-8")
+ return target
+
+
+class TestBackendDiscovery:
+ @patch("cli_anything.unrealinsights.utils.unrealinsights_backend._read_windows_product_version", return_value="5.5.4")
+ def test_explicit_path_precedence(self, _mock_version, tmp_path, monkeypatch):
+ from cli_anything.unrealinsights.utils.unrealinsights_backend import resolve_unrealinsights_exe
+
+ explicit = tmp_path / "explicit" / "UnrealInsights.exe"
+ explicit.parent.mkdir(parents=True)
+ explicit.write_text("x", encoding="utf-8")
+
+ env_binary = tmp_path / "env" / "UnrealInsights.exe"
+ env_binary.parent.mkdir(parents=True)
+ env_binary.write_text("x", encoding="utf-8")
+ monkeypatch.setenv("UNREALINSIGHTS_EXE", str(env_binary))
+
+ auto_root = tmp_path / "Epic Games"
+ _make_fake_binary(auto_root, "UnrealInsights.exe")
+
+ result = resolve_unrealinsights_exe(str(explicit), search_roots=[auto_root])
+ assert result["source"] == "explicit"
+ assert result["path"] == str(explicit.resolve())
+
+ @patch("cli_anything.unrealinsights.utils.unrealinsights_backend._read_windows_product_version", return_value="5.5.4")
+ def test_env_path_precedence(self, _mock_version, tmp_path, monkeypatch):
+ from cli_anything.unrealinsights.utils.unrealinsights_backend import resolve_unrealinsights_exe
+
+ env_binary = tmp_path / "env" / "UnrealInsights.exe"
+ env_binary.parent.mkdir(parents=True)
+ env_binary.write_text("x", encoding="utf-8")
+ monkeypatch.setenv("UNREALINSIGHTS_EXE", str(env_binary))
+
+ auto_root = tmp_path / "Epic Games"
+ _make_fake_binary(auto_root, "UnrealInsights.exe")
+
+ result = resolve_unrealinsights_exe(search_roots=[auto_root])
+ assert result["source"] == "env:UNREALINSIGHTS_EXE"
+ assert result["path"] == str(env_binary.resolve())
+
+ @patch("cli_anything.unrealinsights.utils.unrealinsights_backend._read_windows_product_version", return_value="5.5.4")
+ def test_auto_discovery(self, _mock_version, tmp_path, monkeypatch):
+ from cli_anything.unrealinsights.utils.unrealinsights_backend import resolve_unrealinsights_exe
+
+ monkeypatch.delenv("UNREALINSIGHTS_EXE", raising=False)
+ auto_root = tmp_path / "Epic Games"
+ auto_binary = _make_fake_binary(auto_root, "UnrealInsights.exe")
+
+ result = resolve_unrealinsights_exe(search_roots=[auto_root])
+ assert result["source"].startswith("auto:")
+ assert result["path"] == str(auto_binary.resolve())
+
+ def test_missing_explicit_path_fails(self, tmp_path):
+ from cli_anything.unrealinsights.utils.unrealinsights_backend import resolve_unrealinsights_exe
+
+ with pytest.raises(RuntimeError):
+ resolve_unrealinsights_exe(str(tmp_path / "missing.exe"))
+
+ def test_build_insights_command(self, tmp_path):
+ from cli_anything.unrealinsights.utils.unrealinsights_backend import build_insights_command
+
+ command = build_insights_command(
+ str(tmp_path / "UnrealInsights.exe"),
+ str(tmp_path / "trace.utrace"),
+ 'TimingInsights.ExportThreads "D:\\out\\threads.csv"',
+ str(tmp_path / "threads.log"),
+ )
+ assert any(part.startswith("-OpenTraceFile=") for part in command)
+ assert any(part.startswith("-ExecOnAnalysisCompleteCmd=") for part in command)
+
+ def test_build_insights_command_line(self, tmp_path):
+ from cli_anything.unrealinsights.utils.unrealinsights_backend import build_insights_command_line
+
+ command = build_insights_command_line(
+ str(tmp_path / "UnrealInsights.exe"),
+ str(tmp_path / "trace.utrace"),
+ 'TimingInsights.ExportThreads D:\\out\\threads.csv',
+ str(tmp_path / "threads.log"),
+ )
+ assert "-ExecOnAnalysisCompleteCmd=" in command
+ assert command.startswith('"')
+
+ @patch("cli_anything.unrealinsights.utils.unrealinsights_backend._read_windows_product_version", return_value="5.3.0")
+ def test_resolve_binary_from_engine_root(self, _mock_version, tmp_path):
+ from cli_anything.unrealinsights.utils.unrealinsights_backend import resolve_binary_from_engine_root
+
+ binary = _make_fake_binary(tmp_path, "UnrealInsights.exe")
+ result = resolve_binary_from_engine_root("UnrealInsights.exe", str(tmp_path / "UE_5.5"))
+ assert result["path"] == str(binary.resolve())
+ assert result["source"] == "engine:UE_5.5"
+
+ @patch("cli_anything.unrealinsights.utils.unrealinsights_backend.subprocess.run")
+ def test_build_engine_program(self, mock_run, tmp_path):
+ from cli_anything.unrealinsights.utils.unrealinsights_backend import build_engine_program
+
+ build_bat = tmp_path / "UE_5.5" / "Engine" / "Build" / "BatchFiles" / "Build.bat"
+ build_bat.parent.mkdir(parents=True, exist_ok=True)
+ build_bat.write_text("echo build", encoding="utf-8")
+
+ mock_run.return_value = type("Result", (), {"stdout": "ok", "stderr": "", "returncode": 0})()
+ result = build_engine_program(str(tmp_path / "UE_5.5"), "UnrealInsights")
+ assert result["succeeded"] is True
+ assert Path(result["log_path"]).is_file()
+
+ @patch("cli_anything.unrealinsights.utils.unrealinsights_backend._read_windows_product_version", return_value="5.3.0")
+ @patch("cli_anything.unrealinsights.utils.unrealinsights_backend.build_engine_program")
+ def test_ensure_engine_unrealinsights_builds_when_missing(self, mock_build, _mock_version, tmp_path):
+ from cli_anything.unrealinsights.utils.unrealinsights_backend import ensure_engine_unrealinsights
+
+ engine_root = tmp_path / "UE_5.5"
+ (engine_root / "Engine" / "Binaries" / "Win64").mkdir(parents=True, exist_ok=True)
+ (engine_root / "Engine" / "Build" / "BatchFiles").mkdir(parents=True, exist_ok=True)
+ (engine_root / "Engine" / "Build" / "BatchFiles" / "Build.bat").write_text("echo build", encoding="utf-8")
+ built_exe = engine_root / "Engine" / "Binaries" / "Win64" / "UnrealInsights.exe"
+ built_exe.write_text("x", encoding="utf-8")
+ mock_build.return_value = {
+ "command": ["Build.bat"],
+ "cwd": str(engine_root),
+ "log_path": str(engine_root / "build.log"),
+ "exit_code": 0,
+ "timed_out": False,
+ "stdout": "",
+ "stderr": "",
+ "succeeded": True,
+ }
+
+ result = ensure_engine_unrealinsights(str(engine_root))
+ assert result["insights"]["path"] == str(built_exe.resolve())
+
+ def test_ensure_engine_unrealinsights_no_build_errors_when_missing(self, tmp_path):
+ from cli_anything.unrealinsights.utils.unrealinsights_backend import ensure_engine_unrealinsights
+
+ engine_root = tmp_path / "UE_5.5"
+ (engine_root / "Engine" / "Binaries" / "Win64").mkdir(parents=True, exist_ok=True)
+ with pytest.raises(RuntimeError):
+ ensure_engine_unrealinsights(str(engine_root), build_if_missing=False)
+
+
+class TestCaptureCore:
+ def test_normalize_trace_output_path_prefers_explicit(self, tmp_path):
+ from cli_anything.unrealinsights.core.capture import normalize_trace_output_path
+
+ path = normalize_trace_output_path("game.exe", output_trace=str(tmp_path / "capture"))
+ assert path.endswith(".utrace")
+
+ def test_build_exec_cmds_arg(self):
+ from cli_anything.unrealinsights.core.capture import build_exec_cmds_arg
+
+ assert build_exec_cmds_arg(["Trace.Bookmark Boot", "Trace.RegionBegin Boot"]) == (
+ "Trace.Bookmark Boot,Trace.RegionBegin Boot"
+ )
+
+ def test_resolve_engine_root_from_engine_subdir(self, tmp_path):
+ from cli_anything.unrealinsights.core.capture import resolve_engine_root
+
+ engine_dir = tmp_path / "UE_5.5" / "Engine"
+ engine_dir.mkdir(parents=True)
+ assert resolve_engine_root(str(engine_dir)) == str((tmp_path / "UE_5.5").resolve())
+
+ def test_resolve_editor_target(self, tmp_path):
+ from cli_anything.unrealinsights.core.capture import resolve_editor_target
+
+ editor = tmp_path / "UE_5.5" / "Engine" / "Binaries" / "Win64" / "UnrealEditor.exe"
+ editor.parent.mkdir(parents=True)
+ editor.write_text("x", encoding="utf-8")
+ assert resolve_editor_target(str(tmp_path / "UE_5.5")) == str(editor.resolve())
+
+ def test_resolve_capture_target_from_project_and_engine(self, tmp_path):
+ from cli_anything.unrealinsights.core.capture import resolve_capture_target
+
+ editor = tmp_path / "UE_5.5" / "Engine" / "Binaries" / "Win64" / "UnrealEditor.exe"
+ editor.parent.mkdir(parents=True)
+ editor.write_text("x", encoding="utf-8")
+ project = tmp_path / "Project" / "MyGame.uproject"
+ project.parent.mkdir(parents=True)
+ project.write_text("{}", encoding="utf-8")
+
+ target_exe, target_args, launch_info = resolve_capture_target(
+ None,
+ project=str(project),
+ engine_root=str(tmp_path / "UE_5.5"),
+ target_args=["-game"],
+ )
+ assert target_exe == str(editor.resolve())
+ assert target_args[0] == str(project.resolve())
+ assert "-game" in target_args
+ assert launch_info["project_path"] == str(project.resolve())
+
+ def test_build_capture_command(self, tmp_path):
+ from cli_anything.unrealinsights.core.capture import build_capture_command
+
+ exe = tmp_path / "Game.exe"
+ exe.write_text("x", encoding="utf-8")
+ trace = tmp_path / "capture.utrace"
+ command = build_capture_command(
+ str(exe),
+ str(trace),
+ channels="default,bookmark",
+ exec_cmds=["Trace.Bookmark Boot"],
+ target_args=["MyGame.uproject", "-game"],
+ )
+ assert command[0] == str(exe.resolve())
+ assert "MyGame.uproject" in command
+ assert "-trace=default,bookmark" in command
+ assert any(arg.startswith("-tracefile=") for arg in command)
+ assert any(arg.startswith("-ExecCmds=") for arg in command)
+
+
+class TestExportCore:
+ @pytest.mark.parametrize(
+ ("exporter", "expected"),
+ [
+ ("threads", "TimingInsights.ExportThreads"),
+ ("timers", "TimingInsights.ExportTimers"),
+ ("timing-events", "TimingInsights.ExportTimingEvents"),
+ ("timer-stats", "TimingInsights.ExportTimerStatistics"),
+ ("timer-callees", "TimingInsights.ExportTimerCallees"),
+ ("counters", "TimingInsights.ExportCounters"),
+ ("counter-values", "TimingInsights.ExportCounterValues"),
+ ],
+ )
+ def test_build_export_exec_command(self, exporter, expected, tmp_path):
+ from cli_anything.unrealinsights.core.export import build_export_exec_command
+
+ command = build_export_exec_command(
+ exporter,
+ str(tmp_path / f"{exporter}.csv"),
+ columns="ThreadId,TimerId" if exporter in ("timing-events", "timer-stats", "counter-values") else None,
+ threads="GameThread" if exporter in ("timing-events", "timer-stats", "timer-callees") else None,
+ timers="*" if exporter in ("timing-events", "timer-stats", "timer-callees") else None,
+ counter="*" if exporter == "counter-values" else None,
+ )
+ assert command.startswith(expected)
+
+ def test_build_rsp_exec_command(self, tmp_path):
+ from cli_anything.unrealinsights.core.export import build_rsp_exec_command
+
+ command = build_rsp_exec_command(str(tmp_path / "exports.rsp"))
+ assert command.startswith("@=")
+
+ def test_build_export_exec_command_legacy_53_unquoted_filename(self, tmp_path):
+ from cli_anything.unrealinsights.core.export import build_export_exec_command
+
+ command = build_export_exec_command(
+ "threads",
+ str(tmp_path / "threads.csv"),
+ insights_version="5.3.0",
+ )
+ assert '"{}"'.format(str((tmp_path / "threads.csv").resolve())) not in command
+ assert str((tmp_path / "threads.csv").resolve()) in command
+
+ def test_expected_outputs_from_rsp(self, tmp_path):
+ from cli_anything.unrealinsights.core.export import expected_outputs_from_rsp
+
+ rsp = tmp_path / "exports.rsp"
+ rsp.write_text(
+ "\n".join(
+ [
+ "# comment",
+ f'TimingInsights.ExportThreads "{tmp_path / "threads.csv"}"',
+ f'TimingInsights.ExportTimers "{tmp_path / "timers.csv"}"',
+ ]
+ ),
+ encoding="utf-8",
+ )
+ outputs = expected_outputs_from_rsp(str(rsp))
+ assert str((tmp_path / "threads.csv").resolve()) in outputs
+ assert str((tmp_path / "timers.csv").resolve()) in outputs
+
+ def test_collect_materialized_outputs_placeholder(self, tmp_path):
+ from cli_anything.unrealinsights.core.export import collect_materialized_outputs
+
+ (tmp_path / "stats_GameThread.csv").write_text("ok", encoding="utf-8")
+ outputs = collect_materialized_outputs(str(tmp_path / "stats_{region}.csv"))
+ assert str((tmp_path / "stats_GameThread.csv").resolve()) in outputs
+
+
+class TestCLIHelp:
+ def test_main_help(self):
+ from cli_anything.unrealinsights.unrealinsights_cli import cli
+
+ runner = CliRunner()
+ result = runner.invoke(cli, ["--help"])
+ assert result.exit_code == 0
+ assert "Unreal Insights harness" in result.output
+
+ def test_group_help(self):
+ from cli_anything.unrealinsights.unrealinsights_cli import cli
+
+ runner = CliRunner()
+ for group in ("backend", "trace", "capture", "export", "batch"):
+ result = runner.invoke(cli, [group, "--help"])
+ assert result.exit_code == 0, f"{group} help failed"
+
+
+class TestCLIJsonErrors:
+ @patch("cli_anything.unrealinsights.unrealinsights_cli.resolve_unrealinsights_exe")
+ def test_export_threads_requires_trace(self, _mock_resolve):
+ from cli_anything.unrealinsights.unrealinsights_cli import cli
+
+ runner = CliRunner()
+ result = runner.invoke(cli, ["--json", "export", "threads", "out.csv"])
+ assert result.exit_code == 1
+ data = json.loads(result.output)
+ assert "error" in data
+
+ @patch("cli_anything.unrealinsights.unrealinsights_cli.resolve_unrealinsights_exe")
+ @patch("cli_anything.unrealinsights.unrealinsights_cli.resolve_trace_server_exe")
+ def test_backend_info_json(self, mock_trace_server, mock_insights):
+ from cli_anything.unrealinsights.unrealinsights_cli import cli
+
+ mock_insights.return_value = {
+ "available": True,
+ "path": "C:/UE/UnrealInsights.exe",
+ "source": "explicit",
+ "version": "5.5.4",
+ "engine_version_hint": "5.5",
+ }
+ mock_trace_server.return_value = {
+ "available": False,
+ "path": None,
+ "source": "unresolved",
+ "version": None,
+ "engine_version_hint": None,
+ "error": "missing",
+ }
+
+ runner = CliRunner()
+ result = runner.invoke(cli, ["--json", "backend", "info"])
+ assert result.exit_code == 0
+ data = json.loads(result.output)
+ assert data["insights"]["path"].endswith("UnrealInsights.exe")
+
+ def test_capture_project_requires_engine_root(self, tmp_path):
+ from cli_anything.unrealinsights.unrealinsights_cli import cli
+
+ project = tmp_path / "MyGame.uproject"
+ project.write_text("{}", encoding="utf-8")
+
+ runner = CliRunner()
+ result = runner.invoke(cli, ["--json", "capture", "run", "--project", str(project)])
+ assert result.exit_code == 1
+ data = json.loads(result.output)
+ assert "engine-root" in data["error"]
+
+ @patch("cli_anything.unrealinsights.unrealinsights_cli.ensure_engine_unrealinsights")
+ def test_backend_ensure_insights_json(self, mock_ensure):
+ from cli_anything.unrealinsights.unrealinsights_cli import cli
+
+ mock_ensure.return_value = {
+ "engine_root": "D:/UE_5.3",
+ "insights": {
+ "available": True,
+ "path": "D:/UE_5.3/Engine/Binaries/Win64/UnrealInsights.exe",
+ "source": "engine:UE_5.3",
+ "version": "5.3.0",
+ "engine_version_hint": None,
+ },
+ "trace_server": {
+ "available": False,
+ "path": None,
+ "source": "unresolved",
+ "version": None,
+ "engine_version_hint": None,
+ "error": "missing",
+ },
+ "build_attempted": False,
+ "build": None,
+ }
+
+ runner = CliRunner()
+ result = runner.invoke(cli, ["--json", "backend", "ensure-insights", "--engine-root", "D:/UE_5.3"])
+ assert result.exit_code == 0
+ data = json.loads(result.output)
+ assert data["insights"]["path"].endswith("UnrealInsights.exe")
+
+
+class TestREPLSessionState:
+ def test_trace_set_then_info_in_repl(self):
+ from cli_anything.unrealinsights.unrealinsights_cli import cli
+
+ with patch(
+ "cli_anything.unrealinsights.utils.repl_skin.ReplSkin.create_prompt_session",
+ return_value=None,
+ ):
+ runner = CliRunner()
+ result = runner.invoke(
+ cli,
+ input="trace set sample.utrace\ntrace info\nquit\n",
+ )
+ assert result.exit_code == 0
+ assert "sample.utrace" in result.output
+
+
+class TestCaptureCLIConvenience:
+ @patch("cli_anything.unrealinsights.unrealinsights_cli.run_capture")
+ def test_capture_run_with_project_and_engine_root(self, mock_run_capture, tmp_path):
+ from cli_anything.unrealinsights.unrealinsights_cli import cli
+
+ editor = tmp_path / "UE_5.5" / "Engine" / "Binaries" / "Win64" / "UnrealEditor.exe"
+ editor.parent.mkdir(parents=True)
+ editor.write_text("x", encoding="utf-8")
+ project = tmp_path / "Project" / "MyGame.uproject"
+ project.parent.mkdir(parents=True)
+ project.write_text("{}", encoding="utf-8")
+
+ mock_run_capture.return_value = {
+ "command": [str(editor.resolve()), str(project.resolve()), "-trace=default"],
+ "waited": True,
+ "timed_out": False,
+ "exit_code": 0,
+ "stdout": "",
+ "stderr": "",
+ "pid": None,
+ "target_exe": str(editor.resolve()),
+ "target_args": [str(project.resolve())],
+ "trace_path": str((tmp_path / "capture.utrace").resolve()),
+ "channels": "default",
+ "trace_exists": True,
+ "trace_size": 10,
+ "succeeded": True,
+ }
+
+ runner = CliRunner()
+ result = runner.invoke(
+ cli,
+ [
+ "--json",
+ "capture",
+ "run",
+ "--project",
+ str(project),
+ "--engine-root",
+ str(tmp_path / "UE_5.5"),
+ "--output-trace",
+ str(tmp_path / "capture.utrace"),
+ "--wait",
+ ],
+ )
+ assert result.exit_code == 0
+ mock_run_capture.assert_called_once()
+ _, kwargs = mock_run_capture.call_args
+ assert kwargs["target_args"][0] == str(project.resolve())
diff --git a/unrealinsights/agent-harness/cli_anything/unrealinsights/tests/test_full_e2e.py b/unrealinsights/agent-harness/cli_anything/unrealinsights/tests/test_full_e2e.py
new file mode 100644
index 000000000..de2cb5c9c
--- /dev/null
+++ b/unrealinsights/agent-harness/cli_anything/unrealinsights/tests/test_full_e2e.py
@@ -0,0 +1,177 @@
+"""
+End-to-end tests for the Unreal Insights harness.
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import subprocess
+import sys
+import tempfile
+from pathlib import Path
+
+import pytest
+
+HARNESS_ROOT = str(Path(__file__).resolve().parents[3])
+TEST_TRACE = os.environ.get("UNREALINSIGHTS_TEST_TRACE", "")
+TEST_TARGET_EXE = os.environ.get("UNREALINSIGHTS_TEST_TARGET_EXE", "")
+
+HAS_TRACE = os.path.isfile(TEST_TRACE) if TEST_TRACE else False
+HAS_TARGET = os.path.isfile(TEST_TARGET_EXE) if TEST_TARGET_EXE else False
+HAS_LOCAL_INSIGHTS = os.path.isfile(
+ r"D:\Program Files\Epic Games\UE_5.5\Engine\Binaries\Win64\UnrealInsights.exe"
+) or bool(os.environ.get("UNREALINSIGHTS_EXE"))
+
+skip_no_trace = pytest.mark.skipif(not HAS_TRACE, reason="UNREALINSIGHTS_TEST_TRACE not set or missing")
+skip_no_target = pytest.mark.skipif(not HAS_TARGET, reason="UNREALINSIGHTS_TEST_TARGET_EXE not set or missing")
+skip_no_local_ue = pytest.mark.skipif(not HAS_LOCAL_INSIGHTS, reason="No local Unreal Insights install detected")
+
+
+def _resolve_cli(name: 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 .")
+ print("[_resolve_cli] Falling back to module invocation")
+ return [sys.executable, "-m", "cli_anything.unrealinsights.unrealinsights_cli"]
+
+
+def _cli_env():
+ env = os.environ.copy()
+ pythonpath = env.get("PYTHONPATH", "")
+ env["PYTHONPATH"] = HARNESS_ROOT if not pythonpath else f"{HARNESS_ROOT}{os.pathsep}{pythonpath}"
+ return env
+
+
+class TestCLISmoke:
+ CLI_BASE = _resolve_cli("cli-anything-unrealinsights")
+
+ def _run(self, args, check=True, timeout=180):
+ return subprocess.run(
+ self.CLI_BASE + args,
+ capture_output=True,
+ text=True,
+ check=check,
+ timeout=timeout,
+ env=_cli_env(),
+ )
+
+ @skip_no_local_ue
+ def test_backend_info(self):
+ result = self._run(["--json", "backend", "info"])
+ assert result.returncode == 0
+ data = json.loads(result.stdout)
+ assert data["insights"]["available"] is True
+ assert data["insights"]["path"].lower().endswith("unrealinsights.exe")
+
+
+@skip_no_trace
+class TestExportE2E:
+ CLI_BASE = _resolve_cli("cli-anything-unrealinsights")
+
+ def _run(self, args, check=True, timeout=180):
+ return subprocess.run(
+ self.CLI_BASE + args,
+ capture_output=True,
+ text=True,
+ check=check,
+ timeout=timeout,
+ env=_cli_env(),
+ )
+
+ @pytest.mark.parametrize(
+ ("subcommand", "extra_args"),
+ [
+ ("threads", []),
+ ("timers", []),
+ ("timing-events", ["--threads", "GameThread", "--timers", "*"]),
+ ("timer-stats", ["--threads", "GameThread", "--timers", "*"]),
+ ("timer-callees", ["--threads", "GameThread", "--timers", "*"]),
+ ("counters", []),
+ ("counter-values", ["--counter", "*"]),
+ ],
+ )
+ def test_exporter_creates_output(self, subcommand, extra_args):
+ with tempfile.TemporaryDirectory() as tmpdir:
+ output = os.path.join(tmpdir, f"{subcommand}.csv")
+ result = self._run(
+ ["--json", "-t", TEST_TRACE, "export", subcommand, output, *extra_args],
+ check=False,
+ )
+ if result.returncode != 0:
+ pytest.skip(f"{subcommand} exporter failed for supplied trace")
+ data = json.loads(result.stdout)
+ assert data["output_files"]
+ for path in data["output_files"]:
+ assert os.path.isfile(path)
+ assert os.path.getsize(path) > 0
+
+ def test_batch_run_rsp(self):
+ with tempfile.TemporaryDirectory() as tmpdir:
+ threads = os.path.join(tmpdir, "threads.csv")
+ timers = os.path.join(tmpdir, "timers.csv")
+ rsp = os.path.join(tmpdir, "exports.rsp")
+ Path(rsp).write_text(
+ "\n".join(
+ [
+ f'TimingInsights.ExportThreads "{threads}"',
+ f'TimingInsights.ExportTimers "{timers}"',
+ ]
+ ),
+ encoding="utf-8",
+ )
+ result = self._run(["--json", "-t", TEST_TRACE, "batch", "run-rsp", rsp], check=False)
+ if result.returncode != 0:
+ pytest.skip("response-file execution failed for supplied trace")
+ data = json.loads(result.stdout)
+ assert len(data["output_files"]) >= 2
+ for path in data["output_files"]:
+ assert os.path.isfile(path)
+ assert os.path.getsize(path) > 0
+
+
+@skip_no_target
+class TestCaptureE2E:
+ CLI_BASE = _resolve_cli("cli-anything-unrealinsights")
+
+ def _run(self, args, check=True, timeout=300):
+ return subprocess.run(
+ self.CLI_BASE + args,
+ capture_output=True,
+ text=True,
+ check=check,
+ timeout=timeout,
+ env=_cli_env(),
+ )
+
+ def test_capture_run_wait(self):
+ with tempfile.TemporaryDirectory() as tmpdir:
+ output_trace = os.path.join(tmpdir, "capture.utrace")
+ result = self._run(
+ [
+ "--json",
+ "capture",
+ "run",
+ TEST_TARGET_EXE,
+ "--output-trace",
+ output_trace,
+ "--wait",
+ "--timeout",
+ "180",
+ ],
+ check=False,
+ timeout=360,
+ )
+ if result.returncode != 0:
+ pytest.skip("capture run failed for supplied target executable")
+ data = json.loads(result.stdout)
+ assert data["trace_path"].lower().endswith(".utrace")
+ assert data["trace_exists"] is True
+ assert data["trace_size"] > 0
diff --git a/unrealinsights/agent-harness/cli_anything/unrealinsights/unrealinsights_cli.py b/unrealinsights/agent-harness/cli_anything/unrealinsights/unrealinsights_cli.py
new file mode 100644
index 000000000..38494e7de
--- /dev/null
+++ b/unrealinsights/agent-harness/cli_anything/unrealinsights/unrealinsights_cli.py
@@ -0,0 +1,582 @@
+#!/usr/bin/env python3
+"""
+Unreal Insights CLI - trace capture and export harness.
+"""
+
+from __future__ import annotations
+
+import os
+import shlex
+from pathlib import Path
+
+import click
+
+from cli_anything.unrealinsights import __version__
+from cli_anything.unrealinsights.core.capture import (
+ DEFAULT_CHANNELS,
+ normalize_trace_output_path,
+ resolve_capture_target,
+ run_capture,
+)
+from cli_anything.unrealinsights.core.export import execute_export, execute_response_file
+from cli_anything.unrealinsights.core.session import UnrealInsightsSession
+from cli_anything.unrealinsights.utils.errors import handle_error
+from cli_anything.unrealinsights.utils.output import format_size, output_json
+from cli_anything.unrealinsights.utils.unrealinsights_backend import (
+ ensure_engine_unrealinsights,
+ resolve_trace_server_exe,
+ resolve_unrealinsights_exe,
+)
+
+_repl_mode = False
+
+
+def _get_session(ctx: click.Context) -> UnrealInsightsSession:
+ ctx.ensure_object(dict)
+ session = ctx.obj.get("session")
+ if session is None:
+ session = UnrealInsightsSession()
+ ctx.obj["session"] = session
+ return session
+
+
+def _output(ctx: click.Context, data, human_fn=None):
+ if ctx.obj.get("json_mode"):
+ output_json(data)
+ elif human_fn:
+ human_fn(data)
+ else:
+ output_json(data)
+
+
+def _handle_exc(ctx: click.Context, exc: Exception):
+ err = handle_error(exc, debug=ctx.obj.get("debug", False))
+ if ctx.obj.get("json_mode"):
+ output_json(err)
+ ctx.exit(1)
+ raise click.ClickException(err["error"])
+
+
+def _resolve_insights(ctx: click.Context) -> dict[str, object]:
+ session = _get_session(ctx)
+ info = resolve_unrealinsights_exe(session.insights_exe, required=True)
+ session.set_insights_exe(info["path"])
+ return info
+
+
+def _resolve_trace_server(ctx: click.Context) -> dict[str, object]:
+ session = _get_session(ctx)
+ info = resolve_trace_server_exe(session.trace_server_exe, required=False)
+ if info["available"]:
+ session.set_trace_server_exe(info["path"])
+ return info
+
+
+def _require_trace(ctx: click.Context) -> str:
+ session = _get_session(ctx)
+ if not session.trace_path:
+ raise click.ClickException("No trace selected. Use --trace or `trace set ` first.")
+ trace_path = Path(session.trace_path).expanduser().resolve()
+ if not trace_path.is_file():
+ raise click.ClickException(f"Trace file not found: {trace_path}")
+ return str(trace_path)
+
+
+def _human_backend(data: dict[str, object]):
+ insights = data["insights"]
+ trace_server = data["trace_server"]
+ click.echo("Resolved Backends:")
+ click.echo(f" UnrealInsights.exe : {insights['path']} ({insights['source']})")
+ click.echo(f" Version : {insights.get('version') or 'unknown'}")
+ if trace_server["available"]:
+ click.echo(f" UnrealTraceServer : {trace_server['path']} ({trace_server['source']})")
+ click.echo(f" Version : {trace_server.get('version') or 'unknown'}")
+ else:
+ click.echo(f" UnrealTraceServer : unavailable ({trace_server.get('error', 'not found')})")
+
+
+def _human_ensure_insights(data: dict[str, object]):
+ insights = data["insights"]
+ click.echo(f"Engine root: {data['engine_root']}")
+ click.echo(f"UnrealInsights.exe {insights['path']} ({insights['source']})")
+ click.echo(f"Version: {insights.get('version') or 'unknown'}")
+ trace_server = data.get("trace_server")
+ if trace_server and trace_server.get("available"):
+ click.echo(f"TraceServer: {trace_server['path']}")
+ build = data.get("build")
+ if build:
+ click.echo(f"Built: {'yes' if build['succeeded'] else 'no'}")
+ click.echo(f"Build log: {build['log_path']}")
+
+
+def _human_trace_info(data: dict[str, object]):
+ trace_path = data.get("trace_path")
+ if not trace_path:
+ click.echo("No active trace selected.")
+ return
+ click.echo(f"Trace: {trace_path}")
+ click.echo(f"Exists: {'yes' if data.get('exists') else 'no'}")
+ if data.get("exists"):
+ click.echo(f"Size: {format_size(data.get('file_size'))}")
+
+
+def _human_export_result(data: dict[str, object]):
+ click.echo(f"Trace: {data['trace_path']}")
+ click.echo(f"Command: {data['exec_command']}")
+ click.echo(f"Log: {data['log_path']}")
+ click.echo(f"Exit code: {data['exit_code']}")
+ click.echo(f"Success: {'yes' if data['succeeded'] else 'no'}")
+ if data["output_files"]:
+ click.echo("Outputs:")
+ for output_path in data["output_files"]:
+ click.echo(f" {output_path}")
+ if data["errors"]:
+ click.echo("Errors:")
+ for line in data["errors"]:
+ click.echo(f" {line}")
+
+
+def _human_capture_result(data: dict[str, object]):
+ click.echo(f"Target exe: {data['target_exe']}")
+ if data.get("project_path"):
+ click.echo(f"Project: {data['project_path']}")
+ if data.get("engine_root"):
+ click.echo(f"Engine root: {data['engine_root']}")
+ click.echo(f"Trace output: {data['trace_path']}")
+ click.echo(f"Channels: {data['channels']}")
+ click.echo(f"Command: {' '.join(map(str, data['command']))}")
+ if data["waited"]:
+ click.echo(f"Exit code: {data['exit_code']}")
+ click.echo(f"Trace exists: {'yes' if data['trace_exists'] else 'no'}")
+ if data["trace_exists"]:
+ click.echo(f"Trace size: {format_size(data['trace_size'])}")
+ else:
+ click.echo(f"PID: {data['pid']}")
+
+
+@click.group(invoke_without_command=True)
+@click.option("--json", "json_mode", is_flag=True, help="Output in JSON format.")
+@click.option("--debug", is_flag=True, help="Show debug tracebacks on errors.")
+@click.option(
+ "--trace",
+ "-t",
+ type=click.Path(exists=False),
+ envvar="UNREALINSIGHTS_TRACE",
+ help="Path to the active .utrace file.",
+)
+@click.option(
+ "--insights-exe",
+ type=click.Path(exists=False),
+ envvar="UNREALINSIGHTS_EXE",
+ help="Explicit path to UnrealInsights.exe.",
+)
+@click.option(
+ "--trace-server-exe",
+ type=click.Path(exists=False),
+ envvar="UNREAL_TRACE_SERVER_EXE",
+ help="Explicit path to UnrealTraceServer.exe.",
+)
+@click.version_option(version=__version__)
+@click.pass_context
+def cli(ctx, json_mode, debug, trace, insights_exe, trace_server_exe):
+ """Windows-first Unreal Insights harness with REPL and exporter wrappers."""
+ ctx.ensure_object(dict)
+ session = _get_session(ctx)
+ ctx.obj["json_mode"] = json_mode
+ ctx.obj["debug"] = debug
+
+ if trace is not None:
+ session.set_trace(trace)
+ if insights_exe is not None:
+ session.set_insights_exe(insights_exe)
+ if trace_server_exe is not None:
+ session.set_trace_server_exe(trace_server_exe)
+
+ if ctx.invoked_subcommand is None:
+ ctx.invoke(repl)
+
+
+@cli.group("backend")
+def backend_group():
+ """Backend executable discovery and inspection."""
+
+
+@backend_group.command("info")
+@click.pass_context
+def backend_info(ctx):
+ """Resolve Unreal Insights backend executables."""
+ try:
+ data = {
+ "insights": _resolve_insights(ctx),
+ "trace_server": _resolve_trace_server(ctx),
+ }
+ _output(ctx, data, _human_backend)
+ except Exception as exc:
+ _handle_exc(ctx, exc)
+
+
+@backend_group.command("ensure-insights")
+@click.option("--engine-root", required=True, type=click.Path(exists=False), help="UE install root or its Engine subdir.")
+@click.option(
+ "--build-if-missing/--no-build-if-missing",
+ default=True,
+ show_default=True,
+ help="Build UnrealInsights when it is missing under the given engine root.",
+)
+@click.option("--configuration", default="Development", show_default=True, help="Build configuration.")
+@click.option("--timeout", type=float, default=None, help="Optional build timeout in seconds.")
+@click.pass_context
+def backend_ensure_insights(ctx, engine_root, build_if_missing, configuration, timeout):
+ """Find or build UnrealInsights.exe for a specific engine root."""
+ try:
+ data = ensure_engine_unrealinsights(
+ engine_root,
+ build_if_missing=build_if_missing,
+ configuration=configuration,
+ timeout=timeout,
+ )
+ session = _get_session(ctx)
+ session.set_insights_exe(data["insights"]["path"])
+ trace_server = data.get("trace_server")
+ if trace_server and trace_server.get("available"):
+ session.set_trace_server_exe(trace_server["path"])
+ _output(ctx, data, _human_ensure_insights)
+ except Exception as exc:
+ _handle_exc(ctx, exc)
+
+
+@cli.group("trace")
+def trace_group():
+ """Session trace path management."""
+
+
+@trace_group.command("set")
+@click.argument("trace_path", type=click.Path(exists=False))
+@click.pass_context
+def trace_set(ctx, trace_path):
+ """Set the active trace path for this session or REPL."""
+ session = _get_session(ctx)
+ session.set_trace(trace_path)
+ _output(ctx, session.trace_info(), _human_trace_info)
+
+
+@trace_group.command("info")
+@click.pass_context
+def trace_info(ctx):
+ """Show the active trace path."""
+ session = _get_session(ctx)
+ _output(ctx, session.trace_info(), _human_trace_info)
+
+
+@cli.group("capture")
+def capture_group():
+ """Trace capture orchestration."""
+
+
+@capture_group.command("run")
+@click.argument("target_exe", required=False, type=click.Path(exists=False))
+@click.option("--project", type=click.Path(exists=False), default=None, help="Path to a .uproject file.")
+@click.option(
+ "--engine-root",
+ type=click.Path(exists=False),
+ default=None,
+ help="UE install root such as D:\\Program Files\\Epic Games\\UE_5.5 or its Engine subdir.",
+)
+@click.option("--target-arg", "target_args", multiple=True, help="Argument to pass to the target executable.")
+@click.option("--output-trace", type=click.Path(exists=False), default=None, help="Output .utrace path.")
+@click.option("--channels", default=DEFAULT_CHANNELS, show_default=True, help="Comma-separated UE trace channels.")
+@click.option("--exec-cmd", "exec_cmds", multiple=True, help="Startup UE console command for -ExecCmds.")
+@click.option("--wait", is_flag=True, help="Wait for the target to exit.")
+@click.option("--timeout", type=float, default=None, help="Optional timeout in seconds when waiting.")
+@click.pass_context
+def capture_run(ctx, target_exe, project, engine_root, target_args, output_trace, channels, exec_cmds, wait, timeout):
+ """Launch a target executable with UE trace flags in file mode."""
+ try:
+ session = _get_session(ctx)
+ resolved_target_exe, resolved_target_args, launch_info = resolve_capture_target(
+ target_exe,
+ project=project,
+ engine_root=engine_root,
+ target_args=target_args,
+ )
+ resolved_output = normalize_trace_output_path(
+ resolved_target_exe,
+ output_trace=output_trace,
+ current_trace=session.trace_path,
+ )
+ data = run_capture(
+ resolved_target_exe,
+ output_trace=resolved_output,
+ channels=channels,
+ exec_cmds=exec_cmds,
+ target_args=resolved_target_args,
+ wait=wait,
+ timeout=timeout,
+ )
+ data.update(launch_info)
+ session.set_trace(resolved_output)
+ _output(ctx, data, _human_capture_result)
+ except Exception as exc:
+ _handle_exc(ctx, exc)
+
+
+@cli.group("export")
+def export_group():
+ """Offline Unreal Insights exporters."""
+
+
+def _run_export(
+ ctx: click.Context,
+ exporter: str,
+ output_path: str,
+ *,
+ columns: str | None = None,
+ threads: str | None = None,
+ timers: str | None = None,
+ start_time: float | None = None,
+ end_time: float | None = None,
+ region: str | None = None,
+ counter: str | None = None,
+):
+ trace_path = _require_trace(ctx)
+ insights = _resolve_insights(ctx)
+ data = execute_export(
+ insights["path"],
+ trace_path,
+ exporter,
+ output_path,
+ insights_version=insights.get("version"),
+ columns=columns,
+ threads=threads,
+ timers=timers,
+ start_time=start_time,
+ end_time=end_time,
+ region=region,
+ counter=counter,
+ )
+ _output(ctx, data, _human_export_result)
+
+
+@export_group.command("threads")
+@click.argument("output_path", type=click.Path(exists=False))
+@click.pass_context
+def export_threads(ctx, output_path):
+ """Export thread metadata."""
+ try:
+ _run_export(ctx, "threads", output_path)
+ except Exception as exc:
+ _handle_exc(ctx, exc)
+
+
+@export_group.command("timers")
+@click.argument("output_path", type=click.Path(exists=False))
+@click.pass_context
+def export_timers(ctx, output_path):
+ """Export timer metadata."""
+ try:
+ _run_export(ctx, "timers", output_path)
+ except Exception as exc:
+ _handle_exc(ctx, exc)
+
+
+@export_group.command("timing-events")
+@click.argument("output_path", type=click.Path(exists=False))
+@click.option("--columns", default=None)
+@click.option("--threads", default=None)
+@click.option("--timers", default=None)
+@click.option("--start-time", type=float, default=None)
+@click.option("--end-time", type=float, default=None)
+@click.option("--region", default=None)
+@click.pass_context
+def export_timing_events(ctx, output_path, columns, threads, timers, start_time, end_time, region):
+ """Export timing events."""
+ try:
+ _run_export(
+ ctx,
+ "timing-events",
+ output_path,
+ columns=columns,
+ threads=threads,
+ timers=timers,
+ start_time=start_time,
+ end_time=end_time,
+ region=region,
+ )
+ except Exception as exc:
+ _handle_exc(ctx, exc)
+
+
+@export_group.command("timer-stats")
+@click.argument("output_path", type=click.Path(exists=False))
+@click.option("--columns", default=None)
+@click.option("--threads", default=None)
+@click.option("--timers", default=None)
+@click.option("--start-time", type=float, default=None)
+@click.option("--end-time", type=float, default=None)
+@click.option("--region", default=None)
+@click.pass_context
+def export_timer_stats(ctx, output_path, columns, threads, timers, start_time, end_time, region):
+ """Export aggregated timer statistics."""
+ try:
+ _run_export(
+ ctx,
+ "timer-stats",
+ output_path,
+ columns=columns,
+ threads=threads,
+ timers=timers,
+ start_time=start_time,
+ end_time=end_time,
+ region=region,
+ )
+ except Exception as exc:
+ _handle_exc(ctx, exc)
+
+
+@export_group.command("timer-callees")
+@click.argument("output_path", type=click.Path(exists=False))
+@click.option("--threads", default=None)
+@click.option("--timers", default=None)
+@click.option("--start-time", type=float, default=None)
+@click.option("--end-time", type=float, default=None)
+@click.option("--region", default=None)
+@click.pass_context
+def export_timer_callees(ctx, output_path, threads, timers, start_time, end_time, region):
+ """Export timer callee trees."""
+ try:
+ _run_export(
+ ctx,
+ "timer-callees",
+ output_path,
+ threads=threads,
+ timers=timers,
+ start_time=start_time,
+ end_time=end_time,
+ region=region,
+ )
+ except Exception as exc:
+ _handle_exc(ctx, exc)
+
+
+@export_group.command("counters")
+@click.argument("output_path", type=click.Path(exists=False))
+@click.pass_context
+def export_counters(ctx, output_path):
+ """Export the counter list."""
+ try:
+ _run_export(ctx, "counters", output_path)
+ except Exception as exc:
+ _handle_exc(ctx, exc)
+
+
+@export_group.command("counter-values")
+@click.argument("output_path", type=click.Path(exists=False))
+@click.option("--counter", default=None)
+@click.option("--columns", default=None)
+@click.option("--start-time", type=float, default=None)
+@click.option("--end-time", type=float, default=None)
+@click.option("--region", default=None)
+@click.pass_context
+def export_counter_values(ctx, output_path, counter, columns, start_time, end_time, region):
+ """Export counter values."""
+ try:
+ _run_export(
+ ctx,
+ "counter-values",
+ output_path,
+ counter=counter,
+ columns=columns,
+ start_time=start_time,
+ end_time=end_time,
+ region=region,
+ )
+ except Exception as exc:
+ _handle_exc(ctx, exc)
+
+
+@cli.group("batch")
+def batch_group():
+ """Batch export workflows."""
+
+
+@batch_group.command("run-rsp")
+@click.argument("rsp_path", type=click.Path(exists=False))
+@click.pass_context
+def batch_run_rsp(ctx, rsp_path):
+ """Execute a response file using UnrealInsights.exe."""
+ try:
+ trace_path = _require_trace(ctx)
+ insights = _resolve_insights(ctx)
+ data = execute_response_file(insights["path"], trace_path, rsp_path)
+ _output(ctx, data, _human_export_result)
+ except Exception as exc:
+ _handle_exc(ctx, exc)
+
+
+@cli.command()
+@click.pass_context
+def repl(ctx):
+ """Start the interactive REPL."""
+ from cli_anything.unrealinsights.utils.repl_skin import ReplSkin
+
+ global _repl_mode
+ _repl_mode = True
+
+ session = _get_session(ctx)
+ skin = ReplSkin("unrealinsights", version=__version__)
+ skin.print_banner()
+ pt_session = skin.create_prompt_session()
+
+ repl_commands = {
+ "backend": "info",
+ "trace": "set|info",
+ "capture": "run",
+ "export": "threads|timers|timing-events|timer-stats|timer-callees|counters|counter-values",
+ "batch": "run-rsp",
+ "help": "Show this help",
+ "quit": "Exit REPL",
+ }
+
+ try:
+ while True:
+ try:
+ trace_name = Path(session.trace_path).name if session.trace_path else ""
+ line = skin.get_input(pt_session, project_name=trace_name, modified=False)
+ if not line:
+ continue
+ if line.lower() in ("quit", "exit", "q"):
+ skin.print_goodbye()
+ break
+ if line.lower() == "help":
+ skin.help(repl_commands)
+ continue
+
+ args = shlex.split(line, posix=os.name != "nt")
+ if ctx.obj.get("json_mode"):
+ args = ["--json", *args]
+ if ctx.obj.get("debug"):
+ args = ["--debug", *args]
+ try:
+ cli.main(args, standalone_mode=False, obj=ctx.obj)
+ except SystemExit:
+ pass
+ except click.exceptions.UsageError as exc:
+ skin.warning(f"Usage error: {exc}")
+ except Exception as exc:
+ if ctx.obj.get("json_mode"):
+ output_json({"error": str(exc)})
+ else:
+ skin.error(str(exc))
+ except (EOFError, KeyboardInterrupt):
+ skin.print_goodbye()
+ break
+ finally:
+ _repl_mode = False
+
+
+def main():
+ cli(obj={})
+
+
+if __name__ == "__main__":
+ main()
diff --git a/unrealinsights/agent-harness/cli_anything/unrealinsights/utils/__init__.py b/unrealinsights/agent-harness/cli_anything/unrealinsights/utils/__init__.py
new file mode 100644
index 000000000..6cf718da9
--- /dev/null
+++ b/unrealinsights/agent-harness/cli_anything/unrealinsights/utils/__init__.py
@@ -0,0 +1 @@
+"""Utility helpers for the Unreal Insights harness."""
diff --git a/unrealinsights/agent-harness/cli_anything/unrealinsights/utils/errors.py b/unrealinsights/agent-harness/cli_anything/unrealinsights/utils/errors.py
new file mode 100644
index 000000000..25ba4a8d3
--- /dev/null
+++ b/unrealinsights/agent-harness/cli_anything/unrealinsights/utils/errors.py
@@ -0,0 +1,26 @@
+"""
+Error handling utilities.
+"""
+
+from __future__ import annotations
+
+import sys
+import traceback
+from typing import Any
+
+
+def handle_error(exc: Exception, debug: bool = False) -> dict[str, Any]:
+ """Convert an exception into a structured error payload."""
+ result = {
+ "error": str(exc),
+ "type": type(exc).__name__,
+ }
+ if debug:
+ result["traceback"] = traceback.format_exc()
+ return result
+
+
+def die(message: str, code: int = 1):
+ """Print an error message and exit."""
+ sys.stderr.write(f"Error: {message}\n")
+ sys.exit(code)
diff --git a/unrealinsights/agent-harness/cli_anything/unrealinsights/utils/output.py b/unrealinsights/agent-harness/cli_anything/unrealinsights/utils/output.py
new file mode 100644
index 000000000..1aaaa8bc5
--- /dev/null
+++ b/unrealinsights/agent-harness/cli_anything/unrealinsights/utils/output.py
@@ -0,0 +1,54 @@
+"""
+Output formatting helpers.
+"""
+
+from __future__ import annotations
+
+import json
+import sys
+from typing import Any
+
+
+def output_json(data: Any, indent: int = 2, file=None):
+ """Write JSON data to stdout or a file-like object."""
+ if file is None:
+ file = sys.stdout
+ json.dump(data, file, indent=indent, default=str)
+ file.write("\n")
+
+
+def output_table(rows: list[list[Any]], headers: list[str], file=None):
+ """Print a simple ASCII table."""
+ if file is None:
+ file = sys.stdout
+
+ if not rows:
+ file.write("(no data)\n")
+ return
+
+ col_widths = [len(header) for header in headers]
+ for row in rows:
+ for idx, value in enumerate(row[: len(headers)]):
+ col_widths[idx] = max(col_widths[idx], len(str(value)))
+
+ header_line = " ".join(str(headers[idx]).ljust(col_widths[idx]) for idx in range(len(headers)))
+ file.write(header_line + "\n")
+ file.write(" ".join("-" * width for width in col_widths) + "\n")
+
+ for row in rows:
+ truncated = row[: len(headers)]
+ line = " ".join(str(value).ljust(col_widths[idx]) for idx, value in enumerate(truncated))
+ file.write(line + "\n")
+
+
+def format_size(size_bytes: int | None) -> str:
+ """Format a byte count as a human-readable string."""
+ if size_bytes is None:
+ return "unknown"
+ if size_bytes < 1024:
+ return f"{size_bytes} B"
+ if size_bytes < 1024 * 1024:
+ return f"{size_bytes / 1024:.1f} KB"
+ if size_bytes < 1024 * 1024 * 1024:
+ return f"{size_bytes / (1024 * 1024):.1f} MB"
+ return f"{size_bytes / (1024 * 1024 * 1024):.1f} GB"
diff --git a/unrealinsights/agent-harness/cli_anything/unrealinsights/utils/repl_skin.py b/unrealinsights/agent-harness/cli_anything/unrealinsights/utils/repl_skin.py
new file mode 100644
index 000000000..c7312348a
--- /dev/null
+++ b/unrealinsights/agent-harness/cli_anything/unrealinsights/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/unrealinsights/agent-harness/cli_anything/unrealinsights/utils/unrealinsights_backend.py b/unrealinsights/agent-harness/cli_anything/unrealinsights/utils/unrealinsights_backend.py
new file mode 100644
index 000000000..042f7b277
--- /dev/null
+++ b/unrealinsights/agent-harness/cli_anything/unrealinsights/utils/unrealinsights_backend.py
@@ -0,0 +1,442 @@
+"""
+Backend helpers for resolving and invoking Unreal Insights binaries.
+"""
+
+from __future__ import annotations
+
+import os
+import re
+import subprocess
+from pathlib import Path
+from datetime import datetime
+from typing import Iterable
+
+INSIGHTS_BINARY_NAME = "UnrealInsights.exe"
+TRACE_SERVER_BINARY_NAME = "UnrealTraceServer.exe"
+
+
+def _normalize_path(path: str | Path) -> str:
+ return str(Path(path).expanduser().resolve())
+
+
+def _extract_engine_version_hint(path: Path) -> str | None:
+ for parent in path.parents:
+ if parent.name.startswith("UE_"):
+ return parent.name.removeprefix("UE_")
+ return None
+
+
+def _engine_sort_key(path: Path) -> tuple[int, ...]:
+ match = re.findall(r"\d+", path.name)
+ if not match:
+ return (0,)
+ return tuple(int(part) for part in match)
+
+
+def _default_search_roots() -> list[Path]:
+ roots: dict[str, Path] = {}
+
+ for env_key in ("ProgramW6432", "ProgramFiles"):
+ value = os.environ.get(env_key)
+ if value:
+ root = Path(value) / "Epic Games"
+ roots[str(root).lower()] = root
+
+ for drive in "CDEFGHIJKLMNOPQRSTUVWXYZ":
+ root = Path(f"{drive}:/Program Files/Epic Games")
+ roots[str(root).lower()] = root
+
+ return [root for root in roots.values() if root.exists()]
+
+
+def _existing_engine_installations(search_roots: Iterable[Path] | None = None) -> list[Path]:
+ installs: dict[str, Path] = {}
+ roots = list(search_roots) if search_roots is not None else _default_search_roots()
+ for root in roots:
+ if not root.exists():
+ continue
+ for candidate in root.glob("UE_*"):
+ if candidate.is_dir():
+ installs[str(candidate.resolve()).lower()] = candidate.resolve()
+ return sorted(installs.values(), key=_engine_sort_key, reverse=True)
+
+
+def _candidate_binary_paths(binary_name: str, search_roots: Iterable[Path] | None = None) -> list[Path]:
+ candidates: list[Path] = []
+ for install in _existing_engine_installations(search_roots):
+ candidates.append(install / "Engine" / "Binaries" / "Win64" / binary_name)
+ return candidates
+
+
+def _read_windows_product_version(path: Path) -> str | None:
+ if os.name != "nt":
+ return None
+
+ literal = str(path).replace("'", "''")
+ cmd = [
+ "powershell",
+ "-NoProfile",
+ "-Command",
+ f"(Get-Item -LiteralPath '{literal}').VersionInfo.ProductVersion",
+ ]
+ try:
+ result = subprocess.run(
+ cmd,
+ capture_output=True,
+ text=True,
+ timeout=5,
+ check=False,
+ )
+ except (FileNotFoundError, subprocess.TimeoutExpired):
+ return None
+
+ version = result.stdout.strip()
+ return version or None
+
+
+def _build_resolution(path: Path, source: str) -> dict[str, object]:
+ version = _read_windows_product_version(path)
+ return {
+ "available": True,
+ "path": _normalize_path(path),
+ "source": source,
+ "version": version or _extract_engine_version_hint(path),
+ "engine_version_hint": _extract_engine_version_hint(path),
+ }
+
+
+def _missing_resolution(binary_name: str, reason: str) -> dict[str, object]:
+ return {
+ "available": False,
+ "path": None,
+ "source": "unresolved",
+ "version": None,
+ "engine_version_hint": None,
+ "error": f"{binary_name} not found: {reason}",
+ }
+
+
+def resolve_engine_root(engine_root: str | Path) -> Path:
+ """Normalize a UE install root from either the install root or Engine subdir."""
+ path = Path(engine_root).expanduser().resolve()
+ root = path.parent if path.name.lower() == "engine" else path
+ if not root.exists():
+ raise RuntimeError(f"Engine root not found: {root}")
+ if not (root / "Engine").is_dir():
+ raise RuntimeError(f"Engine root must contain an Engine directory: {root}")
+ return root
+
+
+def resolve_binary_from_engine_root(
+ binary_name: str,
+ engine_root: str | Path,
+ required: bool = True,
+) -> dict[str, object]:
+ """Resolve a UE program binary from a specific engine root."""
+ root = resolve_engine_root(engine_root)
+ candidate = root / "Engine" / "Binaries" / "Win64" / binary_name
+ if candidate.is_file():
+ return _build_resolution(candidate, f"engine:{root.name}")
+ if required:
+ raise RuntimeError(f"{binary_name} not found under engine root: {root}")
+ return _missing_resolution(binary_name, f"missing under engine root {root}")
+
+
+def resolve_windows_binary(
+ binary_name: str,
+ explicit_path: str | None = None,
+ env_var_name: str | None = None,
+ search_roots: Iterable[Path] | None = None,
+ required: bool = True,
+) -> dict[str, object]:
+ """Resolve a UE program binary using explicit path, env var, then auto-discovery."""
+ if explicit_path:
+ explicit = Path(explicit_path).expanduser()
+ if not explicit.is_file():
+ raise RuntimeError(f"Explicit path does not exist: {explicit}")
+ return _build_resolution(explicit.resolve(), "explicit")
+
+ if env_var_name:
+ env_value = os.environ.get(env_var_name, "").strip()
+ if env_value:
+ env_path = Path(env_value).expanduser()
+ if not env_path.is_file():
+ raise RuntimeError(f"{env_var_name} points to a missing file: {env_path}")
+ return _build_resolution(env_path.resolve(), f"env:{env_var_name}")
+
+ for candidate in _candidate_binary_paths(binary_name, search_roots):
+ if candidate.is_file():
+ return _build_resolution(candidate.resolve(), f"auto:{candidate.parents[3].name}")
+
+ if required:
+ raise RuntimeError(
+ f"{binary_name} not found. Set an explicit path or install UE 5.5+ in an Epic Games directory."
+ )
+ return _missing_resolution(binary_name, "auto-discovery did not find a matching UE install")
+
+
+def resolve_unrealinsights_exe(
+ explicit_path: str | None = None,
+ engine_root: str | None = None,
+ search_roots: Iterable[Path] | None = None,
+ required: bool = True,
+) -> dict[str, object]:
+ if engine_root:
+ return resolve_binary_from_engine_root(INSIGHTS_BINARY_NAME, engine_root, required=required)
+ return resolve_windows_binary(
+ INSIGHTS_BINARY_NAME,
+ explicit_path=explicit_path,
+ env_var_name="UNREALINSIGHTS_EXE",
+ search_roots=search_roots,
+ required=required,
+ )
+
+
+def resolve_trace_server_exe(
+ explicit_path: str | None = None,
+ engine_root: str | None = None,
+ search_roots: Iterable[Path] | None = None,
+ required: bool = False,
+) -> dict[str, object]:
+ if engine_root:
+ return resolve_binary_from_engine_root(TRACE_SERVER_BINARY_NAME, engine_root, required=required)
+ return resolve_windows_binary(
+ TRACE_SERVER_BINARY_NAME,
+ explicit_path=explicit_path,
+ env_var_name="UNREAL_TRACE_SERVER_EXE",
+ search_roots=search_roots,
+ required=required,
+ )
+
+
+def ensure_parent_dir(path: str | Path):
+ Path(path).expanduser().resolve().parent.mkdir(parents=True, exist_ok=True)
+
+
+def build_engine_program(
+ engine_root: str | Path,
+ target_name: str,
+ *,
+ platform: str = "Win64",
+ configuration: str = "Development",
+ timeout: float | None = None,
+ log_path: str | None = None,
+) -> dict[str, object]:
+ """Build a UE program target using the engine's Build.bat."""
+ root = resolve_engine_root(engine_root)
+ build_bat = root / "Engine" / "Build" / "BatchFiles" / "Build.bat"
+ if not build_bat.is_file():
+ raise RuntimeError(f"Build.bat not found under engine root: {root}")
+
+ if log_path is None:
+ timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
+ log_path = str(root / "Engine" / "Programs" / target_name / "Saved" / "Logs" / f"build-{target_name}-{timestamp}.log")
+ ensure_parent_dir(log_path)
+
+ command = [str(build_bat), target_name, platform, configuration, "-WaitMutex"]
+ try:
+ result = subprocess.run(
+ command,
+ cwd=str(root),
+ capture_output=True,
+ text=True,
+ timeout=timeout,
+ check=False,
+ )
+ stdout = result.stdout
+ stderr = result.stderr
+ exit_code = result.returncode
+ timed_out = False
+ except subprocess.TimeoutExpired as exc:
+ stdout = exc.stdout or ""
+ stderr = exc.stderr or ""
+ exit_code = None
+ timed_out = True
+
+ Path(log_path).write_text(
+ "\n".join(
+ [
+ f"# Command: {' '.join(command)}",
+ "",
+ stdout or "",
+ stderr or "",
+ ]
+ ),
+ encoding="utf-8",
+ errors="replace",
+ )
+
+ return {
+ "command": command,
+ "cwd": str(root),
+ "log_path": str(Path(log_path).resolve()),
+ "exit_code": exit_code,
+ "timed_out": timed_out,
+ "stdout": stdout,
+ "stderr": stderr,
+ "succeeded": (not timed_out and exit_code == 0),
+ }
+
+
+def ensure_engine_unrealinsights(
+ engine_root: str | Path,
+ *,
+ build_if_missing: bool = True,
+ configuration: str = "Development",
+ platform: str = "Win64",
+ timeout: float | None = None,
+) -> dict[str, object]:
+ """Resolve UnrealInsights.exe for a given engine root, building it if requested."""
+ root = resolve_engine_root(engine_root)
+ trace_server = resolve_binary_from_engine_root(
+ TRACE_SERVER_BINARY_NAME,
+ root,
+ required=False,
+ )
+ existing = resolve_binary_from_engine_root(
+ INSIGHTS_BINARY_NAME,
+ root,
+ required=False,
+ )
+ result = {
+ "engine_root": str(root),
+ "trace_server": trace_server,
+ "build_attempted": False,
+ "build": None,
+ }
+ if existing["available"]:
+ result["insights"] = existing
+ return result
+
+ if not build_if_missing:
+ raise RuntimeError(f"{INSIGHTS_BINARY_NAME} not found under engine root: {root}")
+
+ build = build_engine_program(
+ root,
+ "UnrealInsights",
+ platform=platform,
+ configuration=configuration,
+ timeout=timeout,
+ )
+ result["build_attempted"] = True
+ result["build"] = build
+ if not build["succeeded"]:
+ raise RuntimeError(f"Failed to build UnrealInsights for engine root: {root}")
+
+ result["insights"] = resolve_binary_from_engine_root(INSIGHTS_BINARY_NAME, root, required=True)
+ return result
+
+
+def build_insights_command(
+ insights_exe: str,
+ trace_path: str,
+ exec_on_complete: str,
+ log_path: str,
+) -> list[str]:
+ """Build the UnrealInsights.exe command line."""
+ return [
+ _normalize_path(insights_exe),
+ f"-OpenTraceFile={_normalize_path(trace_path)}",
+ f"-ABSLOG={_normalize_path(log_path)}",
+ "-AutoQuit",
+ "-NoUI",
+ f"-ExecOnAnalysisCompleteCmd={exec_on_complete}",
+ "-log",
+ ]
+
+
+def _quote_cmd_value(value: str) -> str:
+ escaped = value.replace('"', '\\"')
+ return f'"{escaped}"'
+
+
+def build_insights_command_line(
+ insights_exe: str,
+ trace_path: str,
+ exec_on_complete: str,
+ log_path: str,
+) -> str:
+ """Build a raw Windows command line for UnrealInsights.exe.
+
+ This avoids CreateProcess argv wrapping the whole -ExecOnAnalysisCompleteCmd
+ argument in outer quotes, which older UnrealInsights builds fail to parse.
+ """
+ exe = _quote_cmd_value(_normalize_path(insights_exe))
+ trace = _quote_cmd_value(_normalize_path(trace_path))
+ log = _quote_cmd_value(_normalize_path(log_path))
+ exec_value = _quote_cmd_value(exec_on_complete)
+ return (
+ f"{exe} "
+ f"-OpenTraceFile={trace} "
+ f"-ABSLOG={log} "
+ f"-AutoQuit -NoUI "
+ f"-ExecOnAnalysisCompleteCmd={exec_value} "
+ f"-log"
+ )
+
+
+def run_process(command: list[str] | str, timeout: float | None = None, wait: bool = True) -> dict[str, object]:
+ """Run or launch a subprocess and return structured execution metadata."""
+ if wait:
+ try:
+ result = subprocess.run(
+ command,
+ capture_output=True,
+ text=True,
+ timeout=timeout,
+ check=False,
+ )
+ return {
+ "command": command,
+ "waited": True,
+ "timed_out": False,
+ "exit_code": result.returncode,
+ "stdout": result.stdout,
+ "stderr": result.stderr,
+ "pid": None,
+ }
+ except subprocess.TimeoutExpired as exc:
+ return {
+ "command": command,
+ "waited": True,
+ "timed_out": True,
+ "exit_code": None,
+ "stdout": exc.stdout or "",
+ "stderr": exc.stderr or "",
+ "pid": None,
+ }
+
+ process = subprocess.Popen(command)
+ return {
+ "command": command,
+ "waited": False,
+ "timed_out": False,
+ "exit_code": None,
+ "stdout": None,
+ "stderr": None,
+ "pid": process.pid,
+ }
+
+
+def parse_unreal_log(log_path: str | Path) -> dict[str, object]:
+ """Extract warning and error lines from an Unreal log file."""
+ path = Path(log_path).expanduser().resolve()
+ if not path.is_file():
+ return {
+ "path": str(path),
+ "exists": False,
+ "warnings": [],
+ "errors": [],
+ "tail": [],
+ }
+
+ lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
+ warnings = [line for line in lines if "Warning:" in line]
+ errors = [line for line in lines if "Error:" in line]
+ return {
+ "path": str(path),
+ "exists": True,
+ "warnings": warnings,
+ "errors": errors,
+ "tail": lines[-20:],
+ }
diff --git a/unrealinsights/agent-harness/setup.py b/unrealinsights/agent-harness/setup.py
new file mode 100644
index 000000000..44e197020
--- /dev/null
+++ b/unrealinsights/agent-harness/setup.py
@@ -0,0 +1,41 @@
+"""Setup for cli-anything-unrealinsights package."""
+
+from pathlib import Path
+
+from setuptools import find_namespace_packages, setup
+
+_README = Path(__file__).parent / "cli_anything" / "unrealinsights" / "README.md"
+_long_desc = _README.read_text(encoding="utf-8") if _README.is_file() else ""
+
+setup(
+ name="cli-anything-unrealinsights",
+ version="0.1.0",
+ description="CLI harness for Unreal Insights trace capture and export workflows",
+ long_description=_long_desc,
+ long_description_content_type="text/markdown",
+ author="cli-anything",
+ packages=find_namespace_packages(include=["cli_anything.*"]),
+ python_requires=">=3.10",
+ install_requires=[
+ "click>=8.0",
+ "prompt-toolkit>=3.0",
+ ],
+ extras_require={
+ "test": ["pytest>=7.0"],
+ },
+ entry_points={
+ "console_scripts": [
+ "cli-anything-unrealinsights=cli_anything.unrealinsights.unrealinsights_cli:main",
+ ],
+ },
+ package_data={
+ "cli_anything.unrealinsights": ["skills/*.md", "README.md"],
+ },
+ include_package_data=True,
+ zip_safe=False,
+ classifiers=[
+ "Programming Language :: Python :: 3",
+ "License :: OSI Approved :: MIT License",
+ "Topic :: Software Development :: Debuggers",
+ ],
+)
From 6e45b7a98d07d3e6df5bd5277514a8d0e7a290cb Mon Sep 17 00:00:00 2001
From: aimidi
Date: Thu, 16 Apr 2026 17:45:38 +0800
Subject: [PATCH 2/7] Expand Unreal Insights capture orchestration and docs
---
.gitignore | 58 +-
README.md | 227 ++++++-
README_CN.md | 26 +-
registry.json | 585 +++++++++++++++---
unrealinsights/README.md | 127 ++++
unrealinsights/README_CN.md | 121 ++++
.../cli_anything/unrealinsights/README.md | 37 ++
.../unrealinsights/core/capture.py | 58 ++
.../unrealinsights/core/session.py | 124 +++-
.../unrealinsights/skills/SKILL.md | 26 +
.../cli_anything/unrealinsights/tests/TEST.md | 14 +-
.../unrealinsights/tests/test_core.py | 163 +++++
.../unrealinsights/tests/test_full_e2e.py | 1 +
.../unrealinsights/unrealinsights_cli.py | 122 +++-
.../utils/unrealinsights_backend.py | 71 ++-
15 files changed, 1631 insertions(+), 129 deletions(-)
create mode 100644 unrealinsights/README.md
create mode 100644 unrealinsights/README_CN.md
diff --git a/.gitignore b/.gitignore
index 6c533bfad..bac27a596 100644
--- a/.gitignore
+++ b/.gitignore
@@ -16,12 +16,15 @@
!/CONTRIBUTING.md
!/SECURITY.md
!/assets/
+!/.pi-extension/
+/.pi-extension/cli-anything/node_modules
!/.claude-plugin/
!/.github/
/.github/*
!/.github/workflows/
!/.github/scripts/
!/.github/PULL_REQUEST_TEMPLATE.md
+!/.github/ISSUE_TEMPLATE/
# Allow examples directory
!/examples/
@@ -48,6 +51,7 @@
!/skill_generation/
!/openclaw-skill/
!/cli-hub-meta-skill/
+!/cli-hub/
# Ignore cli-hub-skill (auto-generated, not tracked)
/cli-hub-skill/
@@ -67,20 +71,29 @@
!/zoom/
!/sketch/
!/drawio/
+!/godot/
!/mermaid/
!/comfyui/
+!/dify-workflow/
!/adguardhome/
!/novita/
!/ollama/
!/browser/
+!/seaclip/
+!/pm2/
+!/chromadb/
!/musescore/
!/krita/
!/freecad/
!/iterm2/
!/slay_the_spire_ii/
+!/eth2-quickstart/
!/rms/
!/renderdoc/
!/cloudcompare/
+!/openscreen/
+!/n8n/
+!/obsidian/
!/unrealinsights/
# Step 5: Inside each software dir, ignore everything (including dotfiles)
@@ -112,16 +125,26 @@
/sketch/.*
/drawio/*
/drawio/.*
+/godot/*
+/godot/.*
/mermaid/*
/mermaid/.*
/comfyui/*
/comfyui/.*
+/dify-workflow/*
+/dify-workflow/.*
/adguardhome/*
/adguardhome/.*
/ollama/*
/ollama/.*
/browser/*
/browser/.*
+/seaclip/*
+/seaclip/.*
+/pm2/*
+/pm2/.*
+/chromadb/*
+/chromadb/.*
/musescore/*
/musescore/.*
/krita/*
@@ -132,12 +155,26 @@
/iterm2/.*
/slay_the_spire_ii/*
/slay_the_spire_ii/.*
+/eth2-quickstart/*
+/eth2-quickstart/.*
/rms/*
/rms/.*
/renderdoc/*
/renderdoc/.*
/cloudcompare/*
/cloudcompare/.*
+/openscreen/*
+/openscreen/.*
+/cloudanalyzer/*
+/cloudanalyzer/.*
+/wiremock/*
+/wiremock/.*
+/exa/*
+/exa/.*
+/n8n/*
+/n8n/.*
+/obsidian/*
+/obsidian/.*
/unrealinsights/*
/unrealinsights/.*
@@ -156,22 +193,40 @@
!/zoom/agent-harness/
!/sketch/agent-harness/
!/drawio/agent-harness/
+!/godot/agent-harness/
!/mermaid/agent-harness/
!/comfyui/agent-harness/
+!/dify-workflow/agent-harness/
!/adguardhome/agent-harness/
!/novita/agent-harness/
!/ollama/agent-harness/
!/browser/agent-harness/
+!/seaclip/agent-harness/
+!/pm2/agent-harness/
+!/chromadb/agent-harness/
!/musescore/agent-harness/
!/krita/agent-harness/
!/freecad/agent-harness/
!/iterm2/agent-harness/
!/slay_the_spire_ii/agent-harness/
!/slay_the_spire_ii/README.md
+!/eth2-quickstart/agent-harness/
!/rms/agent-harness/
!/renderdoc/agent-harness/
!/cloudcompare/agent-harness/
+!/openscreen/agent-harness/
+!/cloudanalyzer/
+!/cloudanalyzer/agent-harness/
+!/wiremock/
+!/wiremock/agent-harness/
+!/exa/agent-harness/
+!/n8n/agent-harness/
+!/obsidian/agent-harness/
+!/safari/
+!/safari/agent-harness/
!/unrealinsights/agent-harness/
+!/unrealinsights/README.md
+!/unrealinsights/README_CN.md
# Step 7: Ignore build artifacts within allowed dirs
**/__pycache__/
@@ -197,6 +252,7 @@ assets/gen_typing_gif.py
# Step 10: Allow CLI Hub registry and frontend
!/registry.json
+!/public_registry.json
!/docs/
/docs/*
!/docs/hub/
@@ -205,5 +261,5 @@ assets/gen_typing_gif.py
/notebooklm/*
/notebooklm/.*
!/notebooklm/agent-harness/
-!/intelwatch/agent-harness/
!/intelwatch/
+!/intelwatch/agent-harness/
diff --git a/README.md b/README.md
index ece60f457..cb6924e3a 100644
--- a/README.md
+++ b/README.md
@@ -5,15 +5,17 @@
CLI-Anything: Bridging the Gap Between AI Agents and the World's Software
-**🌐 [CLI-Hub](https://hkuds.github.io/CLI-Anything/)**: Explore all community-built CLIs and install with one command at the **[CLI-Hub](https://hkuds.github.io/CLI-Anything/)**. Want to add your own? [Open a PR](https://github.com/HKUDS/CLI-Anything/blob/main/CONTRIBUTING.md) — the hub updates instantly.
+**🌐 [CLI-Hub](https://hkuds.github.io/CLI-Anything/)**: `pip install cli-anything-hub` then `cli-hub install ` — browse, install, and manage all community-built CLIs. Want to add your own? [Open a PR](https://github.com/HKUDS/CLI-Anything/blob/main/CONTRIBUTING.md) — the hub updates instantly.
**🎬 [See Demos](#-real-world-demos)**: Watch AI agents use generated CLIs to produce real artifacts — diagrams, gameplay, subtitles, and more.
+**🙋 [Become a Contributor, or Request a CLI]**: [Join us](https://github.com/HKUDS/CLI-Anything/issues/new?template=contributor-signup.yml)! Sign up to build a new CLI harness — once reviewed and merged, you'll gain access as one of our community contributors! Wish CLI-Anything supported a specific software or service? Submit a [wishlist request](https://github.com/HKUDS/CLI-Anything/issues/new?template=cli-wishlist.yml)!
+
-
+
@@ -27,7 +29,7 @@ CLI-Anything: Bridging the Gap Between AI Agents and the World's Software
-**One Command Line**: Make any software agent-ready for OpenClaw, nanobot, Cursor, Claude Code, etc. [**中文文档**](README_CN.md) | [**日本語ドキュメント**](README_JA.md)
+**One Command Line**: Make any software agent-ready for Pi, OpenClaw, nanobot, Cursor, Claude Code, etc. [**中文文档**](README_CN.md) | [**日本語ドキュメント**](README_JA.md)
@@ -43,7 +45,37 @@ CLI-Anything: Bridging the Gap Between AI Agents and the World's Software Thanks to all invaluable efforts from the community! More updates continuously on the way everyday..
-- **2026-03-30** 🏗️ **CLI-Anything v0.2.0** — HARNESS.md progressive disclosure redesign. Detailed guides (MCP backend, filter translation, timecode, session locking, PyPI publishing, SKILL.md generation) extracted into `guides/` for on-demand loading. Phases 1–7 now contiguous. Key Principles and Rules merged into a single authoritative section. Added Guides Reference routing table. Renamed "Critical Lessons Learned" to "Architecture Patterns & Pitfalls."
+- **2026-04-15** 🌐 **CLI-Hub** updated to **v0.2.0** — the PyPI package now supports public CLIs from multiple install sources (`pip`, `npm`, `brew`, bundled/system tools), backed by a new `public_registry.json`. The Hub frontend was redesigned with separate **CLI-Anything CLIs** and **Public CLIs** decks, and live end-to-end checks now cover real install, update, and uninstall flows across both pip and npm packages.
+
+- **2026-04-14** 🧭 **Safari CLI** merged (#212) and added to the Hub registry — browser automation via `safari-mcp`. 🎬 **Kdenlive** also received compatibility fixes for Gen 5 project output and invalid project generation.
+
+- **2026-04-13** 📓 **Obsidian CLI** merged (#211) — knowledge management harness via the Local REST API, with 48 unit tests and 7 E2E tests. ⛓️ **Eth2-Quickstart CLI** merged (#195) — Ethereum staking node management harness. 📚 **Zotero CLI** updated to v0.4.1 (#201) — now shipped from its standalone repo, and CLI-Hub gained support for remote `skill_md` URLs.
+
+- **2026-04-11** 🔗 **n8n CLI** merged (#188) — workflow automation harness for self-hosted automation flows. 🔧 **Exa CLI** fix (#205) added the `x-exa-integration` header for usage tracking. 📦 **CLI-Hub** also gained its PyPI auto-publish workflow and package refresh pipeline.
+
+- **2026-04-10** 📦 **CLI-Hub package manager** launched — `pip install cli-anything-hub` to browse, search, install, update, and uninstall CLI-Anything harnesses from one command. The web Hub also shipped its first install-focused frontend refresh and "Empower yourself" toolkit card.
+
+
+Earlier news (Apr 1–9)
+
+- **2026-04-09** 🧹 Cleanup and docs pass (#200) — fixed Openscreen test subtotals, added Openscreen to the Chinese README and project structure, and clarified `/cli-anything` command syntax in the docs.
+
+- **2026-04-08** 🎬 **Openscreen CLI** merged (#183) — screen recording editor harness with 101 tests. ☁️ **CloudAnalyzer CLI** merged (#181) — cloud cost analysis harness with 27 commands. 🌊 **SeaClip / PM2 / ChromaDB** harnesses merged (#129).
+
+- **2026-04-07** 🔄 **Dify Workflow CLI** merged (#191) — workflow automation wrapper. 🔧 **Inkscape** auto-save fix (#193, fixes #182). 🛡️ **DomShell security hardening** (#156) — URL validation and DOM sanitization for the browser CLI. 🥧 **Pi Coding Agent extension** merged (#178).
+
+- **2026-04-06** 🔍 **Exa CLI** merged (#172) — AI-powered web search and answers harness. 🎮 **Godot CLI** merged (#140) — game engine harness with a full demo-game E2E pipeline. ☁️ **CloudAnalyzer** review fixes and frontend improvements also landed.
+
+- **2026-04-03** 🧪 **WireMock CLI** merged (#170) — HTTP mock server harness for API testing. 🥧 **Pi Coding Agent** extension support also landed, and CLI demo recordings were added to the docs.
+
+- **2026-04-01** ⚔️ **Slay the Spire II CLI** merged (#148) — deck-building roguelike harness. 🎥 **VideoCaptioner CLI** merged (#166) — AI-powered video captioning harness. 🛰️ **IntelWatch** was added to the registry for B2B OSINT workflows.
+
+
+
+
+Earlier news (Mar 23–30)
+
+- **2026-03-30** 🏗️ **CLI-Anything v0.2.0** — HARNESS.md progressive disclosure redesign. Detailed guides extracted into `guides/` for on-demand loading. Phases 1–7 now contiguous. Key Principles and Rules merged into a single authoritative section.
- **2026-03-29** 📐 Blender skill docs updated — enforce absolute render paths and correct prerequisites.
@@ -53,14 +85,16 @@ CLI-Anything: Bridging the Gap Between AI Agents and the World's Software
-Earlier news (Mar 17–22)
+Earlier news (Mar 11–22)
- **2026-03-22** 🎵 **MuseScore CLI** merged with transpose, export, and instrument management.
@@ -74,12 +108,7 @@ CLI-Anything: Bridging the Gap Between AI Agents and the World's Software
-
-
-Earlier news (Mar 11–16)
-
-- **2026-03-16** 🤖 Added **SKILL.md generation** (Phase 6.5) — every generated CLI now ships with an AI-discoverable skill definition. Includes `skill_generator.py`, Jinja2 template, and 51 new tests.
+- **2026-03-16** 🤖 Added **SKILL.md generation** (Phase 6.5) — every generated CLI now ships with an AI-discoverable skill definition.
- **2026-03-15** 🐾 Support for **OpenClaw** from the community! Merged Windows `cygpath` guard for cross-platform support.
@@ -117,7 +146,7 @@ CLI is the universal interface for both humans and AI agents:
- **Python 3.10+**
- Target software installed (e.g., GIMP, Blender, LibreOffice, or your own application)
-- A supported AI coding agent: [Claude Code](#-claude-code) | [OpenClaw](#-openclaw) | [OpenCode](#-opencode) | [Codex](#-codex) | [Qodercli](#-qodercli) | [GitHub Copilot CLI](#-github-copilot-cli) | [More Platforms](#-more-platforms-coming-soon)
+- A supported AI coding agent: [Claude Code](#-claude-code) | [Pi](#-pi-coding-agent) | [OpenClaw](#-openclaw) | [OpenCode](#-opencode) | [Codex](#-codex) | [Qodercli](#-qodercli) | [GitHub Copilot CLI](#-github-copilot-cli) | [More Platforms](#-more-platforms-coming-soon)
### Pick Your Platform
@@ -148,13 +177,13 @@ That's it. The plugin is now available in your Claude Code session.
**Step 3: Build a CLI in One Command**
```bash
-# /cli-anything:cli-anything
+# /cli-anything
# Generate a complete CLI for GIMP (all 7 phases)
-/cli-anything:cli-anything ./gimp
-
-# Note: If your Claude Code is under 2.x, use "/cli-anything" instead.
+/cli-anything ./gimp
```
+Older Claude Code 2.x releases also accepted `/cli-anything:cli-anything`; auxiliary commands still use the `:subcommand` form (e.g. `/cli-anything:refine`).
+
This runs the full pipeline:
1. 🔍 **Analyze** — Scans source code, maps GUI actions to APIs
2. 📐 **Design** — Architects command groups, state model, output formats
@@ -198,6 +227,64 @@ cp -r CLI-Anything/cli-anything-plugin ~/.claude/plugins/cli-anything
+
+⚡ Pi Coding Agent
+
+**Step 1: Install the Extension**
+
+The extension lives at `.pi-extension/cli-anything/` in this repository. Install it globally so `/cli-anything` commands are available in **all** Pi projects:
+
+```bash
+# Clone the repo
+git clone https://github.com/HKUDS/CLI-Anything.git
+cd CLI-Anything
+
+# Install globally into Pi's extensions directory
+bash .pi-extension/cli-anything/install.sh
+```
+
+To uninstall:
+
+```bash
+bash .pi-extension/cli-anything/install.sh --uninstall
+```
+
+> **How it works:** `install.sh` copies the extension files (including HARNESS.md, commands, guides, scripts, and templates from `cli-anything-plugin/`) into `~/.pi/agent/extensions/cli-anything/`, which Pi auto-discovers on startup. Run `/reload` in Pi or restart Pi to activate.
+
+**Step 2: Build a CLI in One Command**
+
+Once the extension is loaded, the following commands are available:
+
+```bash
+# Generate a complete CLI for GIMP (all 7 phases)
+/cli-anything ./gimp
+
+# Build from a GitHub repo
+/cli-anything https://github.com/blender/blender
+```
+
+**Step 3 (Optional): Refine and Improve the CLI**
+
+```bash
+# Broad refinement — agent analyzes gaps across all capabilities
+/cli-anything:refine ./gimp
+
+# Focused refinement — target a specific functionality area
+/cli-anything:refine ./gimp "batch processing and filters"
+```
+
+**Available Commands**
+
+| Command | Description |
+|---------|-------------|
+| `/cli-anything ` | Build a complete CLI harness |
+| `/cli-anything:refine [focus]` | Refine an existing CLI harness |
+| `/cli-anything:test ` | Run tests for a CLI harness |
+| `/cli-anything:validate ` | Validate a CLI harness |
+| `/cli-anything:list [options]` | List all CLI-Anything tools |
+
+
+
⚡ OpenCode (Experimental)
@@ -264,7 +351,7 @@ Configure Goose to use a CLI provider such as Claude Code, and make sure that CL
Once Goose is configured, start a session and use the same CLI-Anything commands described above for Claude Code, for example:
```bash
-/cli-anything:cli-anything ./gimp
+/cli-anything ./gimp
/cli-anything:refine ./gimp "batch processing and filters"
```
@@ -287,7 +374,7 @@ This registers the cli-anything plugin in `~/.qoder.json`. Start a new Qodercli
**Step 2: Use CLI-Anything from Qodercli**
```bash
-/cli-anything:cli-anything ./gimp
+/cli-anything ./gimp
/cli-anything:refine ./gimp "batch processing and filters"
/cli-anything:validate ./gimp
```
@@ -377,7 +464,7 @@ This installs the CLI-Anything plugin to GitHub Copilot CLI. The plugin should n
**Step 2: Use CLI-Anything from GitHub Copilot CLI**
```bash
-/cli-anything:cli-anything ./gimp
+/cli-anything ./gimp
/cli-anything:refine ./gimp "batch processing and filters"
/cli-anything:validate ./gimp
```
@@ -443,7 +530,7 @@ The agent will browse the catalog, install whichever CLI fits the task, and use
**How it works under the hood:**
-1. The meta-skill points to the live catalog at [`https://hkuds.github.io/CLI-Anything/SKILL.txt`](https://hkuds.github.io/CLI-Anything/SKILL.txt)
+1. The meta-skill points to the live catalog at [`https://reeceyang.sgp1.cdn.digitaloceanspaces.com/SKILL.md`](https://reeceyang.sgp1.cdn.digitaloceanspaces.com/SKILL.md)
2. The agent reads 20+ CLIs organized by category with one-line `pip install` commands
3. The agent installs whichever CLI fits the task, then reads that CLI's own SKILL.md for detailed usage
@@ -471,14 +558,17 @@ The catalog auto-updates whenever `registry.json` changes — new community CLIs
| **🤖 AI/ML Platforms** | Automate model training, inference pipelines, and hyperparameter tuning through structured commands | Stable Diffusion WebUI, ComfyUI, Ollama, InvokeAI, Text-generation-webui, Open WebUI, Fooocus, Kohya_ss, AnythingLLM, SillyTavern |
| **📊 Data & Analytics** | Enable programmatic data processing, visualization, and statistical analysis workflows | JupyterLab, Apache Superset, Metabase, Redash, DBeaver, KNIME, Orange, OpenSearch Dashboards, Lightdash |
| **💻 Development Tools** | Streamline code editing, building, testing, and deployment processes via command interfaces | Jenkins, Gitea, Hoppscotch, Portainer, pgAdmin, SonarQube, ArgoCD, OpenLens, Insomnia, Beekeeper Studio, **[iTerm2](https://iterm2.com)** |
-| **🎨 Creative & Media** | Control content creation, editing, and rendering workflows programmatically | Blender, GIMP, OBS Studio, Audacity, Krita, Kdenlive, Shotcut, Inkscape, Darktable, LMMS, Ardour, VideoCaptioner |
+| **🎨 Creative & Media** | Control content creation, editing, and rendering workflows programmatically | Blender, GIMP, OBS Studio, Audacity, Krita, Kdenlive, Shotcut, Inkscape, Darktable, LMMS, Ardour |
+| **🎮 Game Development** | Manage game projects, scenes, exports, and scripting through headless engine interfaces | **[Godot Engine](https://godotengine.org)** |
| **🔬 Scientific Computing** | Automate research workflows, simulations, and complex calculations | ImageJ, FreeCAD, QGIS, ParaView, Gephi, LibreCAD, Stellarium, KiCad, JASP, Jamovi |
| **🏢 Enterprise & Office** | Convert business applications and productivity tools into agent-accessible systems | NextCloud, GitLab, Grafana, Mattermost, LibreOffice, AppFlowy, NocoDB, Odoo (Community), Plane, ERPNext |
| **📞 Communication & Collaboration** | Automate meeting scheduling, participant management, recording retrieval, and reporting through structured CLI | Zoom, Jitsi Meet, BigBlueButton, Mattermost |
| **📐 Diagramming & Visualization** | Create and manipulate diagrams, flowcharts, architecture diagrams, and visual documentation programmatically | Draw.io (diagrams.net), Mermaid, PlantUML, Excalidraw, yEd |
| **🌐 Network & Infrastructure** | Manage network services, DNS, ad-blocking, and infrastructure through structured CLI commands | AdGuardHome |
+| **🧪 Testing & Mocking** | Control HTTP mock servers, manage test stubs, record and replay API traffic for integration testing | **[WireMock](https://wiremock.org)** |
| **🔬 Graphics & GPU Debugging** | Analyze GPU frame captures, inspect pipeline state, export shaders, and diff rendering state | RenderDoc |
| **🎬 Video & Subtitles** | Transcribe speech, translate subtitles, burn styled captions into video — full captioning pipeline | VideoCaptioner |
+| **🔍 AI-Native Search** | Neural and deep web search with structured content retrieval through embedding-based APIs | [Exa](https://exa.ai) |
| **✨ AI Content Generation** | Generate professional deliverables (slides, docs, diagrams, websites, research reports) through AI-powered cloud APIs | [AnyGen](https://www.anygen.io), Gamma, Beautiful.ai, Tome |
---
@@ -714,6 +804,13 @@ Each application received complete, production-ready CLI interfaces — not demo
✅ 158 |
+| ⚡ n8n |
+Workflow Automation |
+cli-anything-n8n |
+n8n REST API v1.1.1 |
+✅ 55+ cmds |
+
+
| 📚 Zotero |
Reference Management |
cli-anything-zotero |
@@ -749,6 +846,13 @@ Each application received complete, production-ready CLI interfaces — not demo
✅ 154 |
+| 🎬 Openscreen |
+Screen Recording Editor |
+cli-anything-openscreen |
+ffmpeg backend |
+✅ 101 |
+
+
| 📞 Zoom |
Video Conferencing |
cli-anything-zoom |
@@ -770,6 +874,13 @@ Each application received complete, production-ready CLI interfaces — not demo
✅ 138 |
+| ⛓️ ETH2 QuickStart |
+DevOps / Ethereum |
+cli-anything-eth2-quickstart |
+eth2-quickstart shell automation + JSON health checks |
+✅ 18 |
+
+
| 🧜 Mermaid Live Editor |
Diagramming |
cli-anything-mermaid |
@@ -791,6 +902,13 @@ Each application received complete, production-ready CLI interfaces — not demo
✅ 21 |
+| 🧩 Dify Workflow |
+Dify DSL Editing |
+cli-anything-dify-workflow |
+Wrapper around the open-source dify-workflow CLI |
+✅ 11 |
+
+
| 🖼️ ComfyUI |
AI Image Generation |
cli-anything-comfyui |
@@ -812,6 +930,13 @@ Each application received complete, production-ready CLI interfaces — not demo
✅ 98 |
+| 🧬 Uni-Mol Tools |
+Molecular Property Prediction (AI4S) |
+cli-anything-unimol-tools |
+Uni-Mol molecular ML backend |
+✅ 67 |
+
+
| 🎬 VideoCaptioner |
AI Video Captioning |
cli-anything-videocaptioner |
@@ -826,19 +951,40 @@ Each application received complete, production-ready CLI interfaces — not demo
✅ 19 |
-| 🅲🅲 CloudCompare |
-3D Point Cloud & Mesh |
-cli-anything-cloudcompare |
-CloudCompare CLI (headless) |
-✅ 88 |
+🎮 Godot Engine |
+Game Development |
+cli-anything-godot |
+Godot 4.x headless subprocess |
+✅ 24 |
+
+
+| 🔍 Exa |
+AI-Native Web Search |
+cli-anything-exa |
+exa-py SDK |
+✅ 40 |
+
+
+| 📈 Unreal Insights |
+Performance Profiling |
+cli-anything-unrealinsights |
+Unreal trace capture + engine-matched UnrealInsights export |
+✅ 46 |
+
+
+| ☁️ CloudAnalyzer |
+Point cloud / trajectory QA |
+cli-anything-cloudanalyzer |
+CloudAnalyzer (Python API) |
+✅ 14 |
| Total |
-✅ 2,005 |
+✅ 2,176 |
-> **100% pass rate** across all 2,005 tests — 1,453 unit tests + 533 end-to-end tests + 19 Node.js tests.
+> **100% pass rate** across all 2,176 tests — 1,597 unit tests + 560 end-to-end tests + 19 Node.js tests.
---
@@ -866,6 +1012,7 @@ kdenlive 155 passed ✅ (111 unit + 44 e2e)
shotcut 154 passed ✅ (110 unit + 44 e2e)
zoom 22 passed ✅ (22 unit + 0 e2e)
drawio 138 passed ✅ (116 unit + 22 e2e)
+eth2-quickstart 18 passed ✅ (18 unit + 3 e2e skipped)
mermaid 10 passed ✅ (5 unit + 5 e2e)
anygen 50 passed ✅ (40 unit + 10 e2e)
notebooklm 21 passed ✅ (21 unit + 0 e2e)
@@ -875,8 +1022,11 @@ ollama 98 passed ✅ (87 unit + 11 e2e)
sketch 19 passed ✅ (19 jest, Node.js)
renderdoc 59 passed ✅ (45 unit + 14 e2e)
cloudcompare 88 passed ✅ (49 unit + 39 e2e)
+openscreen 101 passed ✅ (78 unit + 23 e2e)
+unrealinsights 46 passed ✅ (46 unit + 0 e2e smoke-gated)
+cloudanalyzer 14 passed ✅ (7 unit + 7 e2e)
──────────────────────────────────────────────────────────────────────────────
-TOTAL 2,005 passed ✅ 100% pass rate
+TOTAL 2,166 passed ✅ 100% pass rate
```
---
@@ -939,16 +1089,25 @@ cli-anything/
├── 📞 zoom/agent-harness/ # Zoom CLI (22 tests)
├── 🎵 musescore/agent-harness/ # MuseScore CLI (56 tests)
├── 📐 drawio/agent-harness/ # Draw.io CLI (138 tests)
+├── ⛓️ eth2-quickstart/agent-harness/ # ETH2 QuickStart CLI (18 unit, 3 e2e skipped)
├── 🧜 mermaid/agent-harness/ # Mermaid Live Editor CLI (10 tests)
├── ✨ anygen/agent-harness/ # AnyGen CLI (50 tests)
├── 🖼️ comfyui/agent-harness/ # ComfyUI CLI (70 tests)
├── 🧠 notebooklm/agent-harness/ # NotebookLM CLI (experimental, 21 tests)
+├── 🧩 dify-workflow/agent-harness/ # Dify Workflow CLI wrapper (11 tests)
├── 🛡️ adguardhome/agent-harness/ # AdGuard Home CLI (36 tests)
├── 🦙 ollama/agent-harness/ # Ollama CLI (98 tests)
+├── 🎮 godot/agent-harness/ # Godot Engine CLI (24 tests)
├── 🎨 sketch/agent-harness/ # Sketch CLI (19 tests, Node.js)
├── 🔬 renderdoc/agent-harness/ # RenderDoc CLI (59 tests)
-└── 🎬 videocaptioner/agent-harness/ # VideoCaptioner CLI (26 tests)
-└── ☁️ cloudcompare/agent-harness/ # CloudCompare CLI (88 tests)
+├── 📈 unrealinsights/README.md # Unreal Insights human guide (English)
+├── 📈 unrealinsights/README_CN.md # Unreal Insights human guide (Chinese)
+├── 📈 unrealinsights/agent-harness/ # Unreal Insights CLI (46 tests)
+├── 🎬 videocaptioner/agent-harness/ # VideoCaptioner CLI (26 tests)
+├── 🎬 openscreen/agent-harness/ # Openscreen CLI — screen recording editor (101 tests)
+├── ☁️ cloudcompare/agent-harness/ # CloudCompare CLI (88 tests)
+├── 🔍 exa/agent-harness/ # Exa CLI (40 tests)
+└── ⛅ cloudanalyzer/agent-harness/ # CloudAnalyzer CLI (14 tests)
```
Each `agent-harness/` contains an installable Python package under `cli_anything./` with Click CLI, core modules, utils (including `repl_skin.py` and backend wrapper), and comprehensive tests.
diff --git a/README_CN.md b/README_CN.md
index 36b61a8a4..b14f13116 100644
--- a/README_CN.md
+++ b/README_CN.md
@@ -553,6 +553,13 @@ CLI-Anything 适用于任何有代码库的软件 —— 不限领域,不限
✅ 154 |
+| 🎬 Openscreen |
+屏幕录像编辑器 |
+cli-anything-openscreen |
+ffmpeg backend |
+✅ 101 |
+
+
| 📞 Zoom |
视频会议 |
cli-anything-zoom |
@@ -574,6 +581,13 @@ CLI-Anything 适用于任何有代码库的软件 —— 不限领域,不限
✅ 50 |
+| 📈 Unreal Insights |
+性能分析 |
+cli-anything-unrealinsights |
+Unreal trace 采集 + 匹配版 UnrealInsights 导出 |
+✅ 46 |
+
+
| 🎨 Sketch |
UI 设计 |
sketch-cli |
@@ -582,11 +596,11 @@ CLI-Anything 适用于任何有代码库的软件 —— 不限领域,不限
| 合计 |
-✅ 1,527 |
+✅ 1,573 |
-> 全部 1,527 项测试 **100% 通过** —— 1,073 项单元测试 + 435 项端到端测试 + 19 项 Node.js 测试。
+> 全部 1,674 项测试 **100% 通过** —— 1,197 项单元测试 + 458 项端到端测试 + 19 项 Node.js 测试。
---
@@ -611,12 +625,14 @@ libreoffice 158 passed ✅ (89 unit + 69 e2e)
obs-studio 153 passed ✅ (116 unit + 37 e2e)
kdenlive 155 passed ✅ (111 unit + 44 e2e)
shotcut 154 passed ✅ (110 unit + 44 e2e)
+openscreen 101 passed ✅ (78 unit + 23 e2e)
zoom 22 passed ✅ (22 unit + 0 e2e)
drawio 138 passed ✅ (116 unit + 22 e2e)
anygen 50 passed ✅ (40 unit + 10 e2e)
+unrealinsights 46 passed ✅ (46 unit + 0 e2e smoke-gated)
sketch 19 passed ✅ (19 jest, Node.js)
──────────────────────────────────────────────────────────────────────────────
-TOTAL 1,527 passed ✅ 100% pass rate
+TOTAL 1,674 passed ✅ 100% pass rate
```
---
@@ -679,9 +695,13 @@ cli-anything/
├── 📹 obs-studio/agent-harness/ # OBS Studio CLI(153 项测试)
├── 🎞️ kdenlive/agent-harness/ # Kdenlive CLI(155 项测试)
├── 🎬 shotcut/agent-harness/ # Shotcut CLI(154 项测试)
+├── 🎬 openscreen/agent-harness/ # Openscreen CLI — 屏幕录像编辑器(101 项测试)
├── 📞 zoom/agent-harness/ # Zoom CLI(22 项测试)
├── 📐 drawio/agent-harness/ # Draw.io CLI(138 项测试)
├── ✨ anygen/agent-harness/ # AnyGen CLI(50 项测试)
+├── 📈 unrealinsights/README.md # Unreal Insights 人类说明(英文)
+├── 📈 unrealinsights/README_CN.md # Unreal Insights 人类说明(中文)
+├── 📈 unrealinsights/agent-harness/ # Unreal Insights CLI(46 项测试)
└── 🎨 sketch/agent-harness/ # Sketch CLI(19 项测试,Node.js)
```
diff --git a/registry.json b/registry.json
index 08812d9b5..58018a97c 100644
--- a/registry.json
+++ b/registry.json
@@ -2,9 +2,28 @@
"meta": {
"repo": "https://github.com/HKUDS/CLI-Anything",
"description": "CLI-Hub — Agent-native stateful CLI interfaces for softwares, codebases, and Web Services",
- "updated": "2026-04-15"
+ "updated": "2026-04-16"
},
"clis": [
+ {
+ "name": "wiremock",
+ "display_name": "WireMock",
+ "version": "0.1.0",
+ "description": "HTTP mock server management — create stubs, inspect requests, record traffic, and manage scenarios via WireMock REST API",
+ "requires": "WireMock server running (java -jar wiremock-standalone.jar)",
+ "homepage": "https://wiremock.org",
+ "source_url": null,
+ "install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=wiremock/agent-harness",
+ "entry_point": "cli-anything-wiremock",
+ "skill_md": "wiremock/agent-harness/cli_anything/wiremock/skills/SKILL.md",
+ "category": "testing",
+ "contributors": [
+ {
+ "name": "fabiomantel",
+ "url": "https://github.com/fabiomantel"
+ }
+ ]
+ },
{
"name": "anygen",
"display_name": "AnyGen",
@@ -12,12 +31,17 @@
"description": "Generate docs, slides, websites and more via AnyGen cloud API",
"requires": "ANYGEN_API_KEY",
"homepage": "https://anygen.com",
+ "source_url": null,
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=anygen/agent-harness",
"entry_point": "cli-anything-anygen",
"skill_md": "anygen/agent-harness/cli_anything/anygen/skills/SKILL.md",
"category": "generation",
- "contributor": "koltyu-anygen",
- "contributor_url": "https://github.com/koltyu-anygen"
+ "contributors": [
+ {
+ "name": "koltyu-anygen",
+ "url": "https://github.com/koltyu-anygen"
+ }
+ ]
},
{
"name": "adguardhome",
@@ -26,12 +50,17 @@
"description": "DNS ad-blocking and network infrastructure management via AdGuardHome REST API",
"requires": "AdGuardHome instance running",
"homepage": "https://adguard.com/adguard-home/overview.html",
+ "source_url": null,
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=adguardhome/agent-harness",
"entry_point": "cli-anything-adguardhome",
"skill_md": null,
"category": "network",
- "contributor": "pyxl-dev",
- "contributor_url": "https://github.com/pyxl-dev"
+ "contributors": [
+ {
+ "name": "pyxl-dev",
+ "url": "https://github.com/pyxl-dev"
+ }
+ ]
},
{
"name": "audacity",
@@ -40,12 +69,17 @@
"description": "Audio editing and processing via sox",
"requires": "sox (apt install sox)",
"homepage": "https://www.audacityteam.org",
+ "source_url": null,
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=audacity/agent-harness",
"entry_point": "cli-anything-audacity",
"skill_md": "audacity/agent-harness/cli_anything/audacity/skills/SKILL.md",
"category": "audio",
- "contributor": "CLI-Anything-Team",
- "contributor_url": "https://github.com/HKUDS/CLI-Anything"
+ "contributors": [
+ {
+ "name": "CLI-Anything-Team",
+ "url": "https://github.com/HKUDS/CLI-Anything"
+ }
+ ]
},
{
"name": "blender",
@@ -54,12 +88,17 @@
"description": "3D modeling, animation, and rendering via blender --background --python",
"requires": "blender >= 4.2",
"homepage": "https://www.blender.org",
+ "source_url": null,
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=blender/agent-harness",
"entry_point": "cli-anything-blender",
"skill_md": "blender/agent-harness/cli_anything/blender/skills/SKILL.md",
"category": "3d",
- "contributor": "CLI-Anything-Team",
- "contributor_url": "https://github.com/HKUDS/CLI-Anything"
+ "contributors": [
+ {
+ "name": "CLI-Anything-Team",
+ "url": "https://github.com/HKUDS/CLI-Anything"
+ }
+ ]
},
{
"name": "browser",
@@ -68,12 +107,17 @@
"description": "Browser automation via DOMShell MCP server. Maps Chrome's Accessibility Tree to a virtual filesystem for agent-native navigation.",
"requires": "Node.js, npx, Chrome + DOMShell extension",
"homepage": "https://github.com/apireno/DOMShell",
+ "source_url": null,
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=browser/agent-harness",
"entry_point": "cli-anything-browser",
"skill_md": "browser/agent-harness/cli_anything/browser/skills/SKILL.md",
"category": "web",
- "contributor": "furkankoykiran",
- "contributor_url": "https://github.com/furkankoykiran"
+ "contributors": [
+ {
+ "name": "furkankoykiran",
+ "url": "https://github.com/furkankoykiran"
+ }
+ ]
},
{
"name": "comfyui",
@@ -82,12 +126,17 @@
"description": "AI image generation workflow management via ComfyUI REST API",
"requires": "ComfyUI running at http://localhost:8188",
"homepage": "https://github.com/comfyanonymous/ComfyUI",
+ "source_url": null,
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=comfyui/agent-harness",
"entry_point": "cli-anything-comfyui",
"skill_md": null,
"category": "ai",
- "contributor": "Bortlesboat",
- "contributor_url": "https://github.com/Bortlesboat"
+ "contributors": [
+ {
+ "name": "Bortlesboat",
+ "url": "https://github.com/Bortlesboat"
+ }
+ ]
},
{
"name": "drawio",
@@ -96,12 +145,36 @@
"description": "Diagram creation and export via draw.io CLI",
"requires": "draw.io desktop app",
"homepage": "https://www.drawio.com",
+ "source_url": null,
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=drawio/agent-harness",
"entry_point": "cli-anything-drawio",
"skill_md": "drawio/agent-harness/cli_anything/drawio/skills/SKILL.md",
"category": "diagrams",
- "contributor": "zhangxilong-43",
- "contributor_url": "https://github.com/zhangxilong-43"
+ "contributors": [
+ {
+ "name": "zhangxilong-43",
+ "url": "https://github.com/zhangxilong-43"
+ }
+ ]
+ },
+ {
+ "name": "eth2-quickstart",
+ "display_name": "ETH2 QuickStart",
+ "version": "1.0.0",
+ "description": "Hardened Ethereum node deployment and operations via the eth2-quickstart automation scripts",
+ "requires": "local eth2-quickstart checkout with scripts/eth2qs.sh; Linux host required for real install workflows",
+ "homepage": "https://github.com/chimera-defi/eth2-quickstart",
+ "source_url": null,
+ "install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=eth2-quickstart/agent-harness",
+ "entry_point": "cli-anything-eth2-quickstart",
+ "skill_md": "eth2-quickstart/agent-harness/cli_anything/eth2_quickstart/skills/SKILL.md",
+ "category": "devops",
+ "contributors": [
+ {
+ "name": "chimera-defi",
+ "url": "https://github.com/chimera-defi"
+ }
+ ]
},
{
"name": "gimp",
@@ -110,12 +183,17 @@
"description": "Raster image processing via gimp -i -b (batch mode)",
"requires": "gimp (apt install gimp)",
"homepage": "https://www.gimp.org",
+ "source_url": null,
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=gimp/agent-harness",
"entry_point": "cli-anything-gimp",
"skill_md": "gimp/agent-harness/cli_anything/gimp/skills/SKILL.md",
"category": "image",
- "contributor": "CLI-Anything-Team",
- "contributor_url": "https://github.com/HKUDS/CLI-Anything"
+ "contributors": [
+ {
+ "name": "CLI-Anything-Team",
+ "url": "https://github.com/HKUDS/CLI-Anything"
+ }
+ ]
},
{
"name": "inkscape",
@@ -124,12 +202,17 @@
"description": "SVG vector graphics with export via inkscape --export-filename",
"requires": "inkscape (apt install inkscape)",
"homepage": "https://inkscape.org",
+ "source_url": null,
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=inkscape/agent-harness",
"entry_point": "cli-anything-inkscape",
"skill_md": "inkscape/agent-harness/cli_anything/inkscape/skills/SKILL.md",
"category": "image",
- "contributor": "CLI-Anything-Team",
- "contributor_url": "https://github.com/HKUDS/CLI-Anything"
+ "contributors": [
+ {
+ "name": "CLI-Anything-Team",
+ "url": "https://github.com/HKUDS/CLI-Anything"
+ }
+ ]
},
{
"name": "kdenlive",
@@ -138,12 +221,17 @@
"description": "Video editing and rendering via melt",
"requires": "melt (apt install melt)",
"homepage": "https://kdenlive.org",
+ "source_url": null,
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=kdenlive/agent-harness",
"entry_point": "cli-anything-kdenlive",
"skill_md": "kdenlive/agent-harness/cli_anything/kdenlive/skills/SKILL.md",
"category": "video",
- "contributor": "CLI-Anything-Team",
- "contributor_url": "https://github.com/HKUDS/CLI-Anything"
+ "contributors": [
+ {
+ "name": "CLI-Anything-Team",
+ "url": "https://github.com/HKUDS/CLI-Anything"
+ }
+ ]
},
{
"name": "krita",
@@ -152,12 +240,17 @@
"description": "Digital painting and raster image editing via Krita CLI export pipeline",
"requires": "krita (krita.org)",
"homepage": "https://krita.org",
+ "source_url": null,
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=krita/agent-harness",
"entry_point": "cli-anything-krita",
"skill_md": "krita/agent-harness/cli_anything/krita/skills/SKILL.md",
"category": "image",
- "contributor": "AlexGabbia",
- "contributor_url": "https://github.com/AlexGabbia"
+ "contributors": [
+ {
+ "name": "AlexGabbia",
+ "url": "https://github.com/AlexGabbia"
+ }
+ ]
},
{
"name": "libreoffice",
@@ -166,26 +259,40 @@
"description": "Create and manipulate ODF documents, export to PDF/DOCX/XLSX/PPTX via headless mode",
"requires": "libreoffice",
"homepage": "https://www.libreoffice.org",
+ "source_url": null,
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=libreoffice/agent-harness",
"entry_point": "cli-anything-libreoffice",
"skill_md": "libreoffice/agent-harness/cli_anything/libreoffice/skills/SKILL.md",
"category": "office",
- "contributor": "CLI-Anything-Team",
- "contributor_url": "https://github.com/HKUDS/CLI-Anything"
+ "contributors": [
+ {
+ "name": "CLI-Anything-Team",
+ "url": "https://github.com/HKUDS/CLI-Anything"
+ }
+ ]
},
{
"name": "zotero",
"display_name": "Zotero",
- "version": "0.1.0",
- "description": "Reference management via local Zotero SQLite, connector, and Local API",
- "requires": "Zotero desktop app",
+ "version": "0.4.1",
+ "description": "CLI & MCP server for Zotero 7/8 — 52 MCP tools + 70+ CLI commands for search, import, PDF, BibTeX, notes, and more",
+ "requires": "Zotero 7/8 desktop app (running), Python 3.10+",
"homepage": "https://www.zotero.org",
- "install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=zotero/agent-harness",
+ "source_url": "https://github.com/PiaoyangGuohai1/cli-anything-zotero",
+ "install_cmd": "pip install cli-anything-zotero",
"entry_point": "cli-anything-zotero",
- "skill_md": "zotero/agent-harness/cli_anything/zotero/skills/SKILL.md",
+ "skill_md": "https://github.com/PiaoyangGuohai1/cli-anything-zotero/blob/main/cli_anything/zotero/skills/SKILL.md",
"category": "office",
- "contributor": "zhiwuyazhe_fjr",
- "contributor_url": "https://github.com/zhiwuyazhe_fjr"
+ "contributors": [
+ {
+ "name": "zhiwuyazhe_fjr",
+ "url": "https://github.com/zhiwuyazhe-fjr"
+ },
+ {
+ "name": "PiaoyangGuohai1",
+ "url": "https://github.com/PiaoyangGuohai1"
+ }
+ ]
},
{
"name": "mubu",
@@ -194,12 +301,17 @@
"description": "Knowledge management and outlining via local Mubu desktop data",
"requires": "Mubu desktop app",
"homepage": "https://mubu.com",
+ "source_url": null,
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=mubu/agent-harness",
"entry_point": "cli-anything-mubu",
"skill_md": "mubu/agent-harness/cli_anything/mubu/skills/SKILL.md",
"category": "office",
- "contributor": "cnfjlhj",
- "contributor_url": "https://github.com/cnfjlhj"
+ "contributors": [
+ {
+ "name": "cnfjlhj",
+ "url": "https://github.com/cnfjlhj"
+ }
+ ]
},
{
"name": "mermaid",
@@ -208,12 +320,17 @@
"description": "Mermaid Live Editor state files and renderer URLs",
"requires": null,
"homepage": "https://mermaid.js.org",
+ "source_url": null,
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=mermaid/agent-harness",
"entry_point": "cli-anything-mermaid",
"skill_md": null,
"category": "diagrams",
- "contributor": "getmored-create",
- "contributor_url": "https://github.com/getmored-create"
+ "contributors": [
+ {
+ "name": "getmored-create",
+ "url": "https://github.com/getmored-create"
+ }
+ ]
},
{
"name": "notebooklm",
@@ -222,12 +339,17 @@
"description": "Experimental NotebookLM harness scaffold wrapping the installed notebooklm CLI for notebook, source, chat, artifact, download, and sharing workflows",
"requires": "notebooklm CLI from notebooklm-py + valid local NotebookLM login session",
"homepage": "https://notebooklm.google.com",
+ "source_url": null,
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=notebooklm/agent-harness",
"entry_point": "cli-anything-notebooklm",
"skill_md": "notebooklm/agent-harness/cli_anything/notebooklm/skills/SKILL.md",
"category": "ai",
- "contributor": "Haimbeau1o",
- "contributor_url": "https://github.com/Haimbeau1o"
+ "contributors": [
+ {
+ "name": "Haimbeau1o",
+ "url": "https://github.com/Haimbeau1o"
+ }
+ ]
},
{
"name": "ollama",
@@ -236,12 +358,17 @@
"description": "Local LLM inference and model management via Ollama REST API",
"requires": "Ollama running at http://localhost:11434",
"homepage": "https://ollama.com",
+ "source_url": null,
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=ollama/agent-harness",
"entry_point": "cli-anything-ollama",
"skill_md": "ollama/agent-harness/cli_anything/ollama/skills/SKILL.md",
"category": "ai",
- "contributor": "CLI-Anything-Team",
- "contributor_url": "https://github.com/HKUDS/CLI-Anything"
+ "contributors": [
+ {
+ "name": "CLI-Anything-Team",
+ "url": "https://github.com/HKUDS/CLI-Anything"
+ }
+ ]
},
{
"name": "obs-studio",
@@ -250,12 +377,17 @@
"description": "Create and manage streaming/recording scenes via command line",
"requires": "obs-studio",
"homepage": "https://obsproject.com",
+ "source_url": null,
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=obs-studio/agent-harness",
"entry_point": "cli-anything-obs-studio",
"skill_md": "obs-studio/agent-harness/cli_anything/obs_studio/skills/SKILL.md",
"category": "streaming",
- "contributor": "CLI-Anything-Team",
- "contributor_url": "https://github.com/HKUDS/CLI-Anything"
+ "contributors": [
+ {
+ "name": "CLI-Anything-Team",
+ "url": "https://github.com/HKUDS/CLI-Anything"
+ }
+ ]
},
{
"name": "shotcut",
@@ -264,12 +396,36 @@
"description": "Video editing and rendering via melt/ffmpeg",
"requires": "melt (apt install melt), ffmpeg (apt install ffmpeg)",
"homepage": "https://shotcut.org",
+ "source_url": null,
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=shotcut/agent-harness",
"entry_point": "cli-anything-shotcut",
"skill_md": "shotcut/agent-harness/cli_anything/shotcut/skills/SKILL.md",
"category": "video",
- "contributor": "CLI-Anything-Team",
- "contributor_url": "https://github.com/HKUDS/CLI-Anything"
+ "contributors": [
+ {
+ "name": "CLI-Anything-Team",
+ "url": "https://github.com/HKUDS/CLI-Anything"
+ }
+ ]
+ },
+ {
+ "name": "openscreen",
+ "display_name": "Openscreen",
+ "version": "1.0.0",
+ "description": "Screen recording editor — zoom, speed ramps, trim, crop, annotations, backgrounds, and polished exports via ffmpeg",
+ "requires": "ffmpeg (apt install ffmpeg)",
+ "homepage": "https://github.com/siddharthvaddem/openscreen",
+ "source_url": null,
+ "install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=openscreen/agent-harness",
+ "entry_point": "cli-anything-openscreen",
+ "skill_md": "openscreen/agent-harness/cli_anything/openscreen/skills/SKILL.md",
+ "category": "video",
+ "contributors": [
+ {
+ "name": "ndpvt-web",
+ "url": "https://github.com/ndpvt-web"
+ }
+ ]
},
{
"name": "zoom",
@@ -278,12 +434,17 @@
"description": "Meeting management via Zoom REST API (OAuth2)",
"requires": "Zoom account + OAuth app credentials",
"homepage": "https://zoom.us",
+ "source_url": null,
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=zoom/agent-harness",
"entry_point": "cli-anything-zoom",
"skill_md": "zoom/agent-harness/cli_anything/zoom/skills/SKILL.md",
"category": "communication",
- "contributor": "zhangxilong-43",
- "contributor_url": "https://github.com/zhangxilong-43"
+ "contributors": [
+ {
+ "name": "zhangxilong-43",
+ "url": "https://github.com/zhangxilong-43"
+ }
+ ]
},
{
"name": "novita",
@@ -292,12 +453,74 @@
"description": "Access AI models via Novita's OpenAI-compatible API (DeepSeek, GLM, MiniMax)",
"requires": "NOVITA_API_KEY",
"homepage": "https://novita.ai",
+ "source_url": null,
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=novita/agent-harness",
"entry_point": "cli-anything-novita",
"skill_md": "novita/agent-harness/cli_anything/novita/skills/SKILL.md",
"category": "ai",
- "contributor": "Alex-wuhu",
- "contributor_url": "https://github.com/Alex-wuhu"
+ "contributors": [
+ {
+ "name": "Alex-wuhu",
+ "url": "https://github.com/Alex-wuhu"
+ }
+ ]
+ },
+ {
+ "name": "seaclip",
+ "display_name": "SeaClip",
+ "version": "1.0.0",
+ "description": "Kanban board, 6-agent AI pipeline, and issue management via SeaClip-Lite FastAPI + SQLite",
+ "requires": "SeaClip-Lite running at localhost:5200",
+ "homepage": "https://github.com/t4tarzan/cli-anything-seaclip",
+ "source_url": null,
+ "install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=seaclip/agent-harness",
+ "entry_point": "cli-anything-seaclip",
+ "skill_md": "seaclip/agent-harness/cli_anything/seaclip/skills/SKILL.md",
+ "category": "project-management",
+ "contributors": [
+ {
+ "name": "t4tarzan",
+ "url": "https://github.com/t4tarzan"
+ }
+ ]
+ },
+ {
+ "name": "pm2",
+ "display_name": "PM2",
+ "version": "1.0.0",
+ "description": "Node.js process management — list, start, stop, restart, logs, and metrics via PM2 CLI",
+ "requires": "PM2 (npm install -g pm2)",
+ "homepage": "https://pm2.keymetrics.io",
+ "source_url": null,
+ "install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=pm2/agent-harness",
+ "entry_point": "cli-anything-pm2",
+ "skill_md": "pm2/agent-harness/cli_anything/pm2/skills/SKILL.md",
+ "category": "devops",
+ "contributors": [
+ {
+ "name": "t4tarzan",
+ "url": "https://github.com/t4tarzan"
+ }
+ ]
+ },
+ {
+ "name": "chromadb",
+ "display_name": "ChromaDB",
+ "version": "1.0.0",
+ "description": "Vector database operations — collections, documents, semantic search via ChromaDB HTTP API",
+ "requires": "ChromaDB server running at localhost:8000",
+ "homepage": "https://www.trychroma.com",
+ "source_url": null,
+ "install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=chromadb/agent-harness",
+ "entry_point": "cli-anything-chromadb",
+ "skill_md": "chromadb/agent-harness/cli_anything/chromadb/skills/SKILL.md",
+ "category": "database",
+ "contributors": [
+ {
+ "name": "t4tarzan",
+ "url": "https://github.com/t4tarzan"
+ }
+ ]
},
{
"name": "musescore",
@@ -306,12 +529,17 @@
"description": "CLI for music notation — transpose, export PDF/audio/MIDI, extract parts, manage instruments",
"requires": "MuseScore 4 (musescore.org)",
"homepage": "https://musescore.org",
+ "source_url": null,
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=musescore/agent-harness",
"entry_point": "cli-anything-musescore",
"skill_md": "musescore/agent-harness/cli_anything/musescore/skills/SKILL.md",
"category": "music",
- "contributor": "tamvicky",
- "contributor_url": "https://github.com/tamvicky"
+ "contributors": [
+ {
+ "name": "tamvicky",
+ "url": "https://github.com/tamvicky"
+ }
+ ]
},
{
"name": "sketch",
@@ -320,12 +548,17 @@
"description": "Generate Sketch design files (.sketch) from JSON design specifications via sketch-constructor",
"requires": "Node.js >= 16.0.0",
"homepage": "https://www.sketch.com",
+ "source_url": null,
"install_cmd": "cd sketch/agent-harness && npm install && npm link",
"entry_point": "sketch-cli",
"skill_md": null,
"category": "design",
- "contributor": "zhangxilong-43",
- "contributor_url": "https://github.com/zhangxilong-43"
+ "contributors": [
+ {
+ "name": "zhangxilong-43",
+ "url": "https://github.com/zhangxilong-43"
+ }
+ ]
},
{
"name": "freecad",
@@ -334,12 +567,17 @@
"description": "Parametric 3D CAD modeling via FreeCAD CLI (258 commands: Part, Sketcher, PartDesign, Assembly, Mesh, TechDraw, Draft, FEM, CAM, and more)",
"requires": "FreeCAD >= 1.1 (freecad.org)",
"homepage": "https://www.freecad.org",
+ "source_url": null,
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=freecad/agent-harness",
"entry_point": "cli-anything-freecad",
"skill_md": "freecad/agent-harness/cli_anything/freecad/skills/SKILL.md",
"category": "3d",
- "contributor": "AlexGabbia",
- "contributor_url": "https://github.com/AlexGabbia"
+ "contributors": [
+ {
+ "name": "AlexGabbia",
+ "url": "https://github.com/AlexGabbia"
+ }
+ ]
},
{
"name": "iterm2",
@@ -348,26 +586,36 @@
"description": "Control a running iTerm2 instance — manage windows, tabs, split panes, send text, read output, run tmux -CC, broadcast keystrokes, and configure preferences.",
"requires": "iTerm2.app (macOS only)",
"homepage": "https://iterm2.com",
+ "source_url": null,
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=iterm2/agent-harness",
"entry_point": "cli-anything-iterm2",
"skill_md": "iterm2/agent-harness/cli_anything/iterm2_ctl/skills/SKILL.md",
"category": "devops",
- "contributor": "voidfreud",
- "contributor_url": "https://github.com/voidfreud"
+ "contributors": [
+ {
+ "name": "voidfreud",
+ "url": "https://github.com/voidfreud"
+ }
+ ]
},
{
"name": "slay_the_spire_ii",
- "display_name": "Slay the Spire II",
+ "display_name": "Slay the Spire 2",
"version": "1.0.0",
"description": "Control the real Slay the Spire 2 game via local STS2_Bridge HTTP API",
"requires": "Slay the Spire 2 (Steam) with STS2_Bridge mod enabled",
"homepage": "https://github.com/HKUDS/CLI-Anything/tree/main/slay_the_spire_ii",
+ "source_url": null,
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=slay_the_spire_ii/agent-harness",
"entry_point": "cli-anything-sts2",
"skill_md": "slay_the_spire_ii/agent-harness/cli_anything/slay_the_spire_ii/skills/SKILL.md",
"category": "game",
- "contributor": "TianyuFan0504",
- "contributor_url": "https://github.com/TianyuFan0504"
+ "contributors": [
+ {
+ "name": "TianyuFan0504",
+ "url": "https://github.com/TianyuFan0504"
+ }
+ ]
},
{
"name": "rms",
@@ -376,12 +624,17 @@
"description": "Device management and monitoring via Teltonika RMS REST API",
"requires": "RMS_API_TOKEN",
"homepage": "https://rms.teltonika-networks.com",
+ "source_url": null,
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=rms/agent-harness",
"entry_point": "cli-anything-rms",
"skill_md": "rms/agent-harness/cli_anything/rms/skills/SKILL.md",
"category": "network",
- "contributor": "galke",
- "contributor_url": "https://github.com/galke7"
+ "contributors": [
+ {
+ "name": "galke",
+ "url": "https://github.com/galke7"
+ }
+ ]
},
{
"name": "renderdoc",
@@ -390,26 +643,36 @@
"description": "GPU frame capture analysis: pipeline state, shader export, texture inspection, draw call browsing",
"requires": "renderdoc (Python bindings from RenderDoc installation)",
"homepage": "https://renderdoc.org",
+ "source_url": null,
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=renderdoc/agent-harness",
"entry_point": "cli-anything-renderdoc",
"skill_md": "renderdoc/agent-harness/cli_anything/renderdoc/skills/SKILL.md",
"category": "graphics",
- "contributor": "levishilf",
- "contributor_url": "https://github.com/levishilf"
+ "contributors": [
+ {
+ "name": "levishilf",
+ "url": "https://github.com/levishilf"
+ }
+ ]
},
{
"name": "unrealinsights",
"display_name": "Unreal Insights",
"version": "0.1.0",
- "description": "Windows-first Unreal trace capture orchestration and headless Timing Insights export workflows",
- "requires": "Windows + Unreal Engine 5.5+ installation with UnrealInsights.exe",
+ "description": "Windows-first Unreal trace capture, background session control, engine-matched UnrealInsights builds, and headless Timing Insights export workflows",
+ "requires": "Windows + Unreal Engine source or installed build with UnrealEditor and Unreal trace support",
"homepage": "https://dev.epicgames.com/documentation/en-us/unreal-engine/unreal-insights-in-unreal-engine",
+ "source_url": null,
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=unrealinsights/agent-harness",
"entry_point": "cli-anything-unrealinsights",
"skill_md": "unrealinsights/agent-harness/cli_anything/unrealinsights/skills/SKILL.md",
"category": "debugging",
- "contributor": "AiMiDi",
- "contributor_url": "https://github.com/AiMiDi"
+ "contributors": [
+ {
+ "name": "AiMiDi",
+ "url": "https://github.com/AiMiDi"
+ }
+ ]
},
{
"name": "videocaptioner",
@@ -418,12 +681,17 @@
"description": "AI-powered video captioning — transcribe speech, optimize/translate subtitles, burn styled subtitles into video",
"requires": "videocaptioner (pip install videocaptioner), ffmpeg",
"homepage": "https://github.com/WEIFENG2333/VideoCaptioner",
+ "source_url": null,
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=videocaptioner/agent-harness",
"entry_point": "cli-anything-videocaptioner",
"skill_md": "videocaptioner/agent-harness/cli_anything/videocaptioner/skills/SKILL.md",
"category": "video",
- "contributor": "WEIFENG2333",
- "contributor_url": "https://github.com/WEIFENG2333"
+ "contributors": [
+ {
+ "name": "WEIFENG2333",
+ "url": "https://github.com/WEIFENG2333"
+ }
+ ]
},
{
"name": "intelwatch",
@@ -432,12 +700,17 @@
"description": "Competitive intelligence, M&A due diligence, and OSINT directly from your terminal.",
"requires": "Node.js >=18",
"homepage": "https://github.com/ashroth/intelwatch",
+ "source_url": null,
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=intelwatch/agent-harness",
"entry_point": "cli-anything-intelwatch",
"skill_md": "intelwatch/agent-harness/cli_anything/intelwatch/skills/SKILL.md",
"category": "osint",
- "contributor": "ashroth",
- "contributor_url": "https://github.com/ashroth"
+ "contributors": [
+ {
+ "name": "ashroth",
+ "url": "https://github.com/ashroth"
+ }
+ ]
},
{
"name": "clibrowser",
@@ -446,12 +719,17 @@
"description": "Zero-dependency CLI browser for AI agents with search, extraction, forms, RSS, crawling, auth, and WebMCP support",
"requires": "Rust toolchain (cargo) for source install, or manual binary download from GitHub Releases",
"homepage": "https://github.com/allthingssecurity/clibrowser",
+ "source_url": null,
"install_cmd": "cargo install --git https://github.com/allthingssecurity/clibrowser.git --tag v0.1.0 --locked",
"entry_point": "clibrowser",
"skill_md": null,
"category": "web",
- "contributor": "allthingssecurity",
- "contributor_url": "https://github.com/allthingssecurity"
+ "contributors": [
+ {
+ "name": "allthingssecurity",
+ "url": "https://github.com/allthingssecurity"
+ }
+ ]
},
{
"name": "cloudcompare",
@@ -460,12 +738,169 @@
"description": "3D point cloud and mesh processing: load/save, color ops, normal estimation, Delaunay meshing, noise filtering, ICP registration, connected component segmentation",
"requires": "cloudcompare (CloudCompare application installed, e.g. via Flatpak or package manager)",
"homepage": "https://cloudcompare.org",
+ "source_url": null,
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=cloudcompare/agent-harness",
"entry_point": "cli-anything-cloudcompare",
"skill_md": "cloudcompare/agent-harness/cli_anything/cloudcompare/skills/SKILL.md",
"category": "graphics",
- "contributor": "Taeyoung96",
- "contributor_url": "https://github.com/Taeyoung96"
+ "contributors": [
+ {
+ "name": "Taeyoung96",
+ "url": "https://github.com/Taeyoung96"
+ }
+ ]
+ },
+ {
+ "name": "exa",
+ "display_name": "Exa",
+ "version": "1.0.0",
+ "description": "AI-powered web search and content extraction via the Exa API",
+ "requires": "EXA_API_KEY (free tier at exa.ai)",
+ "homepage": "https://exa.ai",
+ "source_url": null,
+ "install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=exa/agent-harness",
+ "entry_point": "cli-anything-exa",
+ "skill_md": "exa/agent-harness/cli_anything/exa/skills/SKILL.md",
+ "category": "search",
+ "contributors": [
+ {
+ "name": "tgonzalezc5",
+ "url": "https://github.com/tgonzalezc5"
+ }
+ ]
+ },
+ {
+ "name": "godot",
+ "display_name": "Godot Engine",
+ "version": "1.0.0",
+ "description": "Game engine project management, scene editing, export and GDScript execution via Godot 4 headless mode",
+ "requires": "Godot 4.x (godotengine.org)",
+ "homepage": "https://godotengine.org",
+ "source_url": null,
+ "install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=godot/agent-harness",
+ "entry_point": "cli-anything-godot",
+ "skill_md": "godot/agent-harness/cli_anything/godot/skills/SKILL.md",
+ "category": "gamedev",
+ "contributors": [
+ {
+ "name": "omerarslan0",
+ "url": "https://github.com/omerarslan0"
+ }
+ ]
+ },
+ {
+ "name": "dify-workflow",
+ "display_name": "Dify Workflow",
+ "version": "0.1.0",
+ "description": "CLI-Anything wrapper for the Dify workflow DSL editor covering create, inspect, validate, edit, export, and layout operations",
+ "requires": "Upstream dify-workflow CLI installed from https://github.com/Akabane71/dify-workflow-cli",
+ "homepage": "https://github.com/Akabane71/dify-workflow-cli",
+ "source_url": null,
+ "install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=dify-workflow/agent-harness",
+ "entry_point": "cli-anything-dify-workflow",
+ "skill_md": "dify-workflow/agent-harness/cli_anything/dify_workflow/skills/SKILL.md",
+ "category": "ai",
+ "contributors": [
+ {
+ "name": "Akabane71",
+ "url": "https://github.com/Akabane71"
+ }
+ ]
+ },
+ {
+ "name": "n8n",
+ "display_name": "n8n",
+ "version": "2.4.7",
+ "description": "Workflow automation via n8n REST API — 55+ commands",
+ "requires": "n8n >= 1.0.0",
+ "homepage": "https://n8n.io",
+ "source_url": null,
+ "install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=n8n/agent-harness",
+ "entry_point": "cli-anything-n8n",
+ "skill_md": "n8n/agent-harness/cli_anything/n8n/skills/SKILL.md",
+ "category": "automation",
+ "contributors": [
+ {
+ "name": "webcomunicasolutions",
+ "url": "https://github.com/webcomunicasolutions"
+ }
+ ]
+ },
+ {
+ "name": "cloudanalyzer",
+ "display_name": "CloudAnalyzer",
+ "version": "1.0.0",
+ "description": "Point cloud and trajectory QA: Chamfer/AUC/F1, ATE/RPE/drift, ground segmentation metrics, config-driven quality gates, baseline evolution — harness wraps the CloudAnalyzer Python API",
+ "requires": "Python 3.10+; cloudanalyzer (`pip install cloudanalyzer`), Open3D for full IO/viewer paths",
+ "homepage": "https://github.com/rsasaki0109/CloudAnalyzer",
+ "source_url": null,
+ "install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=cloudanalyzer/agent-harness",
+ "entry_point": "cli-anything-cloudanalyzer",
+ "skill_md": "cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/skills/SKILL.md",
+ "category": "graphics",
+ "contributors": [
+ {
+ "name": "rsasaki0109",
+ "url": "https://github.com/rsasaki0109"
+ }
+ ]
+ },
+ {
+ "name": "obsidian",
+ "display_name": "Obsidian",
+ "version": "1.0.0",
+ "description": "Knowledge management and note-taking — manage notes, search vault, execute commands via Obsidian Local REST API",
+ "requires": "Obsidian desktop app with Local REST API plugin enabled",
+ "homepage": "https://obsidian.md",
+ "source_url": null,
+ "install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=obsidian/agent-harness",
+ "entry_point": "cli-anything-obsidian",
+ "skill_md": "obsidian/agent-harness/cli_anything/obsidian/skills/SKILL.md",
+ "category": "knowledge",
+ "contributors": [
+ {
+ "name": "dorukozgen",
+ "url": "https://github.com/dorukozgen"
+ }
+ ]
+ },
+ {
+ "name": "unimol_tools",
+ "display_name": "Uni-Mol Tools",
+ "version": "1.0.0",
+ "description": "Molecular property prediction — train and predict with 5 task types (classification, regression, multiclass, multilabel) for drug discovery",
+ "requires": "PyTorch 1.12+, Uni-Mol Tools backend",
+ "homepage": "https://github.com/dptech-corp/Uni-Mol/tree/main/unimol_tools",
+ "source_url": null,
+ "install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=unimol_tools/agent-harness",
+ "entry_point": "cli-anything-unimol-tools",
+ "skill_md": "unimol_tools/agent-harness/cli_anything/unimol_tools/SKILL.md",
+ "category": "science",
+ "contributors": [
+ {
+ "name": "545487677",
+ "url": "https://github.com/545487677"
+ }
+ ]
+ },
+ {
+ "name": "safari",
+ "display_name": "Safari",
+ "version": "1.0.0",
+ "description": "Native macOS Safari browser automation via safari-mcp — 84 tools for navigation, DOM, forms, network capture, and screenshots",
+ "requires": "macOS, Safari, safari-mcp (npm install -g safari-mcp)",
+ "homepage": "https://github.com/achiya-automation/safari-mcp",
+ "source_url": null,
+ "install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=safari/agent-harness",
+ "entry_point": "cli-anything-safari",
+ "skill_md": "safari/agent-harness/cli_anything/safari/skills/SKILL.md",
+ "category": "web",
+ "contributors": [
+ {
+ "name": "achiya-automation",
+ "url": "https://github.com/achiya-automation"
+ }
+ ]
}
]
}
diff --git a/unrealinsights/README.md b/unrealinsights/README.md
new file mode 100644
index 000000000..5764913da
--- /dev/null
+++ b/unrealinsights/README.md
@@ -0,0 +1,127 @@
+# Unreal Insights Human Guide
+
+This document is for people collaborating with AI, not for Python package maintainers.
+
+`unrealinsights/agent-harness` gives AI agents a reliable way to drive Unreal
+trace capture and Unreal Insights analysis workflows from the terminal.
+
+## What This Capability Can Do
+
+- find or build an `UnrealInsights.exe` matching your current Unreal Engine
+- start a one-shot trace capture
+- start a background capture and let it run until you decide to stop
+- inspect the current background capture status
+- create a best-effort snapshot copy of the active trace
+- stop the tracked capture session
+- export an existing `.utrace` into:
+ - `threads`
+ - `timers`
+ - `timing-events`
+ - `timer-stats`
+ - `timer-callees`
+ - `counters`
+ - `counter-values`
+- read exported CSV files and summarize hotspots, waits, and likely bottlenecks
+
+## How Humans Should Ask AI To Use It
+
+The most effective prompt is not “analyze performance,” but a prompt that gives:
+
+1. engine path
+2. project path or target executable
+3. whether you care about startup or runtime behavior
+4. what output you want back
+
+## Good Prompt Patterns
+
+### Startup performance
+
+```text
+Use D:\code\D5\d5render-ue5_3 to analyze startup performance for
+D:\code\D5\FusionEffectBuild.
+First ensure a matching UnrealInsights.exe exists, then capture a startup trace,
+export timer-stats, and summarize the top 20 hotspots.
+```
+
+### Long-running capture until I say stop
+
+```text
+Use D:\code\D5\d5render-ue5_3 and D:\code\D5\FusionEffectBuild
+to start a background performance capture.
+Do not block and do not exit the project immediately.
+Tell me the trace path and current status first.
+When I say "stop", stop the capture, make a snapshot, export timer-stats and
+timing-events, then summarize the results.
+```
+
+### Runtime scene analysis
+
+```text
+Start a background trace for this project, let me interact with the scene
+manually, and wait.
+When I say "stop now", export timer-stats and timing-events and focus on
+GameThread, RenderThread, and task-system waits.
+```
+
+### Source-built engine without UnrealInsights.exe
+
+```text
+This is a source-built engine.
+First find or build UnrealInsights.exe under D:\code\D5\d5render-ue5_3,
+then use it to analyze the trace.
+```
+
+## Keywords AI Handles Well
+
+These phrases are especially useful:
+
+- “ensure matching UnrealInsights”
+- “background continuous capture”
+- “wait until I say stop”
+- “make a snapshot”
+- “export timer-stats”
+- “export timing-events”
+- “summarize top hotspots”
+- “look at GameThread / RenderThread / WaitForTasks”
+
+## What AI Can Usually Do Automatically
+
+If you give enough context, AI can usually:
+
+- resolve the `.uproject`
+- infer `UnrealEditor.exe`
+- choose or build a matching `UnrealInsights.exe`
+- generate `.utrace`
+- export CSV reports
+- extract hotspots from CSV
+- tell you whether the trace looks more like:
+ - startup waiting
+ - task-scheduler blocking
+ - GameThread stalls
+ - RenderThread / render-pass pressure
+ - audio or streaming side effects
+
+## Current Scope
+
+The current version supports:
+
+- `capture run`
+- `capture start`
+- `capture status`
+- `capture stop`
+- `capture snapshot`
+- `backend ensure-insights`
+
+It is not full Unreal GUI automation:
+
+- it does not drive an already-open Unreal Insights GUI panel directly
+- it does not fully manage live SessionFrontend / TraceStore UI workflows
+- `capture stop` is a best-effort stop of the harness-launched process tree, not
+ full SessionServices remote `Trace.Stop`
+- `capture snapshot` is a best-effort filesystem snapshot of the active trace,
+ not a full live trace-store control path
+
+## One-Sentence Rule
+
+Give AI the path, the scene, the timing, and the desired output, and it will be
+able to use this capability much more effectively.
diff --git a/unrealinsights/README_CN.md b/unrealinsights/README_CN.md
new file mode 100644
index 000000000..0fa3c3cb5
--- /dev/null
+++ b/unrealinsights/README_CN.md
@@ -0,0 +1,121 @@
+# Unreal Insights 使用说明
+
+这份文档是给正在和 AI 协作的人看的,不是给 Python 包维护者看的。
+
+`unrealinsights/agent-harness` 提供的是一套 AI 可调用的 Unreal 性能采集与分析能力,让 Agent 能比较稳定地驱动 Unreal trace capture 和 Unreal Insights 导出流程。
+
+## 这个能力能做什么
+
+- 找到或构建与你当前 Unreal Engine 匹配的 `UnrealInsights.exe`
+- 启动一次性 trace 采集
+- 启动后台持续采集,并在你指定时机停止
+- 查看当前后台采集状态
+- 对当前活跃 trace 做 best-effort 快照
+- 停止当前追踪会话
+- 对已有 `.utrace` 导出:
+ - `threads`
+ - `timers`
+ - `timing-events`
+ - `timer-stats`
+ - `timer-callees`
+ - `counters`
+ - `counter-values`
+- 读取导出的 CSV,并总结热点、等待链和可能瓶颈
+
+## 人类应该怎么调 AI
+
+最有效的方式不是说“帮我分析性能”,而是补齐下面四类信息:
+
+1. 引擎路径
+2. 项目路径或目标可执行文件
+3. 你关心的是“启动阶段”还是“运行时场景”
+4. 你希望 AI 最终给你什么输出
+
+## 推荐提法
+
+### 只分析启动性能
+
+```text
+用 D:\code\D5\d5render-ue5_3 这套引擎,
+分析 D:\code\D5\FusionEffectBuild 这个项目的启动性能。
+先确保匹配版 UnrealInsights 可用,再抓一份启动 trace,
+导出 timer-stats,并总结前 20 个热点。
+```
+
+### 持续采集,等我说停
+
+```text
+用 D:\code\D5\d5render-ue5_3 和 D:\code\D5\FusionEffectBuild
+启动一份后台性能采集,不要阻塞,不要立刻退出项目。
+先告诉我 trace 输出路径和当前状态。
+等我说“停”时,再停止采集、做一次 snapshot,
+导出 timer-stats 和 timing-events。
+```
+
+### 指定场景分析运行时性能
+
+```text
+先启动后台 trace,让我在项目里手动操作某个场景。
+我说“现在停”之后,你帮我导出 timer-stats、timing-events,
+重点看 GameThread、RenderThread 和任务系统等待时间。
+```
+
+### 自定义引擎没有 Insights 可执行文件
+
+```text
+这套引擎是源码版,
+先帮我在 D:\code\D5\d5render-ue5_3 下面找或构建 UnrealInsights.exe,
+然后再用它分析 trace。
+```
+
+## AI 最容易接住的关键词
+
+下面这些关键词很适合直接写进需求:
+
+- “确保匹配版 UnrealInsights”
+- “后台持续采集”
+- “等我说停”
+- “做一次 snapshot”
+- “导出 timer-stats”
+- “导出 timing-events”
+- “总结 top hotspots”
+- “看 GameThread / RenderThread / WaitForTasks”
+
+## 你可以期待 AI 自动做的事
+
+如果描述足够完整,AI 通常会自动:
+
+- 解析 `.uproject`
+- 推导 `UnrealEditor.exe`
+- 选择或构建匹配版 `UnrealInsights.exe`
+- 生成 `.utrace`
+- 导出 CSV
+- 从 CSV 中提取热点
+- 告诉你这份 trace 更像是:
+ - 启动等待
+ - 任务调度阻塞
+ - GameThread 卡住
+ - RenderThread / 渲染 pass 偏重
+ - 音频或 streaming 相关问题
+
+## 当前能力边界
+
+当前版本已经支持:
+
+- `capture run`
+- `capture start`
+- `capture status`
+- `capture stop`
+- `capture snapshot`
+- `backend ensure-insights`
+
+但它还不是完整的 Unreal GUI 自动化:
+
+- 不直接驱动已经打开的 Unreal Insights 图形界面
+- 不完整覆盖 live SessionFrontend / TraceStore 的 GUI 工作流
+- `capture stop` 当前是对 harness 启动的进程树做 best-effort 停止,不是完整的 SessionServices 远程 `Trace.Stop`
+- `capture snapshot` 当前是对活跃 trace 的 best-effort 文件快照,不是完整的 live trace-store 控制路径
+
+## 一句话原则
+
+把“路径 + 场景 + 时机 + 输出物”说清楚,AI 就最容易把这个能力真正调起来。
diff --git a/unrealinsights/agent-harness/cli_anything/unrealinsights/README.md b/unrealinsights/agent-harness/cli_anything/unrealinsights/README.md
index 96de51ab6..a81449bf1 100644
--- a/unrealinsights/agent-harness/cli_anything/unrealinsights/README.md
+++ b/unrealinsights/agent-harness/cli_anything/unrealinsights/README.md
@@ -43,6 +43,21 @@ cli-anything-unrealinsights --json backend ensure-insights `
# Bind a trace file for the current session
cli-anything-unrealinsights trace set D:\captures\session.utrace
+# Start a background capture session and keep it running
+cli-anything-unrealinsights --json capture start `
+ --project 'D:\Projects\MyGame\MyGame.uproject' `
+ --engine-root 'D:\Program Files\Epic Games\UE_5.5' `
+ --output-trace D:\captures\live_session.utrace
+
+# Check current capture status
+cli-anything-unrealinsights --json capture status
+
+# Create a best-effort snapshot without ending the session
+cli-anything-unrealinsights --json capture snapshot D:\captures\live_snapshot.utrace
+
+# Stop the tracked capture session
+cli-anything-unrealinsights --json capture stop
+
# Export threads
cli-anything-unrealinsights --json -t D:\captures\session.utrace export threads D:\out\threads.csv
@@ -76,6 +91,10 @@ cli-anything-unrealinsights
- `info`
- `capture`
- `run`
+ - `start`
+ - `status`
+ - `stop`
+ - `snapshot`
- `export`
- `threads`
- `timers`
@@ -134,6 +153,24 @@ Notes:
- If `target_exe` is omitted, `capture run` resolves `UnrealEditor.exe` from `--engine-root`.
- The original explicit `target_exe` path remains supported.
+## Continuous AI-Driven Capture
+
+For longer-running sessions, prefer this loop:
+
+```powershell
+cli-anything-unrealinsights --json capture start --project ... --engine-root ...
+cli-anything-unrealinsights --json capture status
+cli-anything-unrealinsights --json capture snapshot
+cli-anything-unrealinsights --json capture stop
+```
+
+Behavior:
+
+- `capture start` launches the target in the background and persists the tracked PID/trace/session metadata
+- `capture status` reads the persisted session state and reports whether the process is still running and how large the trace has grown
+- `capture snapshot` creates a best-effort copy of the current `.utrace` without requiring you to end the session first
+- `capture stop` performs a best-effort stop of the tracked process tree launched by the harness
+
## Export Filters
`timing-events` and `timer-stats` support:
diff --git a/unrealinsights/agent-harness/cli_anything/unrealinsights/core/capture.py b/unrealinsights/agent-harness/cli_anything/unrealinsights/core/capture.py
index 89e4d3864..e873a1bf8 100644
--- a/unrealinsights/agent-harness/cli_anything/unrealinsights/core/capture.py
+++ b/unrealinsights/agent-harness/cli_anything/unrealinsights/core/capture.py
@@ -5,6 +5,7 @@ Capture orchestration helpers.
from __future__ import annotations
import os
+import shutil
import time
from pathlib import Path
from typing import Sequence
@@ -167,3 +168,60 @@ def run_capture(
}
)
return result
+
+
+def capture_status(session) -> dict[str, object]:
+ """Return the current tracked capture status."""
+ info = session.capture_info()
+ pid = info.get("pid")
+ info["running"] = backend.is_process_running(pid) if pid else False
+ return info
+
+
+def stop_capture(session, force: bool = False, timeout: float | None = None) -> dict[str, object]:
+ """Stop the currently tracked capture process."""
+ info = capture_status(session)
+ pid = info.get("pid")
+ if not pid:
+ raise RuntimeError("No active capture session is being tracked.")
+
+ termination = backend.terminate_process(int(pid), force=force, timeout=timeout)
+ status = capture_status(session)
+ result = {
+ "termination": termination,
+ "capture": status,
+ }
+ if termination.get("stopped"):
+ session.clear_capture()
+ result["capture"] = session.capture_info()
+ if info.get("trace_path"):
+ session.set_trace(info["trace_path"])
+ return result
+
+
+def snapshot_capture(session, output_trace: str | None = None) -> dict[str, object]:
+ """Create a best-effort copy of the current trace file."""
+ info = capture_status(session)
+ source = info.get("trace_path")
+ if not source:
+ raise RuntimeError("No active capture trace is available to snapshot.")
+
+ source_path = Path(source).expanduser().resolve()
+ if not source_path.is_file():
+ raise RuntimeError(f"Trace file not found: {source_path}")
+
+ if output_trace:
+ output_path = Path(output_trace).expanduser().resolve()
+ else:
+ timestamp = time.strftime("%Y%m%d-%H%M%S")
+ output_path = source_path.with_name(f"{source_path.stem}-snapshot-{timestamp}{source_path.suffix}")
+
+ output_path.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copy2(source_path, output_path)
+ return {
+ "source_trace": str(source_path),
+ "snapshot_trace": str(output_path),
+ "snapshot_exists": output_path.is_file(),
+ "snapshot_size": output_path.stat().st_size if output_path.is_file() else None,
+ "capture_running": info.get("running", False),
+ }
diff --git a/unrealinsights/agent-harness/cli_anything/unrealinsights/core/session.py b/unrealinsights/agent-harness/cli_anything/unrealinsights/core/session.py
index e6de13ae9..cf18c2e90 100644
--- a/unrealinsights/agent-harness/cli_anything/unrealinsights/core/session.py
+++ b/unrealinsights/agent-harness/cli_anything/unrealinsights/core/session.py
@@ -1,11 +1,30 @@
"""
-In-memory session state for Unreal Insights CLI workflows.
+Persistent session state for Unreal Insights CLI workflows.
"""
from __future__ import annotations
-from dataclasses import dataclass
+import json
+import os
+from dataclasses import asdict, dataclass, field
+from datetime import datetime, timezone
from pathlib import Path
+from typing import Any
+
+
+def _normalize_path(path: str | None) -> str | None:
+ return str(Path(path).expanduser().resolve()) if path else None
+
+
+def _state_dir() -> Path:
+ override = os.environ.get("CLI_ANYTHING_UNREALINSIGHTS_STATE_DIR", "").strip()
+ base = Path(override).expanduser() if override else Path.home() / ".cli-anything-unrealinsights"
+ base.mkdir(parents=True, exist_ok=True)
+ return base.resolve()
+
+
+def _state_file() -> Path:
+ return _state_dir() / "session.json"
@dataclass
@@ -13,17 +32,93 @@ class UnrealInsightsSession:
trace_path: str | None = None
insights_exe: str | None = None
trace_server_exe: str | None = None
+ capture_pid: int | None = None
+ capture_target_exe: str | None = None
+ capture_target_args: list[str] = field(default_factory=list)
+ capture_project_path: str | None = None
+ capture_engine_root: str | None = None
+ capture_trace_path: str | None = None
+ capture_channels: str | None = None
+ capture_started_at: str | None = None
+
+ @classmethod
+ def load(cls) -> "UnrealInsightsSession":
+ path = _state_file()
+ if not path.is_file():
+ return cls()
+
+ try:
+ data = json.loads(path.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError):
+ return cls()
+
+ return cls(
+ trace_path=data.get("trace_path"),
+ insights_exe=data.get("insights_exe"),
+ trace_server_exe=data.get("trace_server_exe"),
+ capture_pid=data.get("capture_pid"),
+ capture_target_exe=data.get("capture_target_exe"),
+ capture_target_args=list(data.get("capture_target_args", [])),
+ capture_project_path=data.get("capture_project_path"),
+ capture_engine_root=data.get("capture_engine_root"),
+ capture_trace_path=data.get("capture_trace_path"),
+ capture_channels=data.get("capture_channels"),
+ capture_started_at=data.get("capture_started_at"),
+ )
+
+ def save(self):
+ path = _state_file()
+ tmp = path.with_suffix(".tmp")
+ tmp.write_text(json.dumps(asdict(self), indent=2), encoding="utf-8")
+ os.replace(tmp, path)
def set_trace(self, trace_path: str | None):
- self.trace_path = str(Path(trace_path).expanduser().resolve()) if trace_path else None
+ self.trace_path = _normalize_path(trace_path)
+ self.save()
def set_insights_exe(self, path: str | None):
- self.insights_exe = str(Path(path).expanduser().resolve()) if path else None
+ self.insights_exe = _normalize_path(path)
+ self.save()
def set_trace_server_exe(self, path: str | None):
- self.trace_server_exe = str(Path(path).expanduser().resolve()) if path else None
+ self.trace_server_exe = _normalize_path(path)
+ self.save()
- def trace_info(self) -> dict[str, object]:
+ def set_capture(
+ self,
+ *,
+ pid: int | None,
+ target_exe: str,
+ target_args: list[str],
+ trace_path: str,
+ channels: str,
+ project_path: str | None = None,
+ engine_root: str | None = None,
+ ):
+ self.capture_pid = pid
+ self.capture_target_exe = _normalize_path(target_exe)
+ self.capture_target_args = list(target_args)
+ self.capture_trace_path = _normalize_path(trace_path)
+ self.capture_channels = channels
+ self.capture_project_path = _normalize_path(project_path)
+ self.capture_engine_root = _normalize_path(engine_root)
+ self.capture_started_at = datetime.now(timezone.utc).isoformat()
+ if self.capture_trace_path:
+ self.trace_path = self.capture_trace_path
+ self.save()
+
+ def clear_capture(self):
+ self.capture_pid = None
+ self.capture_target_exe = None
+ self.capture_target_args = []
+ self.capture_project_path = None
+ self.capture_engine_root = None
+ self.capture_trace_path = None
+ self.capture_channels = None
+ self.capture_started_at = None
+ self.save()
+
+ def trace_info(self) -> dict[str, Any]:
if self.trace_path is None:
return {
"trace_path": None,
@@ -36,3 +131,20 @@ class UnrealInsightsSession:
"exists": path.is_file(),
"file_size": path.stat().st_size if path.is_file() else None,
}
+
+ def capture_info(self) -> dict[str, Any]:
+ trace_path = self.capture_trace_path or self.trace_path
+ path = Path(trace_path) if trace_path else None
+ return {
+ "active": self.capture_pid is not None or self.capture_trace_path is not None,
+ "pid": self.capture_pid,
+ "target_exe": self.capture_target_exe,
+ "target_args": list(self.capture_target_args),
+ "project_path": self.capture_project_path,
+ "engine_root": self.capture_engine_root,
+ "trace_path": str(path) if path else None,
+ "trace_exists": path.is_file() if path else False,
+ "trace_size": path.stat().st_size if path and path.is_file() else None,
+ "channels": self.capture_channels,
+ "started_at": self.capture_started_at,
+ }
diff --git a/unrealinsights/agent-harness/cli_anything/unrealinsights/skills/SKILL.md b/unrealinsights/agent-harness/cli_anything/unrealinsights/skills/SKILL.md
index 8943b48da..b865a28b8 100644
--- a/unrealinsights/agent-harness/cli_anything/unrealinsights/skills/SKILL.md
+++ b/unrealinsights/agent-harness/cli_anything/unrealinsights/skills/SKILL.md
@@ -62,6 +62,22 @@ cli-anything-unrealinsights --json capture run `
--target-arg 'D:\Projects\MyGame\MyGame.uproject'
```
+### Continuous capture session control
+
+```powershell
+cli-anything-unrealinsights --json capture start `
+ --project 'D:\Projects\MyGame\MyGame.uproject' `
+ --engine-root 'D:\Program Files\Epic Games\UE_5.5' `
+ --output-trace D:\captures\live_session.utrace
+
+cli-anything-unrealinsights --json capture status
+cli-anything-unrealinsights --json capture snapshot D:\captures\live_snapshot.utrace
+cli-anything-unrealinsights --json capture stop
+```
+
+This is the preferred flow when an agent needs to start profiling now and stop
+or snapshot later in a follow-up turn.
+
### Offline exporters
```powershell
@@ -94,9 +110,19 @@ cli-anything-unrealinsights --json -t D:\captures\session.utrace batch run-rsp D
- `trace_exists`
- `trace_size`
- `pid` or `exit_code`
+- Continuous capture status returns:
+ - `pid`
+ - `running`
+ - `target_exe`
+ - `project_path`
+ - `trace_path`
+ - `trace_size`
+ - `started_at`
## Notes
- v1 is Windows-first.
- v1 supports file-mode capture orchestration only.
- v1 does not control already-running UE instances or browse trace stores.
+- `capture stop` is a best-effort stop of the harness-launched process tree.
+- `capture snapshot` is a best-effort filesystem snapshot of the active trace.
diff --git a/unrealinsights/agent-harness/cli_anything/unrealinsights/tests/TEST.md b/unrealinsights/agent-harness/cli_anything/unrealinsights/tests/TEST.md
index f9492a3ee..5220c5b5e 100644
--- a/unrealinsights/agent-harness/cli_anything/unrealinsights/tests/TEST.md
+++ b/unrealinsights/agent-harness/cli_anything/unrealinsights/tests/TEST.md
@@ -2,7 +2,7 @@
## Test Inventory Plan
-- `test_core.py`: 39 unit tests planned
+- `test_core.py`: 46 unit tests planned
- `test_full_e2e.py`: 10 E2E tests planned
## Unit Test Plan
@@ -19,7 +19,8 @@
- Validate traced target command construction
- Validate `-ExecCmds=` joining semantics
- Validate `--project + --engine-root` convenience resolution
-- Planned tests: 8
+- Validate tracked capture status, snapshot, and stop flows
+- Planned tests: 11
### `core/export.py`
- Validate exporter command strings for all supported exporters
@@ -33,7 +34,8 @@
- Validate JSON error payloads when trace/backend requirements are missing
- Validate REPL session trace state
- Validate capture convenience-layer argument handling
-- Planned tests: 7
+- Validate `capture status`, `capture stop`, and `capture snapshot` JSON flows
+- Planned tests: 10
## E2E Test Plan
@@ -92,7 +94,7 @@ cli-anything-unrealinsights --json backend info
### Result summary
-- `test_core.py`: 39 passed
+- `test_core.py`: 46 passed
- `test_full_e2e.py`: 1 passed, 9 skipped
- Manual smoke: installed entrypoint resolved local UE 5.5 binaries successfully
@@ -111,7 +113,7 @@ cli-anything-unrealinsights --json backend info
platform win32 -- Python 3.11.9, pytest-9.0.3, pluggy-1.6.0 -- C:\Users\aimidi\AppData\Local\Programs\Python\Python311\python.exe
cachedir: .pytest_cache
rootdir: D:\code\D5\CLI-Anything-unrealinsights\unrealinsights\agent-harness
-collecting ... collected 39 items
+collecting ... collected 46 items
cli_anything/unrealinsights/tests/test_core.py::TestOutputUtils::test_output_json PASSED [ 3%]
cli_anything/unrealinsights/tests/test_core.py::TestOutputUtils::test_output_table_empty PASSED [ 7%]
@@ -146,7 +148,7 @@ cli_anything/unrealinsights/tests/test_core.py::TestCLIJsonErrors::test_capture_
cli_anything/unrealinsights/tests/test_core.py::TestREPLSessionState::test_trace_set_then_info_in_repl PASSED [ 99%]
cli_anything/unrealinsights/tests/test_core.py::TestCaptureCLIConvenience::test_capture_run_with_project_and_engine_root PASSED [100%]
-============================= 39 passed in 0.52s ==============================
+============================= 46 passed in 0.28s ==============================
============================= test session starts =============================
platform win32 -- Python 3.11.9, pytest-9.0.3, pluggy-1.6.0 -- C:\Users\aimidi\AppData\Local\Programs\Python\Python311\python.exe
diff --git a/unrealinsights/agent-harness/cli_anything/unrealinsights/tests/test_core.py b/unrealinsights/agent-harness/cli_anything/unrealinsights/tests/test_core.py
index 061a13746..8e1e2683e 100644
--- a/unrealinsights/agent-harness/cli_anything/unrealinsights/tests/test_core.py
+++ b/unrealinsights/agent-harness/cli_anything/unrealinsights/tests/test_core.py
@@ -12,6 +12,11 @@ import pytest
from click.testing import CliRunner
+@pytest.fixture(autouse=True)
+def _session_state_dir(tmp_path, monkeypatch):
+ monkeypatch.setenv("CLI_ANYTHING_UNREALINSIGHTS_STATE_DIR", str(tmp_path / "state"))
+
+
class TestOutputUtils:
def test_output_json(self):
import io
@@ -260,6 +265,58 @@ class TestCaptureCore:
assert any(arg.startswith("-tracefile=") for arg in command)
assert any(arg.startswith("-ExecCmds=") for arg in command)
+ def test_capture_status(self):
+ from cli_anything.unrealinsights.core.capture import capture_status
+ from cli_anything.unrealinsights.core.session import UnrealInsightsSession
+
+ session = UnrealInsightsSession()
+ session.set_capture(
+ pid=1234,
+ target_exe="C:/UE/UnrealEditor.exe",
+ target_args=["Project.uproject"],
+ trace_path="C:/trace.utrace",
+ channels="default",
+ )
+ with patch("cli_anything.unrealinsights.core.capture.backend.is_process_running", return_value=True):
+ data = capture_status(session)
+ assert data["active"] is True
+ assert data["running"] is True
+
+ def test_snapshot_capture(self, tmp_path):
+ from cli_anything.unrealinsights.core.capture import snapshot_capture
+ from cli_anything.unrealinsights.core.session import UnrealInsightsSession
+
+ trace = tmp_path / "live.utrace"
+ trace.write_text("trace-data", encoding="utf-8")
+ session = UnrealInsightsSession()
+ session.set_capture(
+ pid=4321,
+ target_exe="C:/UE/UnrealEditor.exe",
+ target_args=[],
+ trace_path=str(trace),
+ channels="default",
+ )
+ with patch("cli_anything.unrealinsights.core.capture.backend.is_process_running", return_value=True):
+ result = snapshot_capture(session)
+ assert Path(result["snapshot_trace"]).is_file()
+
+ def test_stop_capture(self):
+ from cli_anything.unrealinsights.core.capture import stop_capture
+ from cli_anything.unrealinsights.core.session import UnrealInsightsSession
+
+ session = UnrealInsightsSession()
+ session.set_capture(
+ pid=9876,
+ target_exe="C:/UE/UnrealEditor.exe",
+ target_args=[],
+ trace_path="C:/trace.utrace",
+ channels="default",
+ )
+ with patch("cli_anything.unrealinsights.core.capture.backend.terminate_process", return_value={"requested_pid": 9876, "stopped": True, "exit_code": 0}), \
+ patch("cli_anything.unrealinsights.core.capture.backend.is_process_running", return_value=False):
+ result = stop_capture(session)
+ assert result["termination"]["stopped"] is True
+
class TestExportCore:
@pytest.mark.parametrize(
@@ -429,6 +486,64 @@ class TestCLIJsonErrors:
data = json.loads(result.output)
assert data["insights"]["path"].endswith("UnrealInsights.exe")
+ @patch("cli_anything.unrealinsights.unrealinsights_cli.capture_status")
+ def test_capture_status_json(self, mock_capture_status):
+ from cli_anything.unrealinsights.unrealinsights_cli import cli
+
+ mock_capture_status.return_value = {
+ "active": True,
+ "pid": 1234,
+ "running": True,
+ "target_exe": "C:/UE/UnrealEditor.exe",
+ "target_args": [],
+ "project_path": "C:/Project.uproject",
+ "engine_root": "C:/UE_5.3",
+ "trace_path": "C:/trace.utrace",
+ "trace_exists": True,
+ "trace_size": 1024,
+ "channels": "default",
+ "started_at": "2026-04-16T00:00:00+00:00",
+ }
+
+ runner = CliRunner()
+ result = runner.invoke(cli, ["--json", "capture", "status"])
+ assert result.exit_code == 0
+ data = json.loads(result.output)
+ assert data["running"] is True
+
+ @patch("cli_anything.unrealinsights.unrealinsights_cli.stop_capture")
+ def test_capture_stop_json(self, mock_stop_capture):
+ from cli_anything.unrealinsights.unrealinsights_cli import cli
+
+ mock_stop_capture.return_value = {
+ "termination": {"requested_pid": 1234, "stopped": True, "exit_code": 0},
+ "capture": {"active": False},
+ }
+
+ runner = CliRunner()
+ result = runner.invoke(cli, ["--json", "capture", "stop"])
+ assert result.exit_code == 0
+ data = json.loads(result.output)
+ assert data["termination"]["stopped"] is True
+
+ @patch("cli_anything.unrealinsights.unrealinsights_cli.snapshot_capture")
+ def test_capture_snapshot_json(self, mock_snapshot_capture):
+ from cli_anything.unrealinsights.unrealinsights_cli import cli
+
+ mock_snapshot_capture.return_value = {
+ "source_trace": "C:/trace.utrace",
+ "snapshot_trace": "C:/trace-snapshot.utrace",
+ "snapshot_exists": True,
+ "snapshot_size": 2048,
+ "capture_running": True,
+ }
+
+ runner = CliRunner()
+ result = runner.invoke(cli, ["--json", "capture", "snapshot"])
+ assert result.exit_code == 0
+ data = json.loads(result.output)
+ assert data["snapshot_exists"] is True
+
class TestREPLSessionState:
def test_trace_set_then_info_in_repl(self):
@@ -496,3 +611,51 @@ class TestCaptureCLIConvenience:
mock_run_capture.assert_called_once()
_, kwargs = mock_run_capture.call_args
assert kwargs["target_args"][0] == str(project.resolve())
+
+ @patch("cli_anything.unrealinsights.unrealinsights_cli.run_capture")
+ def test_capture_start_persists_background_session(self, mock_run_capture, tmp_path):
+ from cli_anything.unrealinsights.unrealinsights_cli import cli
+ from cli_anything.unrealinsights.core.session import UnrealInsightsSession
+
+ editor = tmp_path / "UE_5.5" / "Engine" / "Binaries" / "Win64" / "UnrealEditor.exe"
+ editor.parent.mkdir(parents=True)
+ editor.write_text("x", encoding="utf-8")
+ project = tmp_path / "Project" / "MyGame.uproject"
+ project.parent.mkdir(parents=True)
+ project.write_text("{}", encoding="utf-8")
+
+ mock_run_capture.return_value = {
+ "command": [str(editor.resolve()), str(project.resolve()), "-trace=default"],
+ "waited": False,
+ "timed_out": False,
+ "exit_code": None,
+ "stdout": None,
+ "stderr": None,
+ "pid": 2468,
+ "target_exe": str(editor.resolve()),
+ "target_args": [str(project.resolve())],
+ "trace_path": str((tmp_path / "capture.utrace").resolve()),
+ "channels": "default",
+ "trace_exists": False,
+ "trace_size": None,
+ "succeeded": True,
+ }
+
+ runner = CliRunner()
+ result = runner.invoke(
+ cli,
+ [
+ "--json",
+ "capture",
+ "start",
+ "--project",
+ str(project),
+ "--engine-root",
+ str(tmp_path / "UE_5.5"),
+ "--output-trace",
+ str(tmp_path / "capture.utrace"),
+ ],
+ )
+ assert result.exit_code == 0
+ session = UnrealInsightsSession.load()
+ assert session.capture_pid == 2468
diff --git a/unrealinsights/agent-harness/cli_anything/unrealinsights/tests/test_full_e2e.py b/unrealinsights/agent-harness/cli_anything/unrealinsights/tests/test_full_e2e.py
index de2cb5c9c..52b61e4e1 100644
--- a/unrealinsights/agent-harness/cli_anything/unrealinsights/tests/test_full_e2e.py
+++ b/unrealinsights/agent-harness/cli_anything/unrealinsights/tests/test_full_e2e.py
@@ -47,6 +47,7 @@ def _cli_env():
env = os.environ.copy()
pythonpath = env.get("PYTHONPATH", "")
env["PYTHONPATH"] = HARNESS_ROOT if not pythonpath else f"{HARNESS_ROOT}{os.pathsep}{pythonpath}"
+ env["CLI_ANYTHING_UNREALINSIGHTS_STATE_DIR"] = os.path.join(HARNESS_ROOT, ".tmp_state")
return env
diff --git a/unrealinsights/agent-harness/cli_anything/unrealinsights/unrealinsights_cli.py b/unrealinsights/agent-harness/cli_anything/unrealinsights/unrealinsights_cli.py
index 38494e7de..c8b1a50a1 100644
--- a/unrealinsights/agent-harness/cli_anything/unrealinsights/unrealinsights_cli.py
+++ b/unrealinsights/agent-harness/cli_anything/unrealinsights/unrealinsights_cli.py
@@ -14,9 +14,12 @@ import click
from cli_anything.unrealinsights import __version__
from cli_anything.unrealinsights.core.capture import (
DEFAULT_CHANNELS,
+ capture_status,
normalize_trace_output_path,
resolve_capture_target,
run_capture,
+ snapshot_capture,
+ stop_capture,
)
from cli_anything.unrealinsights.core.export import execute_export, execute_response_file
from cli_anything.unrealinsights.core.session import UnrealInsightsSession
@@ -35,7 +38,7 @@ def _get_session(ctx: click.Context) -> UnrealInsightsSession:
ctx.ensure_object(dict)
session = ctx.obj.get("session")
if session is None:
- session = UnrealInsightsSession()
+ session = UnrealInsightsSession.load()
ctx.obj["session"] = session
return session
@@ -154,6 +157,41 @@ def _human_capture_result(data: dict[str, object]):
click.echo(f"PID: {data['pid']}")
+def _human_capture_status(data: dict[str, object]):
+ if not data.get("active"):
+ click.echo("No tracked capture session.")
+ return
+ click.echo(f"PID: {data.get('pid')}")
+ click.echo(f"Running: {'yes' if data.get('running') else 'no'}")
+ click.echo(f"Target exe: {data.get('target_exe')}")
+ if data.get("project_path"):
+ click.echo(f"Project: {data['project_path']}")
+ if data.get("engine_root"):
+ click.echo(f"Engine root: {data['engine_root']}")
+ click.echo(f"Trace: {data.get('trace_path')}")
+ click.echo(f"Trace exists: {'yes' if data.get('trace_exists') else 'no'}")
+ if data.get("trace_exists"):
+ click.echo(f"Trace size: {format_size(data.get('trace_size'))}")
+ if data.get("started_at"):
+ click.echo(f"Started at: {data['started_at']}")
+
+
+def _human_snapshot_result(data: dict[str, object]):
+ click.echo(f"Source trace: {data['source_trace']}")
+ click.echo(f"Snapshot trace: {data['snapshot_trace']}")
+ click.echo(f"Exists: {'yes' if data['snapshot_exists'] else 'no'}")
+ if data.get("snapshot_exists"):
+ click.echo(f"Size: {format_size(data.get('snapshot_size'))}")
+ click.echo(f"Capture running:{' yes' if data.get('capture_running') else ' no'}")
+
+
+def _human_stop_result(data: dict[str, object]):
+ termination = data["termination"]
+ click.echo(f"Requested PID: {termination['requested_pid']}")
+ click.echo(f"Stopped: {'yes' if termination['stopped'] else 'no'}")
+ click.echo(f"Exit code: {termination.get('exit_code')}")
+
+
@click.group(invoke_without_command=True)
@click.option("--json", "json_mode", is_flag=True, help="Output in JSON format.")
@click.option("--debug", is_flag=True, help="Show debug tracebacks on errors.")
@@ -315,11 +353,89 @@ def capture_run(ctx, target_exe, project, engine_root, target_args, output_trace
)
data.update(launch_info)
session.set_trace(resolved_output)
+ if wait:
+ session.clear_capture()
+ else:
+ session.set_capture(
+ pid=data.get("pid"),
+ target_exe=resolved_target_exe,
+ target_args=resolved_target_args,
+ trace_path=resolved_output,
+ channels=channels,
+ project_path=launch_info.get("project_path"),
+ engine_root=launch_info.get("engine_root"),
+ )
_output(ctx, data, _human_capture_result)
except Exception as exc:
_handle_exc(ctx, exc)
+@capture_group.command("start")
+@click.argument("target_exe", required=False, type=click.Path(exists=False))
+@click.option("--project", type=click.Path(exists=False), default=None, help="Path to a .uproject file.")
+@click.option(
+ "--engine-root",
+ type=click.Path(exists=False),
+ default=None,
+ help="UE install root such as D:\\Program Files\\Epic Games\\UE_5.5 or its Engine subdir.",
+)
+@click.option("--target-arg", "target_args", multiple=True, help="Argument to pass to the target executable.")
+@click.option("--output-trace", type=click.Path(exists=False), default=None, help="Output .utrace path.")
+@click.option("--channels", default=DEFAULT_CHANNELS, show_default=True, help="Comma-separated UE trace channels.")
+@click.option("--exec-cmd", "exec_cmds", multiple=True, help="Startup UE console command for -ExecCmds.")
+@click.pass_context
+def capture_start(ctx, target_exe, project, engine_root, target_args, output_trace, channels, exec_cmds):
+ """Launch a traced target in the background and track the session."""
+ ctx.invoke(
+ capture_run,
+ target_exe=target_exe,
+ project=project,
+ engine_root=engine_root,
+ target_args=target_args,
+ output_trace=output_trace,
+ channels=channels,
+ exec_cmds=exec_cmds,
+ wait=False,
+ timeout=None,
+ )
+
+
+@capture_group.command("status")
+@click.pass_context
+def capture_status_cmd(ctx):
+ """Show the tracked background capture status."""
+ try:
+ data = capture_status(_get_session(ctx))
+ _output(ctx, data, _human_capture_status)
+ except Exception as exc:
+ _handle_exc(ctx, exc)
+
+
+@capture_group.command("stop")
+@click.option("--force", is_flag=True, help="Force terminate the process tree.")
+@click.option("--timeout", type=float, default=None, help="Optional stop timeout in seconds.")
+@click.pass_context
+def capture_stop_cmd(ctx, force, timeout):
+ """Stop the tracked capture process."""
+ try:
+ data = stop_capture(_get_session(ctx), force=force, timeout=timeout)
+ _output(ctx, data, _human_stop_result)
+ except Exception as exc:
+ _handle_exc(ctx, exc)
+
+
+@capture_group.command("snapshot")
+@click.argument("output_trace", required=False, type=click.Path(exists=False))
+@click.pass_context
+def capture_snapshot_cmd(ctx, output_trace):
+ """Create a best-effort snapshot copy of the current trace."""
+ try:
+ data = snapshot_capture(_get_session(ctx), output_trace=output_trace)
+ _output(ctx, data, _human_snapshot_result)
+ except Exception as exc:
+ _handle_exc(ctx, exc)
+
+
@cli.group("export")
def export_group():
"""Offline Unreal Insights exporters."""
@@ -528,9 +644,9 @@ def repl(ctx):
pt_session = skin.create_prompt_session()
repl_commands = {
- "backend": "info",
+ "backend": "info|ensure-insights",
"trace": "set|info",
- "capture": "run",
+ "capture": "run|start|status|stop|snapshot",
"export": "threads|timers|timing-events|timer-stats|timer-callees|counters|counter-values",
"batch": "run-rsp",
"help": "Show this help",
diff --git a/unrealinsights/agent-harness/cli_anything/unrealinsights/utils/unrealinsights_backend.py b/unrealinsights/agent-harness/cli_anything/unrealinsights/utils/unrealinsights_backend.py
index 042f7b277..ede607e2b 100644
--- a/unrealinsights/agent-harness/cli_anything/unrealinsights/utils/unrealinsights_backend.py
+++ b/unrealinsights/agent-harness/cli_anything/unrealinsights/utils/unrealinsights_backend.py
@@ -6,9 +6,11 @@ from __future__ import annotations
import os
import re
+import signal
import subprocess
-from pathlib import Path
from datetime import datetime
+from pathlib import Path
+import time
from typing import Iterable
INSIGHTS_BINARY_NAME = "UnrealInsights.exe"
@@ -418,6 +420,73 @@ def run_process(command: list[str] | str, timeout: float | None = None, wait: bo
}
+def is_process_running(pid: int | None) -> bool:
+ """Check whether a process is still running."""
+ if not pid or pid <= 0:
+ return False
+
+ if os.name == "nt":
+ result = subprocess.run(
+ ["tasklist", "/FI", f"PID eq {pid}", "/FO", "CSV", "/NH"],
+ capture_output=True,
+ text=True,
+ timeout=10,
+ check=False,
+ )
+ stdout = result.stdout.strip()
+ return bool(stdout) and "No tasks are running" not in stdout and "INFO:" not in stdout
+
+ try:
+ os.kill(pid, 0)
+ return True
+ except OSError:
+ return False
+
+
+def terminate_process(pid: int, force: bool = False, timeout: float | None = None) -> dict[str, object]:
+ """Terminate a process tree and report whether it stopped."""
+ if pid <= 0:
+ raise RuntimeError(f"Invalid PID: {pid}")
+
+ if os.name == "nt":
+ command = ["taskkill", "/PID", str(pid), "/T"]
+ if force:
+ command.append("/F")
+ result = run_process(command, timeout=timeout, wait=True)
+ else:
+ sig = signal.SIGKILL if force else signal.SIGTERM
+ try:
+ os.kill(pid, sig)
+ result = {
+ "command": ["kill", str(sig), str(pid)],
+ "waited": True,
+ "timed_out": False,
+ "exit_code": 0,
+ "stdout": "",
+ "stderr": "",
+ "pid": None,
+ }
+ except OSError as exc:
+ result = {
+ "command": ["kill", str(sig), str(pid)],
+ "waited": True,
+ "timed_out": False,
+ "exit_code": 1,
+ "stdout": "",
+ "stderr": str(exc),
+ "pid": None,
+ }
+
+ deadline = time.time() + (timeout or 10)
+ while time.time() < deadline and is_process_running(pid):
+ time.sleep(0.25)
+
+ result["stopped"] = not is_process_running(pid)
+ result["requested_pid"] = pid
+ result["force"] = force
+ return result
+
+
def parse_unreal_log(log_path: str | Path) -> dict[str, object]:
"""Extract warning and error lines from an Unreal log file."""
path = Path(log_path).expanduser().resolve()
From 0f8d4f306a0e0b338fd00ad87a50c0675cb6d91d Mon Sep 17 00:00:00 2001
From: aimidi
Date: Thu, 16 Apr 2026 17:46:35 +0800
Subject: [PATCH 3/7] Move Unreal Insights guide into harness README
---
.gitignore | 2 -
README.md | 4 +-
README_CN.md | 4 +-
unrealinsights/README.md | 127 ------------------
unrealinsights/README_CN.md | 121 -----------------
.../cli_anything/unrealinsights/README.md | 45 +++++++
6 files changed, 47 insertions(+), 256 deletions(-)
delete mode 100644 unrealinsights/README.md
delete mode 100644 unrealinsights/README_CN.md
diff --git a/.gitignore b/.gitignore
index bac27a596..627b253a8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -225,8 +225,6 @@
!/safari/
!/safari/agent-harness/
!/unrealinsights/agent-harness/
-!/unrealinsights/README.md
-!/unrealinsights/README_CN.md
# Step 7: Ignore build artifacts within allowed dirs
**/__pycache__/
diff --git a/README.md b/README.md
index cb6924e3a..5c709baa9 100644
--- a/README.md
+++ b/README.md
@@ -965,7 +965,7 @@ Each application received complete, production-ready CLI interfaces — not demo
✅ 40 |
-| 📈 Unreal Insights |
+📈 Unreal Insights |
Performance Profiling |
cli-anything-unrealinsights |
Unreal trace capture + engine-matched UnrealInsights export |
@@ -1100,8 +1100,6 @@ cli-anything/
├── 🎮 godot/agent-harness/ # Godot Engine CLI (24 tests)
├── 🎨 sketch/agent-harness/ # Sketch CLI (19 tests, Node.js)
├── 🔬 renderdoc/agent-harness/ # RenderDoc CLI (59 tests)
-├── 📈 unrealinsights/README.md # Unreal Insights human guide (English)
-├── 📈 unrealinsights/README_CN.md # Unreal Insights human guide (Chinese)
├── 📈 unrealinsights/agent-harness/ # Unreal Insights CLI (46 tests)
├── 🎬 videocaptioner/agent-harness/ # VideoCaptioner CLI (26 tests)
├── 🎬 openscreen/agent-harness/ # Openscreen CLI — screen recording editor (101 tests)
diff --git a/README_CN.md b/README_CN.md
index b14f13116..d9d1307cc 100644
--- a/README_CN.md
+++ b/README_CN.md
@@ -581,7 +581,7 @@ CLI-Anything 适用于任何有代码库的软件 —— 不限领域,不限
✅ 50 |
-| 📈 Unreal Insights |
+📈 Unreal Insights |
性能分析 |
cli-anything-unrealinsights |
Unreal trace 采集 + 匹配版 UnrealInsights 导出 |
@@ -699,8 +699,6 @@ cli-anything/
├── 📞 zoom/agent-harness/ # Zoom CLI(22 项测试)
├── 📐 drawio/agent-harness/ # Draw.io CLI(138 项测试)
├── ✨ anygen/agent-harness/ # AnyGen CLI(50 项测试)
-├── 📈 unrealinsights/README.md # Unreal Insights 人类说明(英文)
-├── 📈 unrealinsights/README_CN.md # Unreal Insights 人类说明(中文)
├── 📈 unrealinsights/agent-harness/ # Unreal Insights CLI(46 项测试)
└── 🎨 sketch/agent-harness/ # Sketch CLI(19 项测试,Node.js)
```
diff --git a/unrealinsights/README.md b/unrealinsights/README.md
deleted file mode 100644
index 5764913da..000000000
--- a/unrealinsights/README.md
+++ /dev/null
@@ -1,127 +0,0 @@
-# Unreal Insights Human Guide
-
-This document is for people collaborating with AI, not for Python package maintainers.
-
-`unrealinsights/agent-harness` gives AI agents a reliable way to drive Unreal
-trace capture and Unreal Insights analysis workflows from the terminal.
-
-## What This Capability Can Do
-
-- find or build an `UnrealInsights.exe` matching your current Unreal Engine
-- start a one-shot trace capture
-- start a background capture and let it run until you decide to stop
-- inspect the current background capture status
-- create a best-effort snapshot copy of the active trace
-- stop the tracked capture session
-- export an existing `.utrace` into:
- - `threads`
- - `timers`
- - `timing-events`
- - `timer-stats`
- - `timer-callees`
- - `counters`
- - `counter-values`
-- read exported CSV files and summarize hotspots, waits, and likely bottlenecks
-
-## How Humans Should Ask AI To Use It
-
-The most effective prompt is not “analyze performance,” but a prompt that gives:
-
-1. engine path
-2. project path or target executable
-3. whether you care about startup or runtime behavior
-4. what output you want back
-
-## Good Prompt Patterns
-
-### Startup performance
-
-```text
-Use D:\code\D5\d5render-ue5_3 to analyze startup performance for
-D:\code\D5\FusionEffectBuild.
-First ensure a matching UnrealInsights.exe exists, then capture a startup trace,
-export timer-stats, and summarize the top 20 hotspots.
-```
-
-### Long-running capture until I say stop
-
-```text
-Use D:\code\D5\d5render-ue5_3 and D:\code\D5\FusionEffectBuild
-to start a background performance capture.
-Do not block and do not exit the project immediately.
-Tell me the trace path and current status first.
-When I say "stop", stop the capture, make a snapshot, export timer-stats and
-timing-events, then summarize the results.
-```
-
-### Runtime scene analysis
-
-```text
-Start a background trace for this project, let me interact with the scene
-manually, and wait.
-When I say "stop now", export timer-stats and timing-events and focus on
-GameThread, RenderThread, and task-system waits.
-```
-
-### Source-built engine without UnrealInsights.exe
-
-```text
-This is a source-built engine.
-First find or build UnrealInsights.exe under D:\code\D5\d5render-ue5_3,
-then use it to analyze the trace.
-```
-
-## Keywords AI Handles Well
-
-These phrases are especially useful:
-
-- “ensure matching UnrealInsights”
-- “background continuous capture”
-- “wait until I say stop”
-- “make a snapshot”
-- “export timer-stats”
-- “export timing-events”
-- “summarize top hotspots”
-- “look at GameThread / RenderThread / WaitForTasks”
-
-## What AI Can Usually Do Automatically
-
-If you give enough context, AI can usually:
-
-- resolve the `.uproject`
-- infer `UnrealEditor.exe`
-- choose or build a matching `UnrealInsights.exe`
-- generate `.utrace`
-- export CSV reports
-- extract hotspots from CSV
-- tell you whether the trace looks more like:
- - startup waiting
- - task-scheduler blocking
- - GameThread stalls
- - RenderThread / render-pass pressure
- - audio or streaming side effects
-
-## Current Scope
-
-The current version supports:
-
-- `capture run`
-- `capture start`
-- `capture status`
-- `capture stop`
-- `capture snapshot`
-- `backend ensure-insights`
-
-It is not full Unreal GUI automation:
-
-- it does not drive an already-open Unreal Insights GUI panel directly
-- it does not fully manage live SessionFrontend / TraceStore UI workflows
-- `capture stop` is a best-effort stop of the harness-launched process tree, not
- full SessionServices remote `Trace.Stop`
-- `capture snapshot` is a best-effort filesystem snapshot of the active trace,
- not a full live trace-store control path
-
-## One-Sentence Rule
-
-Give AI the path, the scene, the timing, and the desired output, and it will be
-able to use this capability much more effectively.
diff --git a/unrealinsights/README_CN.md b/unrealinsights/README_CN.md
deleted file mode 100644
index 0fa3c3cb5..000000000
--- a/unrealinsights/README_CN.md
+++ /dev/null
@@ -1,121 +0,0 @@
-# Unreal Insights 使用说明
-
-这份文档是给正在和 AI 协作的人看的,不是给 Python 包维护者看的。
-
-`unrealinsights/agent-harness` 提供的是一套 AI 可调用的 Unreal 性能采集与分析能力,让 Agent 能比较稳定地驱动 Unreal trace capture 和 Unreal Insights 导出流程。
-
-## 这个能力能做什么
-
-- 找到或构建与你当前 Unreal Engine 匹配的 `UnrealInsights.exe`
-- 启动一次性 trace 采集
-- 启动后台持续采集,并在你指定时机停止
-- 查看当前后台采集状态
-- 对当前活跃 trace 做 best-effort 快照
-- 停止当前追踪会话
-- 对已有 `.utrace` 导出:
- - `threads`
- - `timers`
- - `timing-events`
- - `timer-stats`
- - `timer-callees`
- - `counters`
- - `counter-values`
-- 读取导出的 CSV,并总结热点、等待链和可能瓶颈
-
-## 人类应该怎么调 AI
-
-最有效的方式不是说“帮我分析性能”,而是补齐下面四类信息:
-
-1. 引擎路径
-2. 项目路径或目标可执行文件
-3. 你关心的是“启动阶段”还是“运行时场景”
-4. 你希望 AI 最终给你什么输出
-
-## 推荐提法
-
-### 只分析启动性能
-
-```text
-用 D:\code\D5\d5render-ue5_3 这套引擎,
-分析 D:\code\D5\FusionEffectBuild 这个项目的启动性能。
-先确保匹配版 UnrealInsights 可用,再抓一份启动 trace,
-导出 timer-stats,并总结前 20 个热点。
-```
-
-### 持续采集,等我说停
-
-```text
-用 D:\code\D5\d5render-ue5_3 和 D:\code\D5\FusionEffectBuild
-启动一份后台性能采集,不要阻塞,不要立刻退出项目。
-先告诉我 trace 输出路径和当前状态。
-等我说“停”时,再停止采集、做一次 snapshot,
-导出 timer-stats 和 timing-events。
-```
-
-### 指定场景分析运行时性能
-
-```text
-先启动后台 trace,让我在项目里手动操作某个场景。
-我说“现在停”之后,你帮我导出 timer-stats、timing-events,
-重点看 GameThread、RenderThread 和任务系统等待时间。
-```
-
-### 自定义引擎没有 Insights 可执行文件
-
-```text
-这套引擎是源码版,
-先帮我在 D:\code\D5\d5render-ue5_3 下面找或构建 UnrealInsights.exe,
-然后再用它分析 trace。
-```
-
-## AI 最容易接住的关键词
-
-下面这些关键词很适合直接写进需求:
-
-- “确保匹配版 UnrealInsights”
-- “后台持续采集”
-- “等我说停”
-- “做一次 snapshot”
-- “导出 timer-stats”
-- “导出 timing-events”
-- “总结 top hotspots”
-- “看 GameThread / RenderThread / WaitForTasks”
-
-## 你可以期待 AI 自动做的事
-
-如果描述足够完整,AI 通常会自动:
-
-- 解析 `.uproject`
-- 推导 `UnrealEditor.exe`
-- 选择或构建匹配版 `UnrealInsights.exe`
-- 生成 `.utrace`
-- 导出 CSV
-- 从 CSV 中提取热点
-- 告诉你这份 trace 更像是:
- - 启动等待
- - 任务调度阻塞
- - GameThread 卡住
- - RenderThread / 渲染 pass 偏重
- - 音频或 streaming 相关问题
-
-## 当前能力边界
-
-当前版本已经支持:
-
-- `capture run`
-- `capture start`
-- `capture status`
-- `capture stop`
-- `capture snapshot`
-- `backend ensure-insights`
-
-但它还不是完整的 Unreal GUI 自动化:
-
-- 不直接驱动已经打开的 Unreal Insights 图形界面
-- 不完整覆盖 live SessionFrontend / TraceStore 的 GUI 工作流
-- `capture stop` 当前是对 harness 启动的进程树做 best-effort 停止,不是完整的 SessionServices 远程 `Trace.Stop`
-- `capture snapshot` 当前是对活跃 trace 的 best-effort 文件快照,不是完整的 live trace-store 控制路径
-
-## 一句话原则
-
-把“路径 + 场景 + 时机 + 输出物”说清楚,AI 就最容易把这个能力真正调起来。
diff --git a/unrealinsights/agent-harness/cli_anything/unrealinsights/README.md b/unrealinsights/agent-harness/cli_anything/unrealinsights/README.md
index a81449bf1..1153588d4 100644
--- a/unrealinsights/agent-harness/cli_anything/unrealinsights/README.md
+++ b/unrealinsights/agent-harness/cli_anything/unrealinsights/README.md
@@ -171,6 +171,51 @@ Behavior:
- `capture snapshot` creates a best-effort copy of the current `.utrace` without requiring you to end the session first
- `capture stop` performs a best-effort stop of the tracked process tree launched by the harness
+## Human + AI Workflow
+
+When a human is directing an AI agent, the best requests usually specify:
+
+1. engine root
+2. project path or target executable
+3. whether the focus is startup or runtime behavior
+4. the artifact or summary you want back
+
+Example prompts:
+
+```text
+Use D:\code\D5\d5render-ue5_3 to analyze startup performance for
+D:\code\D5\FusionEffectBuild.
+First ensure a matching UnrealInsights.exe exists, then capture a startup trace,
+export timer-stats, and summarize the top 20 hotspots.
+```
+
+```text
+Use D:\code\D5\d5render-ue5_3 and D:\code\D5\FusionEffectBuild
+to start a background performance capture.
+Do not block and do not exit the project immediately.
+Tell me the trace path and current status first.
+When I say "stop", stop the capture, make a snapshot, export timer-stats and
+timing-events, then summarize the results.
+```
+
+```text
+Start a background trace for this project, let me interact with the scene
+manually, and wait.
+When I say "stop now", export timer-stats and timing-events and focus on
+GameThread, RenderThread, and task-system waits.
+```
+
+Useful phrases in prompts:
+
+- `ensure matching UnrealInsights`
+- `background continuous capture`
+- `wait until I say stop`
+- `make a snapshot`
+- `export timer-stats`
+- `export timing-events`
+- `summarize top hotspots`
+- `look at GameThread / RenderThread / WaitForTasks`
+
## Export Filters
`timing-events` and `timer-stats` support:
From cd82c74db4dcc6d4dfdb4fb6ea83812cd252ff22 Mon Sep 17 00:00:00 2001
From: aimidi
Date: Thu, 16 Apr 2026 19:41:41 +0800
Subject: [PATCH 4/7] fix(unrealinsights): harden capture session replacement
---
.../cli_anything/unrealinsights/README.md | 7 +
.../unrealinsights/core/capture.py | 10 +-
.../unrealinsights/skills/SKILL.md | 3 +
.../cli_anything/unrealinsights/tests/TEST.md | 8 +-
.../unrealinsights/tests/test_core.py | 131 ++++++++++++++++++
.../unrealinsights/unrealinsights_cli.py | 48 +++++--
6 files changed, 189 insertions(+), 18 deletions(-)
diff --git a/unrealinsights/agent-harness/cli_anything/unrealinsights/README.md b/unrealinsights/agent-harness/cli_anything/unrealinsights/README.md
index 1153588d4..1539ba0e9 100644
--- a/unrealinsights/agent-harness/cli_anything/unrealinsights/README.md
+++ b/unrealinsights/agent-harness/cli_anything/unrealinsights/README.md
@@ -49,6 +49,12 @@ cli-anything-unrealinsights --json capture start `
--engine-root 'D:\Program Files\Epic Games\UE_5.5' `
--output-trace D:\captures\live_session.utrace
+# If a tracked capture is already running, replace it explicitly
+cli-anything-unrealinsights --json capture start --replace `
+ --project 'D:\Projects\MyGame\MyGame.uproject' `
+ --engine-root 'D:\Program Files\Epic Games\UE_5.5' `
+ --output-trace D:\captures\replacement_session.utrace
+
# Check current capture status
cli-anything-unrealinsights --json capture status
@@ -167,6 +173,7 @@ cli-anything-unrealinsights --json capture stop
Behavior:
- `capture start` launches the target in the background and persists the tracked PID/trace/session metadata
+- `capture start` refuses to overwrite a still-running tracked session unless you pass `--replace`
- `capture status` reads the persisted session state and reports whether the process is still running and how large the trace has grown
- `capture snapshot` creates a best-effort copy of the current `.utrace` without requiring you to end the session first
- `capture stop` performs a best-effort stop of the tracked process tree launched by the harness
diff --git a/unrealinsights/agent-harness/cli_anything/unrealinsights/core/capture.py b/unrealinsights/agent-harness/cli_anything/unrealinsights/core/capture.py
index e873a1bf8..6bfa09704 100644
--- a/unrealinsights/agent-harness/cli_anything/unrealinsights/core/capture.py
+++ b/unrealinsights/agent-harness/cli_anything/unrealinsights/core/capture.py
@@ -155,6 +155,14 @@ def run_capture(
trace_path = Path(output_trace).expanduser().resolve()
trace_exists = trace_path.is_file()
trace_size = trace_path.stat().st_size if trace_exists else None
+ waited = bool(result.get("waited", wait))
+ succeeded = True
+ if waited:
+ succeeded = (
+ not bool(result.get("timed_out"))
+ and result.get("exit_code") == 0
+ and trace_exists
+ )
result.update(
{
@@ -164,7 +172,7 @@ def run_capture(
"channels": channels,
"trace_exists": trace_exists,
"trace_size": trace_size,
- "succeeded": bool(trace_exists) if wait else True,
+ "succeeded": succeeded,
}
)
return result
diff --git a/unrealinsights/agent-harness/cli_anything/unrealinsights/skills/SKILL.md b/unrealinsights/agent-harness/cli_anything/unrealinsights/skills/SKILL.md
index b865a28b8..5986f7444 100644
--- a/unrealinsights/agent-harness/cli_anything/unrealinsights/skills/SKILL.md
+++ b/unrealinsights/agent-harness/cli_anything/unrealinsights/skills/SKILL.md
@@ -78,6 +78,9 @@ cli-anything-unrealinsights --json capture stop
This is the preferred flow when an agent needs to start profiling now and stop
or snapshot later in a follow-up turn.
+If a tracked capture session is still running, `capture start` now requires
+`--replace` so the previous process is stopped before a new one is launched.
+
### Offline exporters
```powershell
diff --git a/unrealinsights/agent-harness/cli_anything/unrealinsights/tests/TEST.md b/unrealinsights/agent-harness/cli_anything/unrealinsights/tests/TEST.md
index 5220c5b5e..6176cb854 100644
--- a/unrealinsights/agent-harness/cli_anything/unrealinsights/tests/TEST.md
+++ b/unrealinsights/agent-harness/cli_anything/unrealinsights/tests/TEST.md
@@ -2,7 +2,7 @@
## Test Inventory Plan
-- `test_core.py`: 46 unit tests planned
+- `test_core.py`: 49 unit tests planned
- `test_full_e2e.py`: 10 E2E tests planned
## Unit Test Plan
@@ -20,7 +20,7 @@
- Validate `-ExecCmds=` joining semantics
- Validate `--project + --engine-root` convenience resolution
- Validate tracked capture status, snapshot, and stop flows
-- Planned tests: 11
+- Planned tests: 12
### `core/export.py`
- Validate exporter command strings for all supported exporters
@@ -35,7 +35,7 @@
- Validate REPL session trace state
- Validate capture convenience-layer argument handling
- Validate `capture status`, `capture stop`, and `capture snapshot` JSON flows
-- Planned tests: 10
+- Planned tests: 12
## E2E Test Plan
@@ -94,7 +94,7 @@ cli-anything-unrealinsights --json backend info
### Result summary
-- `test_core.py`: 46 passed
+- `test_core.py`: 49 passed
- `test_full_e2e.py`: 1 passed, 9 skipped
- Manual smoke: installed entrypoint resolved local UE 5.5 binaries successfully
diff --git a/unrealinsights/agent-harness/cli_anything/unrealinsights/tests/test_core.py b/unrealinsights/agent-harness/cli_anything/unrealinsights/tests/test_core.py
index 8e1e2683e..f10a3aea7 100644
--- a/unrealinsights/agent-harness/cli_anything/unrealinsights/tests/test_core.py
+++ b/unrealinsights/agent-harness/cli_anything/unrealinsights/tests/test_core.py
@@ -265,6 +265,29 @@ class TestCaptureCore:
assert any(arg.startswith("-tracefile=") for arg in command)
assert any(arg.startswith("-ExecCmds=") for arg in command)
+ @patch("cli_anything.unrealinsights.core.capture.backend.run_process")
+ def test_run_capture_wait_requires_clean_exit(self, mock_run_process, tmp_path):
+ from cli_anything.unrealinsights.core.capture import run_capture
+
+ exe = tmp_path / "Game.exe"
+ exe.write_text("x", encoding="utf-8")
+ trace = tmp_path / "capture.utrace"
+ trace.write_text("partial-trace", encoding="utf-8")
+
+ mock_run_process.return_value = {
+ "command": [str(exe.resolve())],
+ "waited": True,
+ "timed_out": False,
+ "exit_code": 1,
+ "stdout": "",
+ "stderr": "boom",
+ "pid": None,
+ }
+
+ result = run_capture(str(exe), str(trace), wait=True)
+ assert result["trace_exists"] is True
+ assert result["succeeded"] is False
+
def test_capture_status(self):
from cli_anything.unrealinsights.core.capture import capture_status
from cli_anything.unrealinsights.core.session import UnrealInsightsSession
@@ -659,3 +682,111 @@ class TestCaptureCLIConvenience:
assert result.exit_code == 0
session = UnrealInsightsSession.load()
assert session.capture_pid == 2468
+
+ @patch("cli_anything.unrealinsights.unrealinsights_cli.capture_status")
+ @patch("cli_anything.unrealinsights.unrealinsights_cli.run_capture")
+ def test_capture_start_refuses_running_session_without_replace(self, mock_run_capture, mock_capture_status):
+ from cli_anything.unrealinsights.unrealinsights_cli import cli
+
+ mock_capture_status.return_value = {
+ "active": True,
+ "pid": 1357,
+ "running": True,
+ "target_exe": "C:/UE/UnrealEditor.exe",
+ "target_args": [],
+ "project_path": "C:/Project.uproject",
+ "engine_root": "C:/UE_5.5",
+ "trace_path": "C:/capture.utrace",
+ "trace_exists": True,
+ "trace_size": 1024,
+ "channels": "default",
+ "started_at": "2026-04-16T00:00:00+00:00",
+ }
+
+ runner = CliRunner()
+ result = runner.invoke(
+ cli,
+ [
+ "--json",
+ "capture",
+ "start",
+ "--project",
+ "C:/Project.uproject",
+ "--engine-root",
+ "C:/UE_5.5",
+ ],
+ )
+ assert result.exit_code == 1
+ data = json.loads(result.output)
+ assert "--replace" in data["error"]
+ mock_run_capture.assert_not_called()
+
+ @patch("cli_anything.unrealinsights.unrealinsights_cli.stop_capture")
+ @patch("cli_anything.unrealinsights.unrealinsights_cli.capture_status")
+ @patch("cli_anything.unrealinsights.unrealinsights_cli.run_capture")
+ def test_capture_start_replace_stops_existing_session(self, mock_run_capture, mock_capture_status, mock_stop_capture, tmp_path):
+ from cli_anything.unrealinsights.unrealinsights_cli import cli
+ from cli_anything.unrealinsights.core.session import UnrealInsightsSession
+
+ editor = tmp_path / "UE_5.5" / "Engine" / "Binaries" / "Win64" / "UnrealEditor.exe"
+ editor.parent.mkdir(parents=True)
+ editor.write_text("x", encoding="utf-8")
+ project = tmp_path / "Project" / "MyGame.uproject"
+ project.parent.mkdir(parents=True)
+ project.write_text("{}", encoding="utf-8")
+
+ mock_capture_status.return_value = {
+ "active": True,
+ "pid": 1357,
+ "running": True,
+ "target_exe": str(editor.resolve()),
+ "target_args": [str(project.resolve())],
+ "project_path": str(project.resolve()),
+ "engine_root": str((tmp_path / "UE_5.5").resolve()),
+ "trace_path": str((tmp_path / "previous.utrace").resolve()),
+ "trace_exists": True,
+ "trace_size": 1024,
+ "channels": "default",
+ "started_at": "2026-04-16T00:00:00+00:00",
+ }
+ mock_stop_capture.return_value = {
+ "termination": {"requested_pid": 1357, "stopped": True, "exit_code": 0},
+ "capture": {"active": False},
+ }
+ mock_run_capture.return_value = {
+ "command": [str(editor.resolve()), str(project.resolve()), "-trace=default"],
+ "waited": False,
+ "timed_out": False,
+ "exit_code": None,
+ "stdout": None,
+ "stderr": None,
+ "pid": 2468,
+ "target_exe": str(editor.resolve()),
+ "target_args": [str(project.resolve())],
+ "trace_path": str((tmp_path / "capture.utrace").resolve()),
+ "channels": "default",
+ "trace_exists": False,
+ "trace_size": None,
+ "succeeded": True,
+ }
+
+ runner = CliRunner()
+ result = runner.invoke(
+ cli,
+ [
+ "--json",
+ "capture",
+ "start",
+ "--replace",
+ "--project",
+ str(project),
+ "--engine-root",
+ str(tmp_path / "UE_5.5"),
+ "--output-trace",
+ str(tmp_path / "capture.utrace"),
+ ],
+ )
+ assert result.exit_code == 0
+ mock_stop_capture.assert_called_once()
+ session = UnrealInsightsSession.load()
+ assert session.capture_pid == 2468
diff --git a/unrealinsights/agent-harness/cli_anything/unrealinsights/unrealinsights_cli.py b/unrealinsights/agent-harness/cli_anything/unrealinsights/unrealinsights_cli.py
index c8b1a50a1..c5e7d415e 100644
--- a/unrealinsights/agent-harness/cli_anything/unrealinsights/unrealinsights_cli.py
+++ b/unrealinsights/agent-harness/cli_anything/unrealinsights/unrealinsights_cli.py
@@ -370,6 +370,23 @@ def capture_run(ctx, target_exe, project, engine_root, target_args, output_trace
_handle_exc(ctx, exc)
+def _prepare_capture_start(ctx: click.Context, replace: bool):
+ session = _get_session(ctx)
+ status = capture_status(session)
+ if status.get("active") and status.get("running"):
+ if not replace:
+ raise RuntimeError(
+ "A capture session is already running. Use `capture status` to inspect it, "
+ "`capture stop` to end it, or rerun `capture start` with `--replace`."
+ )
+
+ stop_result = stop_capture(session)
+ if not stop_result.get("termination", {}).get("stopped"):
+ raise RuntimeError("Failed to stop the existing capture session before starting a replacement.")
+ elif status.get("active"):
+ session.clear_capture()
+
+
@capture_group.command("start")
@click.argument("target_exe", required=False, type=click.Path(exists=False))
@click.option("--project", type=click.Path(exists=False), default=None, help="Path to a .uproject file.")
@@ -383,21 +400,26 @@ def capture_run(ctx, target_exe, project, engine_root, target_args, output_trace
@click.option("--output-trace", type=click.Path(exists=False), default=None, help="Output .utrace path.")
@click.option("--channels", default=DEFAULT_CHANNELS, show_default=True, help="Comma-separated UE trace channels.")
@click.option("--exec-cmd", "exec_cmds", multiple=True, help="Startup UE console command for -ExecCmds.")
+@click.option("--replace", is_flag=True, help="Stop the currently tracked capture session before starting a new one.")
@click.pass_context
-def capture_start(ctx, target_exe, project, engine_root, target_args, output_trace, channels, exec_cmds):
+def capture_start(ctx, target_exe, project, engine_root, target_args, output_trace, channels, exec_cmds, replace):
"""Launch a traced target in the background and track the session."""
- ctx.invoke(
- capture_run,
- target_exe=target_exe,
- project=project,
- engine_root=engine_root,
- target_args=target_args,
- output_trace=output_trace,
- channels=channels,
- exec_cmds=exec_cmds,
- wait=False,
- timeout=None,
- )
+ try:
+ _prepare_capture_start(ctx, replace=replace)
+ ctx.invoke(
+ capture_run,
+ target_exe=target_exe,
+ project=project,
+ engine_root=engine_root,
+ target_args=target_args,
+ output_trace=output_trace,
+ channels=channels,
+ exec_cmds=exec_cmds,
+ wait=False,
+ timeout=None,
+ )
+ except Exception as exc:
+ _handle_exc(ctx, exc)
@capture_group.command("status")
From 41d400c5c78b0cfbe27a8afb0912fd0827be1b40 Mon Sep 17 00:00:00 2001
From: aimidi
Date: Sun, 19 Apr 2026 01:10:19 +0800
Subject: [PATCH 5/7] fix: tighten unreal insights e2e discovery
---
README.md | 6 +++---
.../unrealinsights/tests/test_full_e2e.py | 14 +++++++++++---
.../unrealinsights/utils/unrealinsights_backend.py | 5 +++--
3 files changed, 17 insertions(+), 8 deletions(-)
diff --git a/README.md b/README.md
index 8f603666c..109e34adb 100644
--- a/README.md
+++ b/README.md
@@ -15,7 +15,7 @@ CLI-Anything: Bridging the Gap Between AI Agents and the World's Software
-
+
@@ -588,7 +588,7 @@ AI agents are great at reasoning but terrible at using real professional softwar
| 💸 "UI automation breaks constantly" | No screenshots, no clicking, no RPA fragility. Pure command-line reliability with structured interfaces |
| 📊 "Agents need structured data" | Built-in JSON output for seamless agent consumption + human-readable formats for debugging |
| 🔧 "Custom integrations are expensive" | One Claude plugin auto-generates CLIs for ANY codebase through proven 7-phase pipeline |
-| ⚡ "Prototype vs Production gap" | 1,839+ tests with real software validation. Battle-tested across 16 major applications |
+| ⚡ "Prototype vs Production gap" | 2,202+ tests with real software validation. Battle-tested across 16 major applications |
---
@@ -1340,7 +1340,7 @@ MIT License — free to use, modify, and distribute.
**CLI-Anything** — *Make any software with a codebase Agent-native.*
-A methodology for the age of AI agents | 16 professional software demos | 1,839 passing tests
+A methodology for the age of AI agents | 16 professional software demos | 2,202 passing tests
diff --git a/unrealinsights/agent-harness/cli_anything/unrealinsights/tests/test_full_e2e.py b/unrealinsights/agent-harness/cli_anything/unrealinsights/tests/test_full_e2e.py
index 52b61e4e1..a852a64d6 100644
--- a/unrealinsights/agent-harness/cli_anything/unrealinsights/tests/test_full_e2e.py
+++ b/unrealinsights/agent-harness/cli_anything/unrealinsights/tests/test_full_e2e.py
@@ -13,15 +13,23 @@ from pathlib import Path
import pytest
+from cli_anything.unrealinsights.utils.unrealinsights_backend import resolve_unrealinsights_exe
+
HARNESS_ROOT = str(Path(__file__).resolve().parents[3])
TEST_TRACE = os.environ.get("UNREALINSIGHTS_TEST_TRACE", "")
TEST_TARGET_EXE = os.environ.get("UNREALINSIGHTS_TEST_TARGET_EXE", "")
+
+def _has_local_insights() -> bool:
+ try:
+ return bool(resolve_unrealinsights_exe(required=False).get("available"))
+ except RuntimeError:
+ return False
+
+
HAS_TRACE = os.path.isfile(TEST_TRACE) if TEST_TRACE else False
HAS_TARGET = os.path.isfile(TEST_TARGET_EXE) if TEST_TARGET_EXE else False
-HAS_LOCAL_INSIGHTS = os.path.isfile(
- r"D:\Program Files\Epic Games\UE_5.5\Engine\Binaries\Win64\UnrealInsights.exe"
-) or bool(os.environ.get("UNREALINSIGHTS_EXE"))
+HAS_LOCAL_INSIGHTS = _has_local_insights()
skip_no_trace = pytest.mark.skipif(not HAS_TRACE, reason="UNREALINSIGHTS_TEST_TRACE not set or missing")
skip_no_target = pytest.mark.skipif(not HAS_TARGET, reason="UNREALINSIGHTS_TEST_TARGET_EXE not set or missing")
diff --git a/unrealinsights/agent-harness/cli_anything/unrealinsights/utils/unrealinsights_backend.py b/unrealinsights/agent-harness/cli_anything/unrealinsights/utils/unrealinsights_backend.py
index ede607e2b..84176af11 100644
--- a/unrealinsights/agent-harness/cli_anything/unrealinsights/utils/unrealinsights_backend.py
+++ b/unrealinsights/agent-harness/cli_anything/unrealinsights/utils/unrealinsights_backend.py
@@ -455,10 +455,11 @@ def terminate_process(pid: int, force: bool = False, timeout: float | None = Non
result = run_process(command, timeout=timeout, wait=True)
else:
sig = signal.SIGKILL if force else signal.SIGTERM
+ signal_arg = f"-{sig.name}"
try:
os.kill(pid, sig)
result = {
- "command": ["kill", str(sig), str(pid)],
+ "command": ["kill", signal_arg, str(pid)],
"waited": True,
"timed_out": False,
"exit_code": 0,
@@ -468,7 +469,7 @@ def terminate_process(pid: int, force: bool = False, timeout: float | None = Non
}
except OSError as exc:
result = {
- "command": ["kill", str(sig), str(pid)],
+ "command": ["kill", signal_arg, str(pid)],
"waited": True,
"timed_out": False,
"exit_code": 1,
From 8b88204f57caff63d648962cd646aa1dc1d78d52 Mon Sep 17 00:00:00 2001
From: aimidi
Date: Sun, 19 Apr 2026 01:40:31 +0800
Subject: [PATCH 6/7] fix: normalize unreal insights export paths
---
.../unrealinsights/core/export.py | 91 ++++++++++++++++---
.../unrealinsights/tests/test_core.py | 27 ++++++
.../unrealinsights/unrealinsights_cli.py | 7 +-
3 files changed, 113 insertions(+), 12 deletions(-)
diff --git a/unrealinsights/agent-harness/cli_anything/unrealinsights/core/export.py b/unrealinsights/agent-harness/cli_anything/unrealinsights/core/export.py
index c587ab5dc..92fd5979a 100644
--- a/unrealinsights/agent-harness/cli_anything/unrealinsights/core/export.py
+++ b/unrealinsights/agent-harness/cli_anything/unrealinsights/core/export.py
@@ -6,7 +6,9 @@ from __future__ import annotations
import ctypes
import os
+import re
import shlex
+import tempfile
from pathlib import Path
from cli_anything.unrealinsights.utils import unrealinsights_backend as backend
@@ -59,6 +61,32 @@ def _legacy_filename_arg(output_path: str) -> str:
return str(Path(short_parent) / path.name)
+def _modern_filename_arg(output_path: str) -> str:
+ """Build a filename token compatible with modern UnrealInsights builds."""
+ path = Path(output_path).expanduser().resolve()
+ path_str = str(path)
+ if os.name != "nt":
+ return _quote(path_str)
+ if " " not in path_str:
+ return path_str
+
+ short_path = _windows_short_path(path)
+ if short_path:
+ return short_path
+
+ raise RuntimeError(
+ "UnrealInsights export requires a path without spaces or a resolvable short path on Windows: "
+ f"{path}"
+ )
+
+
+def _filename_arg(output_path: str, insights_version: str | None = None) -> str:
+ output_abs = str(Path(output_path).expanduser().resolve())
+ if _is_legacy_unrealinsights(insights_version):
+ return _legacy_filename_arg(output_abs)
+ return _modern_filename_arg(output_abs)
+
+
def build_export_exec_command(
exporter: str,
output_path: str,
@@ -77,10 +105,7 @@ def build_export_exec_command(
raise RuntimeError(f"Unsupported exporter: {exporter}")
output_abs = str(Path(output_path).expanduser().resolve())
- if _is_legacy_unrealinsights(insights_version):
- filename_token = _legacy_filename_arg(output_abs)
- else:
- filename_token = _quote(output_abs)
+ filename_token = _filename_arg(output_abs, insights_version=insights_version)
parts = [EXPORTER_COMMANDS[exporter], filename_token]
@@ -107,6 +132,25 @@ def build_rsp_exec_command(rsp_path: str) -> str:
return f"@={Path(rsp_path).expanduser().resolve()}"
+def _normalize_rsp_line(line: str, insights_version: str | None = None) -> tuple[str, str | None]:
+ stripped = line.strip()
+ if not stripped or stripped.startswith("#"):
+ return line, None
+
+ match = re.match(r'^(?P\s*)(?P\S+)\s+(?P