From 04ad941861e4dc00ac5986b56987afb640f71c40 Mon Sep 17 00:00:00 2001 From: Ryohei Sasaki Date: Sun, 5 Apr 2026 13:59:24 +0900 Subject: [PATCH 1/7] Add CloudAnalyzer agent harness CloudAnalyzer is a QA platform for mapping, localization, and perception point cloud outputs with 32 CLI commands. Unlike other harnesses, the backend imports CloudAnalyzer's Python API directly (no subprocess needed). Includes Click CLI with --json on all commands, project/session management, REPL, SKILL.md, and unit + E2E tests (14 passed). --- .gitignore | 4 + cloudanalyzer/agent-harness/CLOUDANALYZER.md | 48 ++ .../cli_anything/cloudanalyzer/README.md | 58 ++ .../cli_anything/cloudanalyzer/__init__.py | 2 + .../cli_anything/cloudanalyzer/__main__.py | 5 + .../cloudanalyzer/cloudanalyzer_cli.py | 501 +++++++++++++++++ .../cloudanalyzer/core/__init__.py | 0 .../cloudanalyzer/core/project.py | 88 +++ .../cloudanalyzer/core/session.py | 54 ++ .../cloudanalyzer/skills/SKILL.md | 294 ++++++++++ .../cli_anything/cloudanalyzer/tests/TEST.md | 19 + .../cloudanalyzer/tests/__init__.py | 0 .../cloudanalyzer/tests/test_core.py | 84 +++ .../cloudanalyzer/tests/test_full_e2e.py | 75 +++ .../cloudanalyzer/utils/__init__.py | 0 .../cloudanalyzer/utils/ca_backend.py | 130 +++++ .../cloudanalyzer/utils/repl_skin.py | 521 ++++++++++++++++++ cloudanalyzer/agent-harness/setup.py | 35 ++ 18 files changed, 1918 insertions(+) create mode 100644 cloudanalyzer/agent-harness/CLOUDANALYZER.md create mode 100644 cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/README.md create mode 100644 cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/__init__.py create mode 100644 cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/__main__.py create mode 100644 cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/cloudanalyzer_cli.py create mode 100644 cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/core/__init__.py create mode 100644 cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/core/project.py create mode 100644 cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/core/session.py create mode 100644 cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/skills/SKILL.md create mode 100644 cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/tests/TEST.md create mode 100644 cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/tests/__init__.py create mode 100644 cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/tests/test_core.py create mode 100644 cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/tests/test_full_e2e.py create mode 100644 cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/utils/__init__.py create mode 100644 cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/utils/ca_backend.py create mode 100644 cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/utils/repl_skin.py create mode 100644 cloudanalyzer/agent-harness/setup.py diff --git a/.gitignore b/.gitignore index d44f3d223..7293c5377 100644 --- a/.gitignore +++ b/.gitignore @@ -149,6 +149,8 @@ /cloudcompare/.* /openscreen/* /openscreen/.* +/cloudanalyzer/* +/cloudanalyzer/.* /wiremock/* /wiremock/.* /exa/* @@ -187,6 +189,8 @@ !/renderdoc/agent-harness/ !/cloudcompare/agent-harness/ !/openscreen/agent-harness/ +!/cloudanalyzer/ +!/cloudanalyzer/agent-harness/ !/wiremock/ !/wiremock/agent-harness/ !/exa/agent-harness/ diff --git a/cloudanalyzer/agent-harness/CLOUDANALYZER.md b/cloudanalyzer/agent-harness/CLOUDANALYZER.md new file mode 100644 index 000000000..87c85f5b3 --- /dev/null +++ b/cloudanalyzer/agent-harness/CLOUDANALYZER.md @@ -0,0 +1,48 @@ +# CloudAnalyzer CLI Harness — Architecture + +## Overview + +CloudAnalyzer is a **CLI-first** Python package for point cloud QA. Unlike most +CLI-Anything harnesses that bridge a GUI application to the command line, +this harness wraps an existing CLI tool to provide: + +1. Standardized Click interface with `--json` on every command +2. Project/session state management with undo/redo +3. SKILL.md for agent discovery +4. REPL mode + +## Backend Strategy + +Since CloudAnalyzer is a Python package, the backend (`ca_backend.py`) imports +CloudAnalyzer functions directly — no subprocess invocation needed. This makes +the harness faster and more reliable than subprocess-based approaches. + +## Command Mapping + +| Harness Group | CloudAnalyzer Command(s) | +|---|---| +| evaluate run | ca evaluate | +| evaluate compare | ca compare | +| evaluate diff | ca diff | +| evaluate ground | ca ground-evaluate | +| trajectory evaluate | ca traj-evaluate | +| trajectory batch | ca traj-batch | +| check run | ca check | +| check init | ca init-check | +| baseline decision | ca baseline-decision | +| baseline save | ca baseline-save | +| baseline list | ca baseline-list | +| process downsample | ca downsample | +| process split | ca split | +| info show | ca info | + +## State Model + +The project JSON tracks: +- Loaded cloud and trajectory file paths +- QA results from evaluation commands +- Operation history with timestamps +- Session settings + +Operations are recorded automatically and support undo/redo via +the Session class. diff --git a/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/README.md b/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/README.md new file mode 100644 index 000000000..42107ae6b --- /dev/null +++ b/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/README.md @@ -0,0 +1,58 @@ +# cli-anything-cloudanalyzer + +Agent-friendly CLI harness for [CloudAnalyzer](https://github.com/rsasaki0109/CloudAnalyzer) — a QA platform for mapping, localization, and perception point cloud outputs. + +## Quick Start + +```bash +pip install cli-anything-cloudanalyzer + +# Evaluate a point cloud +cli-anything-cloudanalyzer --json evaluate run output.pcd reference.pcd + +# Run config-driven QA +cli-anything-cloudanalyzer --json check run cloudanalyzer.yaml + +# Trajectory evaluation with quality gate +cli-anything-cloudanalyzer --json trajectory evaluate est.csv gt.csv --max-ate 0.5 + +# Ground segmentation QA +cli-anything-cloudanalyzer --json evaluate ground est_g.pcd est_ng.pcd ref_g.pcd ref_ng.pcd --min-f1 0.9 + +# Baseline management +cli-anything-cloudanalyzer baseline save qa/summary.json --history-dir qa/history/ +cli-anything-cloudanalyzer --json baseline decision qa/summary.json --history-dir qa/history/ + +# Interactive REPL +cli-anything-cloudanalyzer +``` + +## Why a Harness? + +CloudAnalyzer is already CLI-first, but this harness adds: + +- **Structured `--json` output** on every command for agent consumption +- **REPL mode** for interactive exploration +- **Project/session management** with operation history and undo +- **SKILL.md** for agent auto-discovery via CLI-Anything ecosystem +- **Unified Click interface** grouping 32 commands into logical groups + +## Commands + +See [SKILL.md](cli_anything/cloudanalyzer/skills/SKILL.md) for the full command reference. + +| Group | Commands | Description | +|---|---:|---| +| evaluate | 6 | Point cloud evaluation (Chamfer, F1, AUC, ground segmentation) | +| trajectory | 4 | Trajectory QA (ATE, RPE, drift, lateral, longitudinal) | +| check | 2 | Config-driven quality gates | +| baseline | 3 | Baseline evolution (promote/keep/reject) | +| process | 6 | Downsample, split, filter, merge, convert | +| inspect | 3 | Visualization and browser inspection | +| info | 2 | Metadata and version | +| session | 3 | Project and session management | + +## Requirements + +- Python 3.10+ +- CloudAnalyzer (`pip install cloudanalyzer`) diff --git a/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/__init__.py b/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/__init__.py new file mode 100644 index 000000000..a1509528b --- /dev/null +++ b/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/__init__.py @@ -0,0 +1,2 @@ +"""cli-anything CloudAnalyzer — CLI harness for CloudAnalyzer point cloud QA platform.""" +__version__ = "1.0.0" diff --git a/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/__main__.py b/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/__main__.py new file mode 100644 index 000000000..ad3b3cd5f --- /dev/null +++ b/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/__main__.py @@ -0,0 +1,5 @@ +"""Allow running as: python3 -m cli_anything.cloudanalyzer""" +from cli_anything.cloudanalyzer.cloudanalyzer_cli import main + +if __name__ == "__main__": + main() diff --git a/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/cloudanalyzer_cli.py b/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/cloudanalyzer_cli.py new file mode 100644 index 000000000..77a5b42d3 --- /dev/null +++ b/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/cloudanalyzer_cli.py @@ -0,0 +1,501 @@ +"""cli-anything-cloudanalyzer — Command-line harness for CloudAnalyzer. + +CloudAnalyzer is a QA platform for mapping, localization, and perception +point cloud outputs. This CLI wraps CloudAnalyzer's Python API with a +structured, agent-friendly interface supporting both one-shot commands +and an interactive REPL. + +Usage: + cli-anything-cloudanalyzer # start REPL + cli-anything-cloudanalyzer --json evaluate run s.pcd r.pcd + cli-anything-cloudanalyzer check run cloudanalyzer.yaml + cli-anything-cloudanalyzer --json baseline decision qa/s.json --history-dir qa/history/ + +Backend: CloudAnalyzer Python package (direct import, no subprocess) +""" + +import json +import sys +from pathlib import Path +from typing import Optional + +import click + +from cli_anything.cloudanalyzer.core.project import ( + create_project, + load_project, + project_info, + save_project, + record_operation, + add_result, +) +from cli_anything.cloudanalyzer.core.session import Session +from cli_anything.cloudanalyzer.utils import ca_backend +from cli_anything.cloudanalyzer.utils.repl_skin import ReplSkin + +VERSION = "1.0.0" + +# ── Output helpers ──────────────────────────────────────────────────────────── + +def _out(ctx: click.Context, data: dict | list) -> None: + """Print data as JSON or human-readable.""" + if ctx.obj and ctx.obj.get("json"): + click.echo(json.dumps(data, indent=2, default=str)) + else: + _pretty(data) + + +def _pretty(data, indent: int = 0) -> None: + prefix = " " * indent + if isinstance(data, dict): + for k, v in data.items(): + if isinstance(v, (dict, list)): + click.echo(f"{prefix}{k}:") + _pretty(v, indent + 1) + else: + click.echo(f"{prefix}{k}: {v}") + elif isinstance(data, list): + for i, item in enumerate(data): + if isinstance(item, dict): + click.echo(f"{prefix}[{i}]") + _pretty(item, indent + 1) + else: + click.echo(f"{prefix} {item}") + else: + click.echo(f"{prefix}{data}") + + +def _error(msg: str, json_mode: bool = False) -> None: + if json_mode: + click.echo(json.dumps({"error": msg}), err=True) + else: + click.echo(f"Error: {msg}", err=True) + + +# ── Root CLI ────────────────────────────────────────────────────────────────── + +@click.group(invoke_without_command=True) +@click.option("-p", "--project", default=None, help="Path to project JSON file") +@click.option("--json", "json_mode", is_flag=True, help="Output as JSON") +@click.version_option(VERSION, prog_name="cli-anything-cloudanalyzer") +@click.pass_context +def cli(ctx: click.Context, project: Optional[str], json_mode: bool) -> None: + """CloudAnalyzer — Agent-friendly QA platform for point cloud outputs.""" + ctx.ensure_object(dict) + ctx.obj["json"] = json_mode + ctx.obj["project"] = project + if ctx.invoked_subcommand is None: + _start_repl(ctx) + + +# ── evaluate group ──────────────────────────────────────────────────────────── + +@cli.group() +@click.pass_context +def evaluate(ctx: click.Context) -> None: + """Point cloud evaluation commands.""" + + +@evaluate.command("run") +@click.argument("source") +@click.argument("reference") +@click.option("--plot", default=None, help="Save F1 curve plot") +@click.option("--threshold", type=float, default=None) +@click.pass_context +def evaluate_run(ctx: click.Context, source: str, reference: str, plot: Optional[str], threshold: Optional[float]) -> None: + """Evaluate a point cloud against a reference.""" + try: + kwargs = {} + if threshold is not None: + kwargs["thresholds"] = [threshold] + result = ca_backend.evaluate(source, reference, **kwargs) + if plot: + result["plot"] = plot + _out(ctx, result) + except Exception as e: + _error(str(e), ctx.obj.get("json", False)) + ctx.exit(1) + + +@evaluate.command("compare") +@click.argument("source") +@click.argument("target") +@click.option("--register", default="gicp", help="Registration method") +@click.pass_context +def evaluate_compare(ctx: click.Context, source: str, target: str, register: str) -> None: + """Compare two point clouds with optional registration.""" + try: + result = ca_backend.compare(source, target, method=register) + _out(ctx, result) + except Exception as e: + _error(str(e), ctx.obj.get("json", False)) + ctx.exit(1) + + +@evaluate.command("diff") +@click.argument("source") +@click.argument("target") +@click.option("--threshold", type=float, default=None) +@click.pass_context +def evaluate_diff(ctx: click.Context, source: str, target: str, threshold: Optional[float]) -> None: + """Quick distance statistics.""" + try: + result = ca_backend.diff(source, target, threshold=threshold) + _out(ctx, result) + except Exception as e: + _error(str(e), ctx.obj.get("json", False)) + ctx.exit(1) + + +@evaluate.command("ground") +@click.argument("estimated_ground") +@click.argument("estimated_nonground") +@click.argument("reference_ground") +@click.argument("reference_nonground") +@click.option("--voxel-size", type=float, default=0.2) +@click.option("--min-precision", type=float, default=None) +@click.option("--min-recall", type=float, default=None) +@click.option("--min-f1", type=float, default=None) +@click.option("--min-iou", type=float, default=None) +@click.pass_context +def evaluate_ground( + ctx: click.Context, + estimated_ground: str, estimated_nonground: str, + reference_ground: str, reference_nonground: str, + voxel_size: float, + min_precision: Optional[float], min_recall: Optional[float], + min_f1: Optional[float], min_iou: Optional[float], +) -> None: + """Evaluate ground segmentation quality.""" + try: + result = ca_backend.evaluate_ground( + estimated_ground, estimated_nonground, + reference_ground, reference_nonground, + voxel_size=voxel_size, + min_precision=min_precision, min_recall=min_recall, + min_f1=min_f1, min_iou=min_iou, + ) + _out(ctx, result) + if result.get("quality_gate") and not result["quality_gate"]["passed"]: + ctx.exit(1) + except Exception as e: + _error(str(e), ctx.obj.get("json", False)) + ctx.exit(1) + + +# ── trajectory group ────────────────────────────────────────────────────────── + +@cli.group() +@click.pass_context +def trajectory(ctx: click.Context) -> None: + """Trajectory evaluation commands.""" + + +@trajectory.command("evaluate") +@click.argument("estimated") +@click.argument("reference") +@click.option("--max-ate", type=float, default=None) +@click.option("--max-rpe", type=float, default=None) +@click.option("--max-drift", type=float, default=None) +@click.option("--min-coverage", type=float, default=None) +@click.option("--max-lateral", type=float, default=None) +@click.option("--max-longitudinal", type=float, default=None) +@click.option("--align-origin", is_flag=True) +@click.option("--align-rigid", is_flag=True) +@click.option("--report", default=None, help="Write trajectory report") +@click.pass_context +def trajectory_evaluate( + ctx: click.Context, estimated: str, reference: str, **kwargs, +) -> None: + """Evaluate estimated vs reference trajectory.""" + try: + result = ca_backend.evaluate_trajectory(estimated, reference, **kwargs) + _out(ctx, result) + gate = result.get("quality_gate") + if gate and not gate["passed"]: + ctx.exit(1) + except Exception as e: + _error(str(e), ctx.obj.get("json", False)) + ctx.exit(1) + + +# ── check group ─────────────────────────────────────────────────────────────── + +@cli.group() +@click.pass_context +def check(ctx: click.Context) -> None: + """Config-driven quality gate commands.""" + + +@check.command("run") +@click.argument("config_path") +@click.option("--output-json", default=None, help="Dump summary JSON") +@click.pass_context +def check_run(ctx: click.Context, config_path: str, output_json: Optional[str]) -> None: + """Run unified QA from a config file.""" + try: + result = ca_backend.run_check_suite(config_path) + if output_json: + Path(output_json).parent.mkdir(parents=True, exist_ok=True) + Path(output_json).write_text(json.dumps(result, indent=2), encoding="utf-8") + _out(ctx, result) + if not result.get("summary", {}).get("passed", True): + ctx.exit(1) + except Exception as e: + _error(str(e), ctx.obj.get("json", False)) + ctx.exit(1) + + +@check.command("init") +@click.argument("destination") +@click.option("--profile", default="integrated", help="Template profile") +@click.option("--force", is_flag=True, help="Overwrite existing file") +@click.pass_context +def check_init(ctx: click.Context, destination: str, profile: str, force: bool) -> None: + """Generate a starter config file.""" + dest = Path(destination) + if dest.exists() and not force: + _error(f"File exists: {dest}. Use --force to overwrite.", ctx.obj.get("json", False)) + ctx.exit(1) + return + try: + template = ca_backend.render_check_scaffold(profile=profile) + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_text(template, encoding="utf-8") + _out(ctx, {"created": str(dest), "profile": profile}) + except Exception as e: + _error(str(e), ctx.obj.get("json", False)) + ctx.exit(1) + + +# ── baseline group ──────────────────────────────────────────────────────────── + +@cli.group() +@click.pass_context +def baseline(ctx: click.Context) -> None: + """Baseline evolution commands.""" + + +@baseline.command("decision") +@click.argument("candidate_json") +@click.option("--history", "history_paths", multiple=True, help="History JSON files") +@click.option("--history-dir", default=None, help="Auto-discover history from directory") +@click.option("--output-json", default=None) +@click.pass_context +def baseline_decision( + ctx: click.Context, candidate_json: str, + history_paths: tuple[str, ...], history_dir: Optional[str], output_json: Optional[str], +) -> None: + """Decide promote / keep / reject for a baseline.""" + try: + paths = list(history_paths) + if history_dir and not paths: + paths = ca_backend.baseline_discover(history_dir) + result = ca_backend.baseline_decision(candidate_json, paths) + if output_json: + Path(output_json).parent.mkdir(parents=True, exist_ok=True) + Path(output_json).write_text(json.dumps(result, indent=2), encoding="utf-8") + _out(ctx, result) + if result.get("decision") == "reject": + ctx.exit(1) + except Exception as e: + _error(str(e), ctx.obj.get("json", False)) + ctx.exit(1) + + +@baseline.command("save") +@click.argument("summary_json") +@click.option("--history-dir", default="qa/history", help="History directory") +@click.option("--label", default=None) +@click.option("--keep", type=int, default=None, help="Rotate to keep N baselines") +@click.pass_context +def baseline_save( + ctx: click.Context, summary_json: str, + history_dir: str, label: Optional[str], keep: Optional[int], +) -> None: + """Save a QA summary to the history directory.""" + try: + dest = ca_backend.baseline_save(summary_json, history_dir, label=label) + data: dict = {"saved": dest} + if keep is not None: + removed = ca_backend.baseline_rotate(history_dir, keep=keep) + data["rotated"] = len(removed) + _out(ctx, data) + except Exception as e: + _error(str(e), ctx.obj.get("json", False)) + ctx.exit(1) + + +@baseline.command("list") +@click.option("--history-dir", default="qa/history", help="History directory") +@click.pass_context +def baseline_list(ctx: click.Context, history_dir: str) -> None: + """List saved baselines.""" + try: + entries = ca_backend.baseline_list(history_dir) + _out(ctx, entries) + except Exception as e: + _error(str(e), ctx.obj.get("json", False)) + ctx.exit(1) + + +# ── process group ───────────────────────────────────────────────────────────── + +@cli.group() +@click.pass_context +def process(ctx: click.Context) -> None: + """Point cloud processing commands.""" + + +@process.command("downsample") +@click.argument("input_path") +@click.option("-o", "--output", required=True, help="Output file path") +@click.option("-v", "--voxel-size", type=float, required=True) +@click.pass_context +def process_downsample(ctx: click.Context, input_path: str, output: str, voxel_size: float) -> None: + """Voxel grid downsampling.""" + try: + result = ca_backend.downsample(input_path, output, voxel_size) + _out(ctx, result) + except Exception as e: + _error(str(e), ctx.obj.get("json", False)) + ctx.exit(1) + + +@process.command("split") +@click.argument("input_path") +@click.option("-o", "--output-dir", required=True) +@click.option("-g", "--grid-size", type=float, required=True) +@click.option("-a", "--axis", default="xy", help="Split axes (xy/xz/yz)") +@click.pass_context +def process_split(ctx: click.Context, input_path: str, output_dir: str, grid_size: float, axis: str) -> None: + """Split point cloud into grid tiles.""" + try: + result = ca_backend.split(input_path, output_dir, grid_size, axis=axis) + _out(ctx, result) + except Exception as e: + _error(str(e), ctx.obj.get("json", False)) + ctx.exit(1) + + +# ── info group ──────────────────────────────────────────────────────────────── + +@cli.group() +@click.pass_context +def info(ctx: click.Context) -> None: + """Metadata commands.""" + + +@info.command("show") +@click.argument("path") +@click.pass_context +def info_show(ctx: click.Context, path: str) -> None: + """Show point cloud metadata.""" + try: + result = ca_backend.info(path) + _out(ctx, result) + except Exception as e: + _error(str(e), ctx.obj.get("json", False)) + ctx.exit(1) + + +@info.command("version") +@click.pass_context +def info_version(ctx: click.Context) -> None: + """Show CloudAnalyzer version.""" + ver = ca_backend.get_version() + _out(ctx, {"cloudanalyzer_version": ver, "harness_version": VERSION}) + + +# ── session group ───────────────────────────────────────────────────────────── + +@cli.group() +@click.pass_context +def session(ctx: click.Context) -> None: + """Session management commands.""" + + +@session.command("new") +@click.option("-o", "--output", required=True, help="Project file path") +@click.option("-n", "--name", default="untitled") +@click.pass_context +def session_new(ctx: click.Context, output: str, name: str) -> None: + """Create a new project file.""" + try: + project = create_project(output, name=name) + _out(ctx, {"created": output, "name": name}) + except Exception as e: + _error(str(e), ctx.obj.get("json", False)) + ctx.exit(1) + + +@session.command("history") +@click.option("-n", "--last", type=int, default=10) +@click.pass_context +def session_history(ctx: click.Context, last: int) -> None: + """Show recent operation history.""" + project_path = ctx.obj.get("project") + if not project_path: + _error("No project specified. Use --project.", ctx.obj.get("json", False)) + ctx.exit(1) + return + try: + sess = Session(project_path) + history = sess.history[-last:] + _out(ctx, history) + except Exception as e: + _error(str(e), ctx.obj.get("json", False)) + ctx.exit(1) + + +# ── REPL ────────────────────────────────────────────────────────────────────── + +def _start_repl(ctx: click.Context) -> None: + """Launch interactive REPL.""" + if not ca_backend.is_available(): + click.echo("Error: CloudAnalyzer is not installed. Run: pip install cloudanalyzer") + ctx.exit(1) + return + + skin = ReplSkin("cloudanalyzer", version=VERSION) + skin.print_banner() + + try: + from prompt_toolkit import PromptSession + from prompt_toolkit.history import FileHistory + repl_session = PromptSession(history=FileHistory(".ca_repl_history")) + except ImportError: + repl_session = None + + while True: + try: + if repl_session: + line = repl_session.prompt(skin.prompt()) + else: + line = input(skin.prompt()) + except (EOFError, KeyboardInterrupt): + skin.print_goodbye() + break + + line = line.strip() + if not line: + continue + if line in ("exit", "quit", "q"): + skin.print_goodbye() + break + + try: + args = line.split() + cli.main(args, standalone_mode=False, obj=ctx.obj) + except SystemExit: + pass + except Exception as e: + _error(str(e)) + + +def main() -> None: + cli(obj={}) + + +if __name__ == "__main__": + main() diff --git a/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/core/__init__.py b/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/core/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/core/project.py b/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/core/project.py new file mode 100644 index 000000000..58a55a3b5 --- /dev/null +++ b/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/core/project.py @@ -0,0 +1,88 @@ +"""Project management for the CloudAnalyzer CLI harness. + +A project tracks loaded point clouds, trajectories, QA results, +and operation history. +""" + +from __future__ import annotations + +import json +import os +import time +from pathlib import Path +from typing import Any + + +def _default_project(name: str = "untitled") -> dict: + """Return a fresh project structure.""" + return { + "version": "1.0", + "name": name, + "created_at": time.strftime("%Y-%m-%dT%H:%M:%S"), + "modified_at": time.strftime("%Y-%m-%dT%H:%M:%S"), + "clouds": [], + "trajectories": [], + "results": [], + "history": [], + "settings": { + "default_voxel_size": 0.05, + }, + } + + +def create_project(path: str, name: str = "untitled") -> dict: + """Create a new project file.""" + project = _default_project(name) + _save(path, project) + return project + + +def load_project(path: str) -> dict: + """Load a project from disk.""" + if not os.path.isfile(path): + raise FileNotFoundError(f"Project file not found: {path}") + with open(path, encoding="utf-8") as f: + return json.load(f) + + +def save_project(path: str, project: dict) -> None: + """Save the project to disk.""" + project["modified_at"] = time.strftime("%Y-%m-%dT%H:%M:%S") + _save(path, project) + + +def record_operation(project: dict, operation: str, details: dict[str, Any] | None = None) -> None: + """Append an operation to the project history.""" + project["history"].append({ + "operation": operation, + "timestamp": time.strftime("%Y-%m-%dT%H:%M:%S"), + "details": details or {}, + }) + + +def add_result(project: dict, result_type: str, data: dict) -> None: + """Store a QA result in the project.""" + project["results"].append({ + "type": result_type, + "timestamp": time.strftime("%Y-%m-%dT%H:%M:%S"), + "data": data, + }) + + +def project_info(project: dict) -> dict: + """Return a summary of the project state.""" + return { + "name": project.get("name", "untitled"), + "clouds": len(project.get("clouds", [])), + "trajectories": len(project.get("trajectories", [])), + "results": len(project.get("results", [])), + "operations": len(project.get("history", [])), + "created_at": project.get("created_at"), + "modified_at": project.get("modified_at"), + } + + +def _save(path: str, data: dict) -> None: + os.makedirs(os.path.dirname(path) or ".", exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2) diff --git a/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/core/session.py b/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/core/session.py new file mode 100644 index 000000000..a60544e32 --- /dev/null +++ b/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/core/session.py @@ -0,0 +1,54 @@ +"""Session management with undo support.""" + +from __future__ import annotations + +import copy +from typing import Any + +from cli_anything.cloudanalyzer.core.project import ( + load_project, + record_operation, + save_project, +) + + +class Session: + """Wraps a project file with undo/redo.""" + + def __init__(self, project_path: str) -> None: + self.path = project_path + self.project = load_project(project_path) + self._undo_stack: list[dict] = [] + self._redo_stack: list[dict] = [] + + def save(self) -> None: + save_project(self.path, self.project) + + def do(self, operation: str, details: dict[str, Any] | None = None) -> None: + """Record an operation and push state for undo.""" + self._undo_stack.append(copy.deepcopy(self.project)) + self._redo_stack.clear() + record_operation(self.project, operation, details) + self.save() + + def undo(self) -> bool: + """Undo the last operation. Returns True if successful.""" + if not self._undo_stack: + return False + self._redo_stack.append(copy.deepcopy(self.project)) + self.project = self._undo_stack.pop() + self.save() + return True + + def redo(self) -> bool: + """Redo the last undone operation. Returns True if successful.""" + if not self._redo_stack: + return False + self._undo_stack.append(copy.deepcopy(self.project)) + self.project = self._redo_stack.pop() + self.save() + return True + + @property + def history(self) -> list[dict]: + return list(self.project.get("history", [])) diff --git a/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/skills/SKILL.md b/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/skills/SKILL.md new file mode 100644 index 000000000..0f9091c4a --- /dev/null +++ b/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/skills/SKILL.md @@ -0,0 +1,294 @@ +--- +name: "cli-anything-cloudanalyzer" +description: "Command-line interface for CloudAnalyzer — Agent-friendly harness for CloudAnalyzer, a QA platform for mapping, localization, and perception outputs. Supports 32 commands across 8 groups: point cloud evaluation, trajectory evaluation, ground segmentation QA, config-driven quality gates, baseline evolution, processing, visualization, and interactive REPL." +--- + +# cli-anything-cloudanalyzer + +Agent-friendly command-line harness for [CloudAnalyzer](https://github.com/rsasaki0109/CloudAnalyzer) — a QA platform for mapping, localization, and perception point cloud outputs. + +**32 commands** across 8 groups. + +## Installation + +```bash +pip install cli-anything-cloudanalyzer +``` + +**Prerequisites:** +- Python 3.10+ +- CloudAnalyzer: `pip install cloudanalyzer` + +## Global Options + +```bash +cli-anything-cloudanalyzer [--project FILE] [--json] COMMAND [ARGS]... +``` + +| Option | Description | +|---|---| +| `-p, --project TEXT` | Path to project JSON file | +| `--json` | Output results as JSON (for agent consumption) | + +## Command Groups + +### 1. evaluate — Point Cloud Evaluation (6 commands) + +#### evaluate run +Evaluate a point cloud against a reference (Chamfer, F1, AUC, Hausdorff). + +```bash +cli-anything-cloudanalyzer evaluate run source.pcd reference.pcd +cli-anything-cloudanalyzer --json evaluate run source.pcd reference.pcd +``` + +Options: `--plot TEXT`, `--threshold FLOAT` + +#### evaluate compare +Compare two point clouds with optional registration. + +```bash +cli-anything-cloudanalyzer evaluate compare src.pcd tgt.pcd --register gicp +``` + +Options: `--register TEXT` (icp/gicp/none), `--report TEXT` + +#### evaluate diff +Quick distance statistics between two point clouds. + +```bash +cli-anything-cloudanalyzer evaluate diff a.pcd b.pcd --threshold 0.1 +``` + +#### evaluate batch +Batch evaluation of multiple point clouds against a reference. + +```bash +cli-anything-cloudanalyzer --json evaluate batch results/ reference.pcd --min-auc 0.95 +``` + +Options: `--min-auc FLOAT`, `--max-chamfer FLOAT` + +#### evaluate ground +Evaluate ground segmentation quality (precision, recall, F1, IoU). + +```bash +cli-anything-cloudanalyzer --json evaluate ground est_ground.pcd est_ng.pcd ref_ground.pcd ref_ng.pcd --min-f1 0.9 +``` + +Options: `--voxel-size FLOAT`, `--min-precision FLOAT`, `--min-recall FLOAT`, `--min-f1 FLOAT`, `--min-iou FLOAT` + +#### evaluate pipeline +Filter, downsample, evaluate in one command. + +```bash +cli-anything-cloudanalyzer evaluate pipeline input.pcd reference.pcd -o output.pcd +``` + +--- + +### 2. trajectory — Trajectory Evaluation (4 commands) + +#### trajectory evaluate +Evaluate estimated vs reference trajectory (ATE, RPE, drift, lateral, longitudinal). + +```bash +cli-anything-cloudanalyzer --json trajectory evaluate est.csv gt.csv --max-ate 0.5 --max-lateral 0.3 +``` + +Options: `--max-ate FLOAT`, `--max-rpe FLOAT`, `--max-drift FLOAT`, `--min-coverage FLOAT`, `--max-lateral FLOAT`, `--max-longitudinal FLOAT`, `--align-origin`, `--align-rigid`, `--report TEXT` + +#### trajectory batch +Batch trajectory evaluation. + +```bash +cli-anything-cloudanalyzer trajectory batch runs/ --reference-dir gt/ --max-drift 1.0 +``` + +#### trajectory run-evaluate +Integrated map + trajectory evaluation. + +```bash +cli-anything-cloudanalyzer trajectory run-evaluate map.pcd map_ref.pcd traj.csv traj_ref.csv +``` + +#### trajectory run-batch +Combined batch QA for map + trajectory. + +```bash +cli-anything-cloudanalyzer trajectory run-batch maps/ --map-reference-dir refs/ --trajectory-dir trajs/ --trajectory-reference-dir traj_refs/ +``` + +--- + +### 3. check — Config-Driven Quality Gate (2 commands) + +#### check run +Run unified QA from a config file. + +```bash +cli-anything-cloudanalyzer --json check run cloudanalyzer.yaml +``` + +Options: `--output-json TEXT` + +#### check init +Generate a starter config file. + +```bash +cli-anything-cloudanalyzer check init cloudanalyzer.yaml --profile integrated +``` + +Options: `--profile TEXT` (mapping/localization/perception/integrated), `--force` + +--- + +### 4. baseline — Baseline Evolution (3 commands) + +#### baseline decision +Decide whether to promote, keep, or reject a candidate baseline. + +```bash +cli-anything-cloudanalyzer --json baseline decision qa/summary.json --history-dir qa/history/ +``` + +Options: `--history TEXT` (repeatable), `--history-dir TEXT`, `--output-json TEXT` + +#### baseline save +Save a QA summary to the history directory. + +```bash +cli-anything-cloudanalyzer baseline save qa/summary.json --history-dir qa/history/ --keep 10 +``` + +Options: `--history-dir TEXT`, `--label TEXT`, `--keep INTEGER` + +#### baseline list +List saved baselines. + +```bash +cli-anything-cloudanalyzer --json baseline list --history-dir qa/history/ +``` + +--- + +### 5. process — Point Cloud Processing (6 commands) + +#### process downsample +Voxel grid downsampling. + +```bash +cli-anything-cloudanalyzer process downsample cloud.pcd -o down.pcd -v 0.05 +``` + +#### process sample +Random point sampling. + +```bash +cli-anything-cloudanalyzer process sample cloud.pcd -o sampled.pcd -n 10000 +``` + +#### process filter +Statistical outlier removal. + +```bash +cli-anything-cloudanalyzer process filter cloud.pcd -o filtered.pcd +``` + +#### process split +Split point cloud into grid tiles (writes metadata.yaml). + +```bash +cli-anything-cloudanalyzer process split large.pcd -o tiles/ -g 100 +``` + +#### process merge +Merge multiple point clouds. + +```bash +cli-anything-cloudanalyzer process merge a.pcd b.pcd -o merged.pcd +``` + +#### process convert +Convert between point cloud formats. + +```bash +cli-anything-cloudanalyzer process convert input.las -o output.pcd +``` + +--- + +### 6. inspect — Visualization (3 commands) + +#### inspect view +Open a point cloud viewer. + +```bash +cli-anything-cloudanalyzer inspect view cloud.pcd +``` + +#### inspect web +Interactive browser inspection. + +```bash +cli-anything-cloudanalyzer inspect web map.pcd ref.pcd --heatmap +``` + +#### inspect web-export +Export a static HTML inspection bundle. + +```bash +cli-anything-cloudanalyzer inspect web-export map.pcd ref.pcd -o bundle/ +``` + +--- + +### 7. info — Metadata (2 commands) + +#### info show +Show point cloud metadata. + +```bash +cli-anything-cloudanalyzer --json info show cloud.pcd +``` + +#### info version +Show CloudAnalyzer version. + +--- + +### 8. session — Session Management (3 commands) + +#### session new / session history / session save + +--- + +## Typical Agent Workflows + +### Workflow 1: Evaluate and gate a point cloud + +```bash +cli-anything-cloudanalyzer --json evaluate run output.pcd reference.pcd +``` + +### Workflow 2: Config-driven QA pipeline + +```bash +cli-anything-cloudanalyzer check init cloudanalyzer.yaml --profile integrated +cli-anything-cloudanalyzer --json check run cloudanalyzer.yaml +``` + +### Workflow 3: Baseline management + +```bash +cli-anything-cloudanalyzer --json check run cloudanalyzer.yaml --output-json qa/summary.json +cli-anything-cloudanalyzer baseline save qa/summary.json --history-dir qa/history/ +cli-anything-cloudanalyzer --json baseline decision qa/summary.json --history-dir qa/history/ +``` + +### Workflow 4: Ground segmentation QA + +```bash +cli-anything-cloudanalyzer --json evaluate ground \ + est_ground.pcd est_ng.pcd ref_ground.pcd ref_ng.pcd --min-f1 0.9 +``` diff --git a/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/tests/TEST.md b/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/tests/TEST.md new file mode 100644 index 000000000..d0ecc2810 --- /dev/null +++ b/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/tests/TEST.md @@ -0,0 +1,19 @@ +# Test Plan — cli-anything-cloudanalyzer + +## Unit Tests (test_core.py) — No CloudAnalyzer required + +- Project creation, loading, saving +- Session undo/redo +- Operation history recording +- Backend availability check + +## E2E Tests (test_full_e2e.py) — Requires CloudAnalyzer + Open3D + +- `evaluate run` with real PCD files +- `trajectory evaluate` with CSV trajectories +- `check init` + `check run` cycle +- `baseline save` + `baseline list` + `baseline decision` cycle +- `process downsample` with real PCD +- `info show` and `info version` +- `--json` flag produces valid JSON for all commands +- REPL startup and quit diff --git a/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/tests/__init__.py b/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/tests/test_core.py b/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/tests/test_core.py new file mode 100644 index 000000000..aede685ae --- /dev/null +++ b/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/tests/test_core.py @@ -0,0 +1,84 @@ +"""Unit tests for project and session management (no CloudAnalyzer needed).""" + +import json +import os +import tempfile + +import pytest + +from cli_anything.cloudanalyzer.core.project import ( + create_project, + load_project, + save_project, + project_info, + record_operation, + add_result, +) +from cli_anything.cloudanalyzer.core.session import Session + + +class TestProject: + def test_create_and_load(self, tmp_path): + path = str(tmp_path / "project.json") + project = create_project(path, name="test-project") + + assert project["name"] == "test-project" + assert project["version"] == "1.0" + assert os.path.isfile(path) + + loaded = load_project(path) + assert loaded["name"] == "test-project" + + def test_record_operation(self, tmp_path): + path = str(tmp_path / "project.json") + project = create_project(path) + record_operation(project, "evaluate", {"source": "a.pcd"}) + + assert len(project["history"]) == 1 + assert project["history"][0]["operation"] == "evaluate" + + def test_add_result(self, tmp_path): + path = str(tmp_path / "project.json") + project = create_project(path) + add_result(project, "evaluation", {"auc": 0.95}) + + assert len(project["results"]) == 1 + assert project["results"][0]["data"]["auc"] == 0.95 + + def test_project_info(self, tmp_path): + path = str(tmp_path / "project.json") + project = create_project(path, name="info-test") + info = project_info(project) + + assert info["name"] == "info-test" + assert info["clouds"] == 0 + assert info["operations"] == 0 + + def test_load_missing_raises(self, tmp_path): + with pytest.raises(FileNotFoundError): + load_project(str(tmp_path / "nope.json")) + + +class TestSession: + def test_undo_redo(self, tmp_path): + path = str(tmp_path / "project.json") + create_project(path, name="undo-test") + sess = Session(path) + + sess.do("first-op", {"detail": "a"}) + sess.do("second-op", {"detail": "b"}) + assert len(sess.history) == 2 + + assert sess.undo() + assert len(sess.history) == 1 + + assert sess.redo() + assert len(sess.history) == 2 + + def test_undo_empty_returns_false(self, tmp_path): + path = str(tmp_path / "project.json") + create_project(path) + sess = Session(path) + + assert not sess.undo() + assert not sess.redo() diff --git a/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/tests/test_full_e2e.py b/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/tests/test_full_e2e.py new file mode 100644 index 000000000..713d0ba8d --- /dev/null +++ b/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/tests/test_full_e2e.py @@ -0,0 +1,75 @@ +"""E2E tests — requires CloudAnalyzer and Open3D installed.""" + +import json +import subprocess +import sys + +import pytest + +from click.testing import CliRunner + +from cli_anything.cloudanalyzer.cloudanalyzer_cli import cli + + +@pytest.fixture +def runner(): + return CliRunner() + + +class TestInfoCommands: + def test_version_json(self, runner): + result = runner.invoke(cli, ["--json", "info", "version"]) + assert result.exit_code == 0 + data = json.loads(result.output) + assert "cloudanalyzer_version" in data + assert "harness_version" in data + + def test_version_human(self, runner): + result = runner.invoke(cli, ["info", "version"]) + assert result.exit_code == 0 + assert "cloudanalyzer_version" in result.output + + +class TestSessionCommands: + def test_create_project(self, runner, tmp_path): + path = str(tmp_path / "project.json") + result = runner.invoke(cli, ["session", "new", "-o", path, "-n", "test"]) + assert result.exit_code == 0 + + def test_history_requires_project(self, runner): + result = runner.invoke(cli, ["session", "history"]) + assert result.exit_code != 0 + + +class TestCheckCommands: + def test_init_creates_config(self, runner, tmp_path): + dest = str(tmp_path / "cloudanalyzer.yaml") + result = runner.invoke(cli, ["check", "init", dest]) + assert result.exit_code == 0 + assert (tmp_path / "cloudanalyzer.yaml").exists() + + def test_init_refuses_overwrite(self, runner, tmp_path): + dest = tmp_path / "cloudanalyzer.yaml" + dest.write_text("existing", encoding="utf-8") + result = runner.invoke(cli, ["check", "init", str(dest)]) + assert result.exit_code != 0 + + +class TestBaselineCommands: + def test_save_and_list(self, runner, tmp_path): + summary = tmp_path / "summary.json" + summary.write_text(json.dumps({ + "config_path": "test", + "project": "test", + "summary": {"passed": True, "failed_check_ids": []}, + "checks": [], + }), encoding="utf-8") + history_dir = str(tmp_path / "history") + + result = runner.invoke(cli, ["baseline", "save", str(summary), "--history-dir", history_dir]) + assert result.exit_code == 0 + + result = runner.invoke(cli, ["--json", "baseline", "list", "--history-dir", history_dir]) + assert result.exit_code == 0 + data = json.loads(result.output) + assert len(data) == 1 diff --git a/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/utils/__init__.py b/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/utils/ca_backend.py b/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/utils/ca_backend.py new file mode 100644 index 000000000..5649f8ad4 --- /dev/null +++ b/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/utils/ca_backend.py @@ -0,0 +1,130 @@ +"""CloudAnalyzer backend — direct Python import (no subprocess needed). + +CloudAnalyzer is a Python package so we import and call its functions +directly rather than shelling out to a binary. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + + +def is_available() -> bool: + """Check if CloudAnalyzer is importable.""" + try: + import ca # noqa: F401 + return True + except ImportError: + return False + + +def get_version() -> str: + """Return the installed CloudAnalyzer version.""" + try: + from importlib.metadata import version + return version("cloudanalyzer") + except Exception: + return "unknown" + + +def evaluate(source: str, reference: str, **kwargs: Any) -> dict: + """Run point cloud evaluation.""" + from ca.evaluate import evaluate as _evaluate + return _evaluate(source, reference, **kwargs) + + +def compare(source: str, target: str, method: str = "gicp", **kwargs: Any) -> dict: + """Run point cloud comparison with optional registration.""" + from ca.compare import run_compare + return run_compare(source, target, method=method, **kwargs) + + +def diff(source: str, target: str, threshold: float | None = None) -> dict: + """Run quick distance diff.""" + from ca.diff import run_diff + return run_diff(source, target, threshold=threshold) + + +def evaluate_trajectory(estimated: str, reference: str, **kwargs: Any) -> dict: + """Evaluate a trajectory against reference.""" + from ca.trajectory import evaluate_trajectory as _eval_traj + return _eval_traj(estimated, reference, **kwargs) + + +def evaluate_ground( + est_ground: str, est_nonground: str, + ref_ground: str, ref_nonground: str, **kwargs: Any, +) -> dict: + """Evaluate ground segmentation quality.""" + from ca.ground_evaluate import evaluate_ground_segmentation + return evaluate_ground_segmentation( + est_ground, est_nonground, ref_ground, ref_nonground, **kwargs, + ) + + +def run_check_suite(config_path: str) -> dict: + """Run config-driven QA checks.""" + from ca.core import load_check_suite, run_check_suite as _run + suite = load_check_suite(config_path) + return _run(suite) + + +def render_check_scaffold(profile: str = "integrated") -> str: + """Generate a starter config YAML.""" + from ca.core import render_check_scaffold as _render + result = _render(profile=profile) + return result.yaml_text + + +def baseline_decision(candidate_path: str, history_paths: list[str]) -> dict: + """Decide promote / keep / reject for a baseline.""" + from ca.core import summarize_baseline_evolution + candidate = json.loads(Path(candidate_path).read_text(encoding="utf-8")) + history = [ + json.loads(Path(p).read_text(encoding="utf-8")) for p in history_paths + ] + return summarize_baseline_evolution(candidate, history) + + +def baseline_save(summary_path: str, history_dir: str, **kwargs: Any) -> str: + """Save a QA summary to the history directory.""" + from ca.baseline_history import save_baseline + return save_baseline(summary_path, history_dir, **kwargs) + + +def baseline_list(history_dir: str) -> list[dict]: + """List saved baselines.""" + from ca.baseline_history import list_baselines + return list_baselines(history_dir) + + +def baseline_discover(history_dir: str) -> list[str]: + """Discover history JSON paths.""" + from ca.baseline_history import discover_history + return discover_history(history_dir) + + +def baseline_rotate(history_dir: str, keep: int = 10) -> list[str]: + """Rotate old baselines.""" + from ca.baseline_history import rotate_history + return rotate_history(history_dir, keep=keep) + + +def downsample(input_path: str, output_path: str, voxel_size: float) -> dict: + """Voxel grid downsampling.""" + from ca.downsample import downsample as _ds + return _ds(input_path, voxel_size, output_path) + + +def split(input_path: str, output_dir: str, grid_size: float, axis: str = "xy") -> dict: + """Split a point cloud into grid tiles.""" + from ca.split import split as _split + return _split(input_path, output_dir, grid_size, axis=axis) + + +def info(path: str) -> dict: + """Get point cloud metadata.""" + from ca.info import get_info + return get_info(path) diff --git a/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/utils/repl_skin.py b/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/utils/repl_skin.py new file mode 100644 index 000000000..c7312348a --- /dev/null +++ b/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/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/cloudanalyzer/agent-harness/setup.py b/cloudanalyzer/agent-harness/setup.py new file mode 100644 index 000000000..616044574 --- /dev/null +++ b/cloudanalyzer/agent-harness/setup.py @@ -0,0 +1,35 @@ +from pathlib import Path +from setuptools import setup, find_namespace_packages + +_readme = Path("cli_anything/cloudanalyzer/README.md") +_long_description = _readme.read_text(encoding="utf-8") if _readme.is_file() else "" + +setup( + name="cli-anything-cloudanalyzer", + version="1.0.0", + description="Agent-friendly CLI harness for CloudAnalyzer point cloud QA platform", + long_description=_long_description, + long_description_content_type="text/markdown", + author="cli-anything", + python_requires=">=3.10", + packages=find_namespace_packages(include=["cli_anything.*"]), + package_data={ + "cli_anything.cloudanalyzer": ["skills/*.md"], + }, + install_requires=[ + "click>=8.0.0", + "prompt-toolkit>=3.0.0", + "cloudanalyzer", + ], + entry_points={ + "console_scripts": [ + "cli-anything-cloudanalyzer=cli_anything.cloudanalyzer.cloudanalyzer_cli:main", + ], + }, + classifiers=[ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", + "Topic :: Scientific/Engineering :: GIS", + "Topic :: Scientific/Engineering :: Information Analysis", + ], +) From 9412347b096e976399d7d73caf1cf2a710db82fb Mon Sep 17 00:00:00 2001 From: Ryohei Sasaki Date: Sun, 5 Apr 2026 14:09:47 +0900 Subject: [PATCH 2/7] Fix self-review issues: add missing CLI commands, clean unused imports - Add all missing CLI commands: evaluate batch/pipeline, trajectory batch/run-evaluate, process sample/filter/merge/convert, inspect view/web/web-export (now matches SKILL.md documentation) - Remove unused imports (sys, tempfile, subprocess) - Remove unused variable (project in session_new) - Fix license to GPL-3.0 to match CLI-Anything repo --- .../cloudanalyzer/cloudanalyzer_cli.py | 194 +++++++++++++++++- .../cloudanalyzer/tests/test_core.py | 1 - .../cloudanalyzer/tests/test_full_e2e.py | 2 - cloudanalyzer/agent-harness/setup.py | 2 +- 4 files changed, 193 insertions(+), 6 deletions(-) diff --git a/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/cloudanalyzer_cli.py b/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/cloudanalyzer_cli.py index 77a5b42d3..13aade69f 100644 --- a/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/cloudanalyzer_cli.py +++ b/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/cloudanalyzer_cli.py @@ -15,7 +15,6 @@ Backend: CloudAnalyzer Python package (direct import, no subprocess) """ import json -import sys from pathlib import Path from typing import Optional @@ -183,6 +182,41 @@ def evaluate_ground( ctx.exit(1) +@evaluate.command("batch") +@click.argument("directory") +@click.argument("reference") +@click.option("--min-auc", type=float, default=None) +@click.option("--max-chamfer", type=float, default=None) +@click.pass_context +def evaluate_batch(ctx: click.Context, directory: str, reference: str, min_auc: Optional[float], max_chamfer: Optional[float]) -> None: + """Batch evaluation of multiple point clouds.""" + try: + from ca.batch import batch_evaluate + result = batch_evaluate(directory, reference, min_auc=min_auc, max_chamfer=max_chamfer) + _out(ctx, result) + except Exception as e: + _error(str(e), ctx.obj.get("json", False)) + ctx.exit(1) + + +@evaluate.command("pipeline") +@click.argument("input_path") +@click.argument("reference") +@click.option("-o", "--output", required=True) +@click.option("-v", "--voxel-size", type=float, default=0.05) +@click.option("--filter/--no-filter", "apply_filter", default=False) +@click.pass_context +def evaluate_pipeline(ctx: click.Context, input_path: str, reference: str, output: str, voxel_size: float, apply_filter: bool) -> None: + """Filter, downsample, evaluate in one command.""" + try: + from ca.pipeline import run_pipeline + result = run_pipeline(input_path, reference, output, voxel_size=voxel_size, apply_filter=apply_filter) + _out(ctx, result) + except Exception as e: + _error(str(e), ctx.obj.get("json", False)) + ctx.exit(1) + + # ── trajectory group ────────────────────────────────────────────────────────── @cli.group() @@ -219,6 +253,45 @@ def trajectory_evaluate( ctx.exit(1) +@trajectory.command("batch") +@click.argument("directory") +@click.option("--reference-dir", required=True) +@click.option("--max-ate", type=float, default=None) +@click.option("--max-rpe", type=float, default=None) +@click.option("--max-drift", type=float, default=None) +@click.option("--min-coverage", type=float, default=None) +@click.pass_context +def trajectory_batch(ctx: click.Context, directory: str, reference_dir: str, **kwargs) -> None: + """Batch trajectory evaluation.""" + try: + from ca.batch import trajectory_batch_evaluate + result = trajectory_batch_evaluate(directory, reference_dir, **kwargs) + _out(ctx, result) + except Exception as e: + _error(str(e), ctx.obj.get("json", False)) + ctx.exit(1) + + +@trajectory.command("run-evaluate") +@click.argument("map_path") +@click.argument("map_reference") +@click.argument("trajectory_path") +@click.argument("trajectory_reference") +@click.option("--min-auc", type=float, default=None) +@click.option("--max-ate", type=float, default=None) +@click.option("--report", default=None) +@click.pass_context +def trajectory_run_evaluate(ctx: click.Context, map_path: str, map_reference: str, trajectory_path: str, trajectory_reference: str, **kwargs) -> None: + """Integrated map + trajectory evaluation.""" + try: + from ca.run_evaluate import run_evaluate + result = run_evaluate(map_path, map_reference, trajectory_path, trajectory_reference, **kwargs) + _out(ctx, result) + except Exception as e: + _error(str(e), ctx.obj.get("json", False)) + ctx.exit(1) + + # ── check group ─────────────────────────────────────────────────────────────── @cli.group() @@ -378,6 +451,123 @@ def process_split(ctx: click.Context, input_path: str, output_dir: str, grid_siz ctx.exit(1) +@process.command("sample") +@click.argument("input_path") +@click.option("-o", "--output", required=True) +@click.option("-n", "--num-points", type=int, required=True) +@click.pass_context +def process_sample(ctx: click.Context, input_path: str, output: str, num_points: int) -> None: + """Random point sampling.""" + try: + from ca.sample import random_sample + result = random_sample(input_path, output, num_points) + _out(ctx, result) + except Exception as e: + _error(str(e), ctx.obj.get("json", False)) + ctx.exit(1) + + +@process.command("filter") +@click.argument("input_path") +@click.option("-o", "--output", required=True) +@click.option("--nb-neighbors", type=int, default=20) +@click.option("--std-ratio", type=float, default=2.0) +@click.pass_context +def process_filter(ctx: click.Context, input_path: str, output: str, nb_neighbors: int, std_ratio: float) -> None: + """Statistical outlier removal.""" + try: + from ca.filter import filter_outliers + result = filter_outliers(input_path, output, nb_neighbors=nb_neighbors, std_ratio=std_ratio) + _out(ctx, result) + except Exception as e: + _error(str(e), ctx.obj.get("json", False)) + ctx.exit(1) + + +@process.command("merge") +@click.argument("inputs", nargs=-1, required=True) +@click.option("-o", "--output", required=True) +@click.pass_context +def process_merge(ctx: click.Context, inputs: tuple[str, ...], output: str) -> None: + """Merge multiple point clouds.""" + try: + from ca.merge import merge + result = merge(list(inputs), output) + _out(ctx, result) + except Exception as e: + _error(str(e), ctx.obj.get("json", False)) + ctx.exit(1) + + +@process.command("convert") +@click.argument("input_path") +@click.option("-o", "--output", required=True) +@click.pass_context +def process_convert(ctx: click.Context, input_path: str, output: str) -> None: + """Convert between point cloud formats.""" + try: + from ca.convert import convert + result = convert(input_path, output) + _out(ctx, result) + except Exception as e: + _error(str(e), ctx.obj.get("json", False)) + ctx.exit(1) + + +# ── inspect group ───────────────────────────────────────────────────────────── + +@cli.group() +@click.pass_context +def inspect(ctx: click.Context) -> None: + """Visualization and inspection commands.""" + + +@inspect.command("view") +@click.argument("path") +@click.pass_context +def inspect_view(ctx: click.Context, path: str) -> None: + """Open a point cloud viewer.""" + try: + from ca.view import view + view(path) + except Exception as e: + _error(str(e), ctx.obj.get("json", False)) + ctx.exit(1) + + +@inspect.command("web") +@click.argument("source") +@click.argument("reference", required=False, default=None) +@click.option("--heatmap", is_flag=True) +@click.option("--trajectory", default=None) +@click.option("--port", type=int, default=8080) +@click.pass_context +def inspect_web(ctx: click.Context, source: str, reference: Optional[str], heatmap: bool, trajectory: Optional[str], port: int) -> None: + """Interactive browser inspection.""" + try: + from ca.web import launch_web + launch_web(source, reference=reference, heatmap=heatmap, trajectory=trajectory, port=port) + except Exception as e: + _error(str(e), ctx.obj.get("json", False)) + ctx.exit(1) + + +@inspect.command("web-export") +@click.argument("source") +@click.argument("reference", required=False, default=None) +@click.option("-o", "--output", required=True) +@click.pass_context +def inspect_web_export(ctx: click.Context, source: str, reference: Optional[str], output: str) -> None: + """Export a static HTML inspection bundle.""" + try: + from ca.web import export_web + result = export_web(source, reference=reference, output_dir=output) + _out(ctx, result) + except Exception as e: + _error(str(e), ctx.obj.get("json", False)) + ctx.exit(1) + + # ── info group ──────────────────────────────────────────────────────────────── @cli.group() @@ -422,7 +612,7 @@ def session(ctx: click.Context) -> None: def session_new(ctx: click.Context, output: str, name: str) -> None: """Create a new project file.""" try: - project = create_project(output, name=name) + create_project(output, name=name) _out(ctx, {"created": output, "name": name}) except Exception as e: _error(str(e), ctx.obj.get("json", False)) diff --git a/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/tests/test_core.py b/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/tests/test_core.py index aede685ae..c6539d0d2 100644 --- a/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/tests/test_core.py +++ b/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/tests/test_core.py @@ -2,7 +2,6 @@ import json import os -import tempfile import pytest diff --git a/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/tests/test_full_e2e.py b/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/tests/test_full_e2e.py index 713d0ba8d..3efd6a4a9 100644 --- a/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/tests/test_full_e2e.py +++ b/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/tests/test_full_e2e.py @@ -1,8 +1,6 @@ """E2E tests — requires CloudAnalyzer and Open3D installed.""" import json -import subprocess -import sys import pytest diff --git a/cloudanalyzer/agent-harness/setup.py b/cloudanalyzer/agent-harness/setup.py index 616044574..108a11f01 100644 --- a/cloudanalyzer/agent-harness/setup.py +++ b/cloudanalyzer/agent-harness/setup.py @@ -28,7 +28,7 @@ setup( }, classifiers=[ "Programming Language :: Python :: 3", - "License :: OSI Approved :: MIT License", + "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", "Topic :: Scientific/Engineering :: GIS", "Topic :: Scientific/Engineering :: Information Analysis", ], From d73c5880787735ff178d2d35b081eea437ca367e Mon Sep 17 00:00:00 2001 From: Ryohei Sasaki Date: Sun, 5 Apr 2026 14:15:51 +0900 Subject: [PATCH 3/7] Fix license classifier to MIT (matching CloudAnalyzer) --- cloudanalyzer/agent-harness/setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cloudanalyzer/agent-harness/setup.py b/cloudanalyzer/agent-harness/setup.py index 108a11f01..616044574 100644 --- a/cloudanalyzer/agent-harness/setup.py +++ b/cloudanalyzer/agent-harness/setup.py @@ -28,7 +28,7 @@ setup( }, classifiers=[ "Programming Language :: Python :: 3", - "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", + "License :: OSI Approved :: MIT License", "Topic :: Scientific/Engineering :: GIS", "Topic :: Scientific/Engineering :: Information Analysis", ], From a75b174c08b4e316c6473ff8729d2864f69977b2 Mon Sep 17 00:00:00 2001 From: Ryohei Sasaki Date: Mon, 6 Apr 2026 15:44:58 +0900 Subject: [PATCH 4/7] fix(cloudanalyzer): address harness review (backend guard, docs, README, e2e skips) Made-with: Cursor --- README.md | 13 +- cloudanalyzer/agent-harness/CLOUDANALYZER.md | 34 ++-- .../cli_anything/cloudanalyzer/README.md | 4 +- .../cloudanalyzer/cloudanalyzer_cli.py | 115 +++++++++---- .../cloudanalyzer/skills/SKILL.md | 31 ++-- .../cloudanalyzer/tests/test_full_e2e.py | 9 ++ .../cloudanalyzer/utils/ca_backend.py | 152 ++++++++++++++++++ 7 files changed, 298 insertions(+), 60 deletions(-) diff --git a/README.md b/README.md index 1a54e30c1..21916eb92 100644 --- a/README.md +++ b/README.md @@ -918,6 +918,13 @@ Each application received complete, production-ready CLI interfaces — not demo ✅ 40 +☁️ CloudAnalyzer +Point cloud / trajectory QA +cli-anything-cloudanalyzer +CloudAnalyzer (Python API) +✅ 14 + + Total ✅ 2,045 @@ -960,8 +967,9 @@ 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) +cloudanalyzer 14 passed ✅ (7 unit + 7 e2e) ────────────────────────────────────────────────────────────────────────────── -TOTAL 2,005 passed ✅ 100% pass rate +TOTAL 2,019 passed ✅ 100% pass rate ``` --- @@ -1036,7 +1044,8 @@ cli-anything/ ├── 🔬 renderdoc/agent-harness/ # RenderDoc CLI (59 tests) ├── 🎬 videocaptioner/agent-harness/ # VideoCaptioner CLI (26 tests) ├── ☁️ cloudcompare/agent-harness/ # CloudCompare CLI (88 tests) -└── 🔍 exa/agent-harness/ # Exa CLI (40 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/cloudanalyzer/agent-harness/CLOUDANALYZER.md b/cloudanalyzer/agent-harness/CLOUDANALYZER.md index 87c85f5b3..7cd10a19f 100644 --- a/cloudanalyzer/agent-harness/CLOUDANALYZER.md +++ b/cloudanalyzer/agent-harness/CLOUDANALYZER.md @@ -17,24 +17,30 @@ Since CloudAnalyzer is a Python package, the backend (`ca_backend.py`) imports CloudAnalyzer functions directly — no subprocess invocation needed. This makes the harness faster and more reliable than subprocess-based approaches. +Call paths go through `ca_backend`: handlers in `cloudanalyzer_cli.py` do not +import `ca.*` directly. + ## Command Mapping | Harness Group | CloudAnalyzer Command(s) | |---|---| -| evaluate run | ca evaluate | -| evaluate compare | ca compare | -| evaluate diff | ca diff | -| evaluate ground | ca ground-evaluate | -| trajectory evaluate | ca traj-evaluate | -| trajectory batch | ca traj-batch | -| check run | ca check | -| check init | ca init-check | -| baseline decision | ca baseline-decision | -| baseline save | ca baseline-save | -| baseline list | ca baseline-list | -| process downsample | ca downsample | -| process split | ca split | -| info show | ca info | +| evaluate run | ca.evaluate.evaluate | +| evaluate compare | ca.compare.run_compare | +| evaluate diff | ca.diff.run_diff | +| evaluate batch | ca.batch.batch_evaluate | +| evaluate ground | ca.ground_evaluate.evaluate_ground_segmentation | +| evaluate pipeline | ca.pipeline.run_pipeline | +| trajectory evaluate | ca.trajectory.evaluate_trajectory | +| trajectory batch | ca.batch.trajectory_batch_evaluate | +| trajectory run-evaluate | ca.run_evaluate.evaluate_run | +| check run | ca.core.run_check_suite | +| check init | ca.core.render_check_scaffold | +| baseline decision | ca.core.summarize_baseline_evolution | +| baseline save / list | ca.baseline_history.* | +| process * | ca.downsample / split / sample / filter / merge / convert | +| inspect view | ca.view.view | +| inspect web / web-export | ca.web.serve / export_static_bundle | +| info show | ca.info.get_info | ## State Model diff --git a/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/README.md b/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/README.md index 42107ae6b..998af98b7 100644 --- a/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/README.md +++ b/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/README.md @@ -35,7 +35,7 @@ CloudAnalyzer is already CLI-first, but this harness adds: - **REPL mode** for interactive exploration - **Project/session management** with operation history and undo - **SKILL.md** for agent auto-discovery via CLI-Anything ecosystem -- **Unified Click interface** grouping 32 commands into logical groups +- **Unified Click interface** grouping 27 commands into logical groups ## Commands @@ -50,7 +50,7 @@ See [SKILL.md](cli_anything/cloudanalyzer/skills/SKILL.md) for the full command | process | 6 | Downsample, split, filter, merge, convert | | inspect | 3 | Visualization and browser inspection | | info | 2 | Metadata and version | -| session | 3 | Project and session management | +| session | 2 | Project and session management | ## Requirements diff --git a/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/cloudanalyzer_cli.py b/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/cloudanalyzer_cli.py index 13aade69f..698b17ae0 100644 --- a/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/cloudanalyzer_cli.py +++ b/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/cloudanalyzer_cli.py @@ -191,8 +191,9 @@ def evaluate_ground( def evaluate_batch(ctx: click.Context, directory: str, reference: str, min_auc: Optional[float], max_chamfer: Optional[float]) -> None: """Batch evaluation of multiple point clouds.""" try: - from ca.batch import batch_evaluate - result = batch_evaluate(directory, reference, min_auc=min_auc, max_chamfer=max_chamfer) + result = ca_backend.batch_evaluate( + directory, reference, min_auc=min_auc, max_chamfer=max_chamfer, + ) _out(ctx, result) except Exception as e: _error(str(e), ctx.obj.get("json", False)) @@ -204,13 +205,19 @@ def evaluate_batch(ctx: click.Context, directory: str, reference: str, min_auc: @click.argument("reference") @click.option("-o", "--output", required=True) @click.option("-v", "--voxel-size", type=float, default=0.05) -@click.option("--filter/--no-filter", "apply_filter", default=False) @click.pass_context -def evaluate_pipeline(ctx: click.Context, input_path: str, reference: str, output: str, voxel_size: float, apply_filter: bool) -> None: +def evaluate_pipeline( + ctx: click.Context, + input_path: str, + reference: str, + output: str, + voxel_size: float, +) -> None: """Filter, downsample, evaluate in one command.""" try: - from ca.pipeline import run_pipeline - result = run_pipeline(input_path, reference, output, voxel_size=voxel_size, apply_filter=apply_filter) + result = ca_backend.run_pipeline( + input_path, reference, output, voxel_size=voxel_size, + ) _out(ctx, result) except Exception as e: _error(str(e), ctx.obj.get("json", False)) @@ -236,14 +243,34 @@ def trajectory(ctx: click.Context) -> None: @click.option("--max-longitudinal", type=float, default=None) @click.option("--align-origin", is_flag=True) @click.option("--align-rigid", is_flag=True) -@click.option("--report", default=None, help="Write trajectory report") @click.pass_context def trajectory_evaluate( - ctx: click.Context, estimated: str, reference: str, **kwargs, + ctx: click.Context, + estimated: str, + reference: str, + max_ate: Optional[float], + max_rpe: Optional[float], + max_drift: Optional[float], + min_coverage: Optional[float], + max_lateral: Optional[float], + max_longitudinal: Optional[float], + align_origin: bool, + align_rigid: bool, ) -> None: """Evaluate estimated vs reference trajectory.""" try: - result = ca_backend.evaluate_trajectory(estimated, reference, **kwargs) + result = ca_backend.evaluate_trajectory( + estimated, + reference, + max_ate=max_ate, + max_rpe=max_rpe, + max_drift=max_drift, + min_coverage=min_coverage, + max_lateral=max_lateral, + max_longitudinal=max_longitudinal, + align_origin=align_origin, + align_rigid=align_rigid, + ) _out(ctx, result) gate = result.get("quality_gate") if gate and not gate["passed"]: @@ -261,11 +288,25 @@ def trajectory_evaluate( @click.option("--max-drift", type=float, default=None) @click.option("--min-coverage", type=float, default=None) @click.pass_context -def trajectory_batch(ctx: click.Context, directory: str, reference_dir: str, **kwargs) -> None: +def trajectory_batch( + ctx: click.Context, + directory: str, + reference_dir: str, + max_ate: Optional[float], + max_rpe: Optional[float], + max_drift: Optional[float], + min_coverage: Optional[float], +) -> None: """Batch trajectory evaluation.""" try: - from ca.batch import trajectory_batch_evaluate - result = trajectory_batch_evaluate(directory, reference_dir, **kwargs) + result = ca_backend.trajectory_batch_evaluate( + directory, + reference_dir, + max_ate=max_ate, + max_rpe=max_rpe, + max_drift=max_drift, + min_coverage=min_coverage, + ) _out(ctx, result) except Exception as e: _error(str(e), ctx.obj.get("json", False)) @@ -279,13 +320,26 @@ def trajectory_batch(ctx: click.Context, directory: str, reference_dir: str, **k @click.argument("trajectory_reference") @click.option("--min-auc", type=float, default=None) @click.option("--max-ate", type=float, default=None) -@click.option("--report", default=None) @click.pass_context -def trajectory_run_evaluate(ctx: click.Context, map_path: str, map_reference: str, trajectory_path: str, trajectory_reference: str, **kwargs) -> None: +def trajectory_run_evaluate( + ctx: click.Context, + map_path: str, + map_reference: str, + trajectory_path: str, + trajectory_reference: str, + min_auc: Optional[float], + max_ate: Optional[float], +) -> None: """Integrated map + trajectory evaluation.""" try: - from ca.run_evaluate import run_evaluate - result = run_evaluate(map_path, map_reference, trajectory_path, trajectory_reference, **kwargs) + result = ca_backend.evaluate_run( + map_path, + map_reference, + trajectory_path, + trajectory_reference, + min_auc=min_auc, + max_ate=max_ate, + ) _out(ctx, result) except Exception as e: _error(str(e), ctx.obj.get("json", False)) @@ -459,8 +513,7 @@ def process_split(ctx: click.Context, input_path: str, output_dir: str, grid_siz def process_sample(ctx: click.Context, input_path: str, output: str, num_points: int) -> None: """Random point sampling.""" try: - from ca.sample import random_sample - result = random_sample(input_path, output, num_points) + result = ca_backend.random_sample(input_path, output, num_points) _out(ctx, result) except Exception as e: _error(str(e), ctx.obj.get("json", False)) @@ -476,8 +529,9 @@ def process_sample(ctx: click.Context, input_path: str, output: str, num_points: def process_filter(ctx: click.Context, input_path: str, output: str, nb_neighbors: int, std_ratio: float) -> None: """Statistical outlier removal.""" try: - from ca.filter import filter_outliers - result = filter_outliers(input_path, output, nb_neighbors=nb_neighbors, std_ratio=std_ratio) + result = ca_backend.filter_outliers( + input_path, output, nb_neighbors=nb_neighbors, std_ratio=std_ratio, + ) _out(ctx, result) except Exception as e: _error(str(e), ctx.obj.get("json", False)) @@ -491,8 +545,7 @@ def process_filter(ctx: click.Context, input_path: str, output: str, nb_neighbor def process_merge(ctx: click.Context, inputs: tuple[str, ...], output: str) -> None: """Merge multiple point clouds.""" try: - from ca.merge import merge - result = merge(list(inputs), output) + result = ca_backend.merge(list(inputs), output) _out(ctx, result) except Exception as e: _error(str(e), ctx.obj.get("json", False)) @@ -506,8 +559,7 @@ def process_merge(ctx: click.Context, inputs: tuple[str, ...], output: str) -> N def process_convert(ctx: click.Context, input_path: str, output: str) -> None: """Convert between point cloud formats.""" try: - from ca.convert import convert - result = convert(input_path, output) + result = ca_backend.convert(input_path, output) _out(ctx, result) except Exception as e: _error(str(e), ctx.obj.get("json", False)) @@ -528,8 +580,7 @@ def inspect(ctx: click.Context) -> None: def inspect_view(ctx: click.Context, path: str) -> None: """Open a point cloud viewer.""" try: - from ca.view import view - view(path) + ca_backend.view_point_cloud(path) except Exception as e: _error(str(e), ctx.obj.get("json", False)) ctx.exit(1) @@ -545,8 +596,13 @@ def inspect_view(ctx: click.Context, path: str) -> None: def inspect_web(ctx: click.Context, source: str, reference: Optional[str], heatmap: bool, trajectory: Optional[str], port: int) -> None: """Interactive browser inspection.""" try: - from ca.web import launch_web - launch_web(source, reference=reference, heatmap=heatmap, trajectory=trajectory, port=port) + ca_backend.web_serve( + source, + reference, + port=port, + heatmap=heatmap, + trajectory=trajectory, + ) except Exception as e: _error(str(e), ctx.obj.get("json", False)) ctx.exit(1) @@ -560,8 +616,7 @@ def inspect_web(ctx: click.Context, source: str, reference: Optional[str], heatm def inspect_web_export(ctx: click.Context, source: str, reference: Optional[str], output: str) -> None: """Export a static HTML inspection bundle.""" try: - from ca.web import export_web - result = export_web(source, reference=reference, output_dir=output) + result = ca_backend.web_export_bundle(source, reference, output) _out(ctx, result) except Exception as e: _error(str(e), ctx.obj.get("json", False)) diff --git a/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/skills/SKILL.md b/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/skills/SKILL.md index 0f9091c4a..048518439 100644 --- a/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/skills/SKILL.md +++ b/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/skills/SKILL.md @@ -1,6 +1,6 @@ --- name: "cli-anything-cloudanalyzer" -description: "Command-line interface for CloudAnalyzer — Agent-friendly harness for CloudAnalyzer, a QA platform for mapping, localization, and perception outputs. Supports 32 commands across 8 groups: point cloud evaluation, trajectory evaluation, ground segmentation QA, config-driven quality gates, baseline evolution, processing, visualization, and interactive REPL." +description: "Command-line interface for CloudAnalyzer — Agent-friendly harness for CloudAnalyzer, a QA platform for mapping, localization, and perception outputs. Supports 27 commands across 8 groups: point cloud evaluation, trajectory evaluation, ground segmentation QA, config-driven quality gates, baseline evolution, processing, visualization, and interactive REPL." --- # cli-anything-cloudanalyzer @@ -51,7 +51,7 @@ Compare two point clouds with optional registration. cli-anything-cloudanalyzer evaluate compare src.pcd tgt.pcd --register gicp ``` -Options: `--register TEXT` (icp/gicp/none), `--report TEXT` +Options: `--register TEXT` (icp/gicp/none) #### evaluate diff Quick distance statistics between two point clouds. @@ -87,7 +87,7 @@ cli-anything-cloudanalyzer evaluate pipeline input.pcd reference.pcd -o output.p --- -### 2. trajectory — Trajectory Evaluation (4 commands) +### 2. trajectory — Trajectory Evaluation (3 commands) #### trajectory evaluate Evaluate estimated vs reference trajectory (ATE, RPE, drift, lateral, longitudinal). @@ -96,7 +96,7 @@ Evaluate estimated vs reference trajectory (ATE, RPE, drift, lateral, longitudin cli-anything-cloudanalyzer --json trajectory evaluate est.csv gt.csv --max-ate 0.5 --max-lateral 0.3 ``` -Options: `--max-ate FLOAT`, `--max-rpe FLOAT`, `--max-drift FLOAT`, `--min-coverage FLOAT`, `--max-lateral FLOAT`, `--max-longitudinal FLOAT`, `--align-origin`, `--align-rigid`, `--report TEXT` +Options: `--max-ate FLOAT`, `--max-rpe FLOAT`, `--max-drift FLOAT`, `--min-coverage FLOAT`, `--max-lateral FLOAT`, `--max-longitudinal FLOAT`, `--align-origin`, `--align-rigid` #### trajectory batch Batch trajectory evaluation. @@ -112,12 +112,7 @@ Integrated map + trajectory evaluation. cli-anything-cloudanalyzer trajectory run-evaluate map.pcd map_ref.pcd traj.csv traj_ref.csv ``` -#### trajectory run-batch -Combined batch QA for map + trajectory. - -```bash -cli-anything-cloudanalyzer trajectory run-batch maps/ --map-reference-dir refs/ --trajectory-dir trajs/ --trajectory-reference-dir traj_refs/ -``` +Options: `--min-auc FLOAT`, `--max-ate FLOAT` --- @@ -257,9 +252,21 @@ Show CloudAnalyzer version. --- -### 8. session — Session Management (3 commands) +### 8. session — Session Management (2 commands) -#### session new / session history / session save +#### session new +Create a new harness project JSON file. + +```bash +cli-anything-cloudanalyzer session new -o project.json -n my-run +``` + +#### session history +Show recent operations for the project given with `-p` / `--project`. + +```bash +cli-anything-cloudanalyzer --project project.json session history --last 20 +``` --- diff --git a/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/tests/test_full_e2e.py b/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/tests/test_full_e2e.py index 3efd6a4a9..86b4c9f0b 100644 --- a/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/tests/test_full_e2e.py +++ b/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/tests/test_full_e2e.py @@ -7,6 +7,13 @@ import pytest from click.testing import CliRunner from cli_anything.cloudanalyzer.cloudanalyzer_cli import cli +from cli_anything.cloudanalyzer.utils import ca_backend + + +requires_cloudanalyzer = pytest.mark.skipif ( + not ca_backend.is_available(), + reason="CloudAnalyzer (import ca) is not installed", +) @pytest.fixture @@ -39,6 +46,7 @@ class TestSessionCommands: assert result.exit_code != 0 +@requires_cloudanalyzer class TestCheckCommands: def test_init_creates_config(self, runner, tmp_path): dest = str(tmp_path / "cloudanalyzer.yaml") @@ -53,6 +61,7 @@ class TestCheckCommands: assert result.exit_code != 0 +@requires_cloudanalyzer class TestBaselineCommands: def test_save_and_list(self, runner, tmp_path): summary = tmp_path / "summary.json" diff --git a/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/utils/ca_backend.py b/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/utils/ca_backend.py index 5649f8ad4..96e2a4bca 100644 --- a/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/utils/ca_backend.py +++ b/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/utils/ca_backend.py @@ -10,6 +10,11 @@ import json from pathlib import Path from typing import Any +MISSING_CLOUDANALYZER_MSG = ( + "CloudAnalyzer is not installed or not importable. " + "Install with: pip install cloudanalyzer" +) + def is_available() -> bool: """Check if CloudAnalyzer is importable.""" @@ -20,6 +25,11 @@ def is_available() -> bool: return False +def _ensure_ca() -> None: + if not is_available(): + raise RuntimeError(MISSING_CLOUDANALYZER_MSG) + + def get_version() -> str: """Return the installed CloudAnalyzer version.""" try: @@ -31,24 +41,28 @@ def get_version() -> str: def evaluate(source: str, reference: str, **kwargs: Any) -> dict: """Run point cloud evaluation.""" + _ensure_ca() from ca.evaluate import evaluate as _evaluate return _evaluate(source, reference, **kwargs) def compare(source: str, target: str, method: str = "gicp", **kwargs: Any) -> dict: """Run point cloud comparison with optional registration.""" + _ensure_ca() from ca.compare import run_compare return run_compare(source, target, method=method, **kwargs) def diff(source: str, target: str, threshold: float | None = None) -> dict: """Run quick distance diff.""" + _ensure_ca() from ca.diff import run_diff return run_diff(source, target, threshold=threshold) def evaluate_trajectory(estimated: str, reference: str, **kwargs: Any) -> dict: """Evaluate a trajectory against reference.""" + _ensure_ca() from ca.trajectory import evaluate_trajectory as _eval_traj return _eval_traj(estimated, reference, **kwargs) @@ -58,6 +72,7 @@ def evaluate_ground( ref_ground: str, ref_nonground: str, **kwargs: Any, ) -> dict: """Evaluate ground segmentation quality.""" + _ensure_ca() from ca.ground_evaluate import evaluate_ground_segmentation return evaluate_ground_segmentation( est_ground, est_nonground, ref_ground, ref_nonground, **kwargs, @@ -66,6 +81,7 @@ def evaluate_ground( def run_check_suite(config_path: str) -> dict: """Run config-driven QA checks.""" + _ensure_ca() from ca.core import load_check_suite, run_check_suite as _run suite = load_check_suite(config_path) return _run(suite) @@ -73,6 +89,7 @@ def run_check_suite(config_path: str) -> dict: def render_check_scaffold(profile: str = "integrated") -> str: """Generate a starter config YAML.""" + _ensure_ca() from ca.core import render_check_scaffold as _render result = _render(profile=profile) return result.yaml_text @@ -80,6 +97,7 @@ def render_check_scaffold(profile: str = "integrated") -> str: def baseline_decision(candidate_path: str, history_paths: list[str]) -> dict: """Decide promote / keep / reject for a baseline.""" + _ensure_ca() from ca.core import summarize_baseline_evolution candidate = json.loads(Path(candidate_path).read_text(encoding="utf-8")) history = [ @@ -90,41 +108,175 @@ def baseline_decision(candidate_path: str, history_paths: list[str]) -> dict: def baseline_save(summary_path: str, history_dir: str, **kwargs: Any) -> str: """Save a QA summary to the history directory.""" + _ensure_ca() from ca.baseline_history import save_baseline return save_baseline(summary_path, history_dir, **kwargs) def baseline_list(history_dir: str) -> list[dict]: """List saved baselines.""" + _ensure_ca() from ca.baseline_history import list_baselines return list_baselines(history_dir) def baseline_discover(history_dir: str) -> list[str]: """Discover history JSON paths.""" + _ensure_ca() from ca.baseline_history import discover_history return discover_history(history_dir) def baseline_rotate(history_dir: str, keep: int = 10) -> list[str]: """Rotate old baselines.""" + _ensure_ca() from ca.baseline_history import rotate_history return rotate_history(history_dir, keep=keep) def downsample(input_path: str, output_path: str, voxel_size: float) -> dict: """Voxel grid downsampling.""" + _ensure_ca() from ca.downsample import downsample as _ds return _ds(input_path, voxel_size, output_path) def split(input_path: str, output_dir: str, grid_size: float, axis: str = "xy") -> dict: """Split a point cloud into grid tiles.""" + _ensure_ca() from ca.split import split as _split return _split(input_path, output_dir, grid_size, axis=axis) def info(path: str) -> dict: """Get point cloud metadata.""" + _ensure_ca() from ca.info import get_info return get_info(path) + + +def batch_evaluate(directory: str, reference: str, **kwargs: Any) -> dict: + """Evaluate every point cloud in a directory against one reference.""" + _ensure_ca() + from ca.batch import batch_evaluate as _be + return _be(directory, reference, **kwargs) + + +def run_pipeline( + input_path: str, reference: str, output: str, **kwargs: Any, +) -> dict: + """Filter, downsample, evaluate in one pipeline.""" + _ensure_ca() + from ca.pipeline import run_pipeline as _rp + return _rp(input_path, reference, output, **kwargs) + + +def trajectory_batch_evaluate( + directory: str, reference_dir: str, **kwargs: Any, +) -> dict: + """Batch trajectory evaluation.""" + _ensure_ca() + from ca.batch import trajectory_batch_evaluate as _tbe + return _tbe(directory, reference_dir, **kwargs) + + +def evaluate_run( + map_path: str, + map_reference_path: str, + trajectory_path: str, + trajectory_reference_path: str, + **kwargs: Any, +) -> dict: + """Evaluate one map and one trajectory together.""" + _ensure_ca() + from ca.run_evaluate import evaluate_run as _er + return _er( + map_path, + map_reference_path, + trajectory_path, + trajectory_reference_path, + **kwargs, + ) + + +def random_sample(input_path: str, output_path: str, num_points: int) -> dict: + """Random point sampling.""" + _ensure_ca() + from ca.sample import random_sample as _rs + return _rs(input_path, output_path, num_points) + + +def filter_outliers( + input_path: str, output_path: str, **kwargs: Any, +) -> dict: + """Statistical outlier removal.""" + _ensure_ca() + from ca.filter import filter_outliers as _fo + return _fo(input_path, output_path, **kwargs) + + +def merge(paths: list[str], output: str) -> dict: + """Merge point clouds.""" + _ensure_ca() + from ca.merge import merge as _m + return _m(paths, output) + + +def convert(input_path: str, output_path: str) -> dict: + """Convert between point cloud formats.""" + _ensure_ca() + from ca.convert import convert as _c + return _c(input_path, output_path) + + +def view_point_cloud(path: str) -> None: + """Open the interactive point cloud viewer (single file).""" + _ensure_ca() + from ca.view import view as _view + _view([path]) + + +def web_serve( + source: str, + reference: str | None, + *, + port: int = 8080, + heatmap: bool = False, + trajectory: str | None = None, + trajectory_reference: str | None = None, + open_browser: bool = True, +) -> None: + """Start the CloudAnalyzer web viewer.""" + _ensure_ca() + from ca.web import serve + paths: list[str] = [source] if not reference else [source, reference] + serve( + paths, + port=port, + open_browser=open_browser, + heatmap=heatmap, + trajectory_path=trajectory, + trajectory_reference_path=trajectory_reference, + ) + + +def web_export_bundle( + source: str, + reference: str | None, + output_dir: str, + *, + heatmap: bool = False, + trajectory: str | None = None, + trajectory_reference: str | None = None, +) -> dict: + """Write a static HTML viewer bundle.""" + _ensure_ca() + from ca.web import export_static_bundle + paths: list[str] = [source] if not reference else [source, reference] + return export_static_bundle( + paths, + output_dir=output_dir, + heatmap=heatmap, + trajectory_path=trajectory, + trajectory_reference_path=trajectory_reference, + ) From c9fc67619c077163153ff2e77bf1ab693a840bdd Mon Sep 17 00:00:00 2001 From: Ryohei Sasaki Date: Mon, 6 Apr 2026 15:58:56 +0900 Subject: [PATCH 5/7] chore: add cloudanalyzer to CLI-Hub registry.json Made-with: Cursor --- registry.json | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/registry.json b/registry.json index deb1fab1e..23c5569b7 100644 --- a/registry.json +++ b/registry.json @@ -522,6 +522,20 @@ "category": "ai", "contributor": "Akabane71", "contributor_url": "https://github.com/Akabane71" + }, + { + "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", + "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", + "contributor": "rsasaki0109", + "contributor_url": "https://github.com/rsasaki0109" } ] } From dbce794db493c20c2887b4dc6d37f20452fa9c67 Mon Sep 17 00:00:00 2001 From: Ryohei Sasaki Date: Wed, 8 Apr 2026 05:57:35 +0900 Subject: [PATCH 6/7] fix(cloudanalyzer): align command counts to 27 in SKILL.md header and README trajectory row --- .../agent-harness/cli_anything/cloudanalyzer/README.md | 2 +- .../agent-harness/cli_anything/cloudanalyzer/skills/SKILL.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/README.md b/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/README.md index 998af98b7..70b4a2fa0 100644 --- a/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/README.md +++ b/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/README.md @@ -44,7 +44,7 @@ See [SKILL.md](cli_anything/cloudanalyzer/skills/SKILL.md) for the full command | Group | Commands | Description | |---|---:|---| | evaluate | 6 | Point cloud evaluation (Chamfer, F1, AUC, ground segmentation) | -| trajectory | 4 | Trajectory QA (ATE, RPE, drift, lateral, longitudinal) | +| trajectory | 3 | Trajectory QA (ATE, RPE, drift, lateral, longitudinal) | | check | 2 | Config-driven quality gates | | baseline | 3 | Baseline evolution (promote/keep/reject) | | process | 6 | Downsample, split, filter, merge, convert | diff --git a/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/skills/SKILL.md b/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/skills/SKILL.md index 048518439..97cb9ec75 100644 --- a/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/skills/SKILL.md +++ b/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/skills/SKILL.md @@ -7,7 +7,7 @@ description: "Command-line interface for CloudAnalyzer — Agent-friendly harnes Agent-friendly command-line harness for [CloudAnalyzer](https://github.com/rsasaki0109/CloudAnalyzer) — a QA platform for mapping, localization, and perception point cloud outputs. -**32 commands** across 8 groups. +**27 commands** across 8 groups. ## Installation From 3702ff029f244ceac8d6e988b128608ce6d244a2 Mon Sep 17 00:00:00 2001 From: Ryohei Sasaki Date: Wed, 8 Apr 2026 07:20:04 +0900 Subject: [PATCH 7/7] fix(cloudanalyzer): address code review findings - Remove unused imports (load_project, save_project, etc.) - Use shlex.split() in REPL for proper quoted-path handling - Pass json_mode to _error() in REPL for consistent JSON errors - Add --trajectory-reference to inspect web/web-export commands - Add --heatmap/--trajectory to web-export command - Validate --history/--history-dir in baseline decision (fail fast) - Combine --history and --history-dir instead of ignoring one - Add JSON validation with clear errors in baseline_decision backend - Add try/except to info version command - Pass --plot to backend instead of post-hoc dict mutation --- .../cloudanalyzer/cloudanalyzer_cli.py | 52 ++++++++++++------- .../cloudanalyzer/utils/ca_backend.py | 20 +++++-- 2 files changed, 49 insertions(+), 23 deletions(-) diff --git a/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/cloudanalyzer_cli.py b/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/cloudanalyzer_cli.py index 698b17ae0..6f734d83b 100644 --- a/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/cloudanalyzer_cli.py +++ b/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/cloudanalyzer_cli.py @@ -15,19 +15,13 @@ Backend: CloudAnalyzer Python package (direct import, no subprocess) """ import json +import shlex from pathlib import Path from typing import Optional import click -from cli_anything.cloudanalyzer.core.project import ( - create_project, - load_project, - project_info, - save_project, - record_operation, - add_result, -) +from cli_anything.cloudanalyzer.core.project import create_project from cli_anything.cloudanalyzer.core.session import Session from cli_anything.cloudanalyzer.utils import ca_backend from cli_anything.cloudanalyzer.utils.repl_skin import ReplSkin @@ -107,9 +101,9 @@ def evaluate_run(ctx: click.Context, source: str, reference: str, plot: Optional kwargs = {} if threshold is not None: kwargs["thresholds"] = [threshold] - result = ca_backend.evaluate(source, reference, **kwargs) if plot: - result["plot"] = plot + kwargs["plot"] = plot + result = ca_backend.evaluate(source, reference, **kwargs) _out(ctx, result) except Exception as e: _error(str(e), ctx.obj.get("json", False)) @@ -416,8 +410,12 @@ def baseline_decision( """Decide promote / keep / reject for a baseline.""" try: paths = list(history_paths) - if history_dir and not paths: - paths = ca_backend.baseline_discover(history_dir) + if history_dir: + paths.extend(ca_backend.baseline_discover(history_dir)) + if not paths: + _error("Provide --history or --history-dir.", ctx.obj.get("json", False)) + ctx.exit(1) + return result = ca_backend.baseline_decision(candidate_json, paths) if output_json: Path(output_json).parent.mkdir(parents=True, exist_ok=True) @@ -591,9 +589,10 @@ def inspect_view(ctx: click.Context, path: str) -> None: @click.argument("reference", required=False, default=None) @click.option("--heatmap", is_flag=True) @click.option("--trajectory", default=None) +@click.option("--trajectory-reference", default=None) @click.option("--port", type=int, default=8080) @click.pass_context -def inspect_web(ctx: click.Context, source: str, reference: Optional[str], heatmap: bool, trajectory: Optional[str], port: int) -> None: +def inspect_web(ctx: click.Context, source: str, reference: Optional[str], heatmap: bool, trajectory: Optional[str], trajectory_reference: Optional[str], port: int) -> None: """Interactive browser inspection.""" try: ca_backend.web_serve( @@ -602,6 +601,7 @@ def inspect_web(ctx: click.Context, source: str, reference: Optional[str], heatm port=port, heatmap=heatmap, trajectory=trajectory, + trajectory_reference=trajectory_reference, ) except Exception as e: _error(str(e), ctx.obj.get("json", False)) @@ -612,11 +612,19 @@ def inspect_web(ctx: click.Context, source: str, reference: Optional[str], heatm @click.argument("source") @click.argument("reference", required=False, default=None) @click.option("-o", "--output", required=True) +@click.option("--heatmap", is_flag=True) +@click.option("--trajectory", default=None) +@click.option("--trajectory-reference", default=None) @click.pass_context -def inspect_web_export(ctx: click.Context, source: str, reference: Optional[str], output: str) -> None: +def inspect_web_export(ctx: click.Context, source: str, reference: Optional[str], output: str, heatmap: bool, trajectory: Optional[str], trajectory_reference: Optional[str]) -> None: """Export a static HTML inspection bundle.""" try: - result = ca_backend.web_export_bundle(source, reference, output) + result = ca_backend.web_export_bundle( + source, reference, output, + heatmap=heatmap, + trajectory=trajectory, + trajectory_reference=trajectory_reference, + ) _out(ctx, result) except Exception as e: _error(str(e), ctx.obj.get("json", False)) @@ -648,8 +656,12 @@ def info_show(ctx: click.Context, path: str) -> None: @click.pass_context def info_version(ctx: click.Context) -> None: """Show CloudAnalyzer version.""" - ver = ca_backend.get_version() - _out(ctx, {"cloudanalyzer_version": ver, "harness_version": VERSION}) + try: + ver = ca_backend.get_version() + _out(ctx, {"cloudanalyzer_version": ver, "harness_version": VERSION}) + except Exception as e: + _error(str(e), ctx.obj.get("json", False)) + ctx.exit(1) # ── session group ───────────────────────────────────────────────────────────── @@ -730,12 +742,14 @@ def _start_repl(ctx: click.Context) -> None: break try: - args = line.split() + args = shlex.split(line) cli.main(args, standalone_mode=False, obj=ctx.obj) except SystemExit: pass + except ValueError as e: + _error(f"Invalid input: {e}", ctx.obj.get("json", False)) except Exception as e: - _error(str(e)) + _error(str(e), ctx.obj.get("json", False)) def main() -> None: diff --git a/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/utils/ca_backend.py b/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/utils/ca_backend.py index 96e2a4bca..f284ce65d 100644 --- a/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/utils/ca_backend.py +++ b/cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/utils/ca_backend.py @@ -99,10 +99,22 @@ def baseline_decision(candidate_path: str, history_paths: list[str]) -> dict: """Decide promote / keep / reject for a baseline.""" _ensure_ca() from ca.core import summarize_baseline_evolution - candidate = json.loads(Path(candidate_path).read_text(encoding="utf-8")) - history = [ - json.loads(Path(p).read_text(encoding="utf-8")) for p in history_paths - ] + cp = Path(candidate_path) + if not cp.exists(): + raise FileNotFoundError(f"Candidate file not found: {candidate_path}") + try: + candidate = json.loads(cp.read_text(encoding="utf-8")) + except json.JSONDecodeError as e: + raise ValueError(f"Invalid JSON in {candidate_path}: {e}") from e + history = [] + for p in history_paths: + hp = Path(p) + if not hp.exists(): + raise FileNotFoundError(f"History file not found: {p}") + try: + history.append(json.loads(hp.read_text(encoding="utf-8"))) + except json.JSONDecodeError as e: + raise ValueError(f"Invalid JSON in {p}: {e}") from e return summarize_baseline_evolution(candidate, history)