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)! +

Quick Start CLI Hub Demos - Tests + Tests License

@@ -27,7 +29,7 @@ CLI-Anything: Bridging the Gap Between AI Agents and the World's SoftwareWeChat

-**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)

CLI-Anything typing demo @@ -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 SoftwareQuick Start CLI Hub Demos - Tests + Tests License

@@ -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"[^"]*"|\S+)(?P.*)$', line) + if not match: + return line, None + + command = match.group("command") + if command not in EXPORTER_COMMANDS.values(): + return line, None + + output_path = match.group("output").strip('"') + normalized_output = _filename_arg(output_path, insights_version=insights_version) + normalized_line = f"{match.group('indent')}{command} {normalized_output}{match.group('rest')}" + return normalized_line, str(Path(output_path).expanduser().resolve()) + + def _path_contains_placeholders(path: Path) -> bool: return "{counter}" in path.name or "{region}" in path.name @@ -245,15 +289,40 @@ def execute_response_file( trace_path: str, rsp_path: str, *, + insights_version: str | None = None, 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, - ) + lines = Path(rsp_abs).read_text(encoding="utf-8", errors="replace").splitlines() + + normalized_lines: list[str] = [] + expected_outputs: list[str] = [] + for line in lines: + normalized_line, normalized_output = _normalize_rsp_line(line, insights_version=insights_version) + normalized_lines.append(normalized_line) + if normalized_output: + expected_outputs.append(normalized_output) + + if not expected_outputs: + expected_outputs = expected_outputs_from_rsp(rsp_abs) + + temp_rsp_path = None + try: + with tempfile.NamedTemporaryFile("w", suffix=".rsp", delete=False, encoding="utf-8", newline="\n") as handle: + temp_rsp_path = handle.name + handle.write("\n".join(normalized_lines)) + return _execute_insights( + insights_exe, + trace_path, + exec_command=build_rsp_exec_command(temp_rsp_path), + expected_outputs=expected_outputs, + log_path=resolved_log_path, + ) + finally: + if temp_rsp_path: + try: + Path(temp_rsp_path).unlink() + except OSError: + pass 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 f10a3aea7..7501854c2 100644 --- a/unrealinsights/agent-harness/cli_anything/unrealinsights/tests/test_core.py +++ b/unrealinsights/agent-harness/cli_anything/unrealinsights/tests/test_core.py @@ -5,6 +5,7 @@ Unit tests for Unreal Insights harness modules. from __future__ import annotations import json +import os from pathlib import Path from unittest.mock import patch @@ -373,6 +374,19 @@ class TestExportCore: command = build_rsp_exec_command(str(tmp_path / "exports.rsp")) assert command.startswith("@=") + @pytest.mark.skipif(os.name != "nt", reason="Windows quoting behavior") + def test_normalize_rsp_line_modern_windows_avoids_nested_quotes(self, tmp_path): + from cli_anything.unrealinsights.core.export import _normalize_rsp_line + + line, output = _normalize_rsp_line( + f'TimingInsights.ExportThreads "{tmp_path / "threads.csv"}"', + insights_version="5.5.4", + ) + resolved = str((tmp_path / "threads.csv").resolve()) + assert f'"{resolved}"' not in line + assert resolved in line + assert output == resolved + def test_build_export_exec_command_legacy_53_unquoted_filename(self, tmp_path): from cli_anything.unrealinsights.core.export import build_export_exec_command @@ -384,6 +398,19 @@ class TestExportCore: assert '"{}"'.format(str((tmp_path / "threads.csv").resolve())) not in command assert str((tmp_path / "threads.csv").resolve()) in command + @pytest.mark.skipif(os.name != "nt", reason="Windows quoting behavior") + def test_build_export_exec_command_modern_windows_avoids_nested_quotes(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.5.4", + ) + resolved = str((tmp_path / "threads.csv").resolve()) + assert f'"{resolved}"' not in command + assert resolved in command + def test_expected_outputs_from_rsp(self, tmp_path): from cli_anything.unrealinsights.core.export import expected_outputs_from_rsp diff --git a/unrealinsights/agent-harness/cli_anything/unrealinsights/unrealinsights_cli.py b/unrealinsights/agent-harness/cli_anything/unrealinsights/unrealinsights_cli.py index c5e7d415e..909b446d7 100644 --- a/unrealinsights/agent-harness/cli_anything/unrealinsights/unrealinsights_cli.py +++ b/unrealinsights/agent-harness/cli_anything/unrealinsights/unrealinsights_cli.py @@ -645,7 +645,12 @@ def batch_run_rsp(ctx, rsp_path): try: trace_path = _require_trace(ctx) insights = _resolve_insights(ctx) - data = execute_response_file(insights["path"], trace_path, rsp_path) + data = execute_response_file( + insights["path"], + trace_path, + rsp_path, + insights_version=insights.get("version"), + ) _output(ctx, data, _human_export_result) except Exception as exc: _handle_exc(ctx, exc) From e0eec109718455aa9af9eb66eb5398dd55ec9e91 Mon Sep 17 00:00:00 2001 From: yuhao Date: Tue, 21 Apr 2026 06:11:15 +0000 Subject: [PATCH 7/7] fix unreal insights skill mirror and repl history path --- skills/cli-anything-unrealinsights/SKILL.md | 131 ++++++++++++++++++ .../unrealinsights/core/session.py | 4 +- .../unrealinsights/unrealinsights_cli.py | 4 +- 3 files changed, 135 insertions(+), 4 deletions(-) create mode 100644 skills/cli-anything-unrealinsights/SKILL.md diff --git a/skills/cli-anything-unrealinsights/SKILL.md b/skills/cli-anything-unrealinsights/SKILL.md new file mode 100644 index 000000000..5986f7444 --- /dev/null +++ b/skills/cli-anything-unrealinsights/SKILL.md @@ -0,0 +1,131 @@ +--- +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' +``` + +### 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. + +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 +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` +- 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/core/session.py b/unrealinsights/agent-harness/cli_anything/unrealinsights/core/session.py index cf18c2e90..3685835eb 100644 --- a/unrealinsights/agent-harness/cli_anything/unrealinsights/core/session.py +++ b/unrealinsights/agent-harness/cli_anything/unrealinsights/core/session.py @@ -16,7 +16,7 @@ def _normalize_path(path: str | None) -> str | None: return str(Path(path).expanduser().resolve()) if path else None -def _state_dir() -> Path: +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) @@ -24,7 +24,7 @@ def _state_dir() -> Path: def _state_file() -> Path: - return _state_dir() / "session.json" + return state_dir() / "session.json" @dataclass diff --git a/unrealinsights/agent-harness/cli_anything/unrealinsights/unrealinsights_cli.py b/unrealinsights/agent-harness/cli_anything/unrealinsights/unrealinsights_cli.py index 909b446d7..8c1852dbe 100644 --- a/unrealinsights/agent-harness/cli_anything/unrealinsights/unrealinsights_cli.py +++ b/unrealinsights/agent-harness/cli_anything/unrealinsights/unrealinsights_cli.py @@ -22,7 +22,7 @@ from cli_anything.unrealinsights.core.capture import ( stop_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.core.session import UnrealInsightsSession, state_dir 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 ( @@ -666,7 +666,7 @@ def repl(ctx): _repl_mode = True session = _get_session(ctx) - skin = ReplSkin("unrealinsights", version=__version__) + skin = ReplSkin("unrealinsights", version=__version__, history_file=str(state_dir() / "history")) skin.print_banner() pt_session = skin.create_prompt_session()