chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
# 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.
|
||||
|
||||
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 |
|
||||
| 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
|
||||
|
||||
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.
|
||||
@@ -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 27 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 | 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 |
|
||||
| inspect | 3 | Visualization and browser inspection |
|
||||
| info | 2 | Metadata and version |
|
||||
| session | 2 | Project and session management |
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.10+
|
||||
- CloudAnalyzer (`pip install cloudanalyzer`)
|
||||
@@ -0,0 +1,2 @@
|
||||
"""cli-anything CloudAnalyzer — CLI harness for CloudAnalyzer point cloud QA platform."""
|
||||
__version__ = "1.0.0"
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Allow running as: python3 -m cli_anything.cloudanalyzer"""
|
||||
from cli_anything.cloudanalyzer.cloudanalyzer_cli import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,760 @@
|
||||
"""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 shlex
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import click
|
||||
|
||||
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
|
||||
|
||||
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]
|
||||
if 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))
|
||||
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)
|
||||
|
||||
|
||||
@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:
|
||||
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))
|
||||
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.pass_context
|
||||
def evaluate_pipeline(
|
||||
ctx: click.Context,
|
||||
input_path: str,
|
||||
reference: str,
|
||||
output: str,
|
||||
voxel_size: float,
|
||||
) -> None:
|
||||
"""Filter, downsample, evaluate in one command."""
|
||||
try:
|
||||
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))
|
||||
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.pass_context
|
||||
def trajectory_evaluate(
|
||||
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,
|
||||
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"]:
|
||||
ctx.exit(1)
|
||||
except Exception as e:
|
||||
_error(str(e), ctx.obj.get("json", False))
|
||||
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,
|
||||
max_ate: Optional[float],
|
||||
max_rpe: Optional[float],
|
||||
max_drift: Optional[float],
|
||||
min_coverage: Optional[float],
|
||||
) -> None:
|
||||
"""Batch trajectory evaluation."""
|
||||
try:
|
||||
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))
|
||||
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.pass_context
|
||||
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:
|
||||
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))
|
||||
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:
|
||||
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)
|
||||
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)
|
||||
|
||||
|
||||
@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:
|
||||
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))
|
||||
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:
|
||||
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))
|
||||
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:
|
||||
result = ca_backend.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:
|
||||
result = ca_backend.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:
|
||||
ca_backend.view_point_cloud(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("--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], trajectory_reference: Optional[str], port: int) -> None:
|
||||
"""Interactive browser inspection."""
|
||||
try:
|
||||
ca_backend.web_serve(
|
||||
source,
|
||||
reference,
|
||||
port=port,
|
||||
heatmap=heatmap,
|
||||
trajectory=trajectory,
|
||||
trajectory_reference=trajectory_reference,
|
||||
)
|
||||
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.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, 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,
|
||||
heatmap=heatmap,
|
||||
trajectory=trajectory,
|
||||
trajectory_reference=trajectory_reference,
|
||||
)
|
||||
_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."""
|
||||
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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
@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:
|
||||
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 = 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), ctx.obj.get("json", False))
|
||||
|
||||
|
||||
def main() -> None:
|
||||
cli(obj={})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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)
|
||||
@@ -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", []))
|
||||
@@ -0,0 +1,301 @@
|
||||
---
|
||||
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 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
|
||||
|
||||
Agent-friendly command-line harness for [CloudAnalyzer](https://github.com/rsasaki0109/CloudAnalyzer) — a QA platform for mapping, localization, and perception point cloud outputs.
|
||||
|
||||
**27 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)
|
||||
|
||||
#### 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 (3 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`
|
||||
|
||||
#### 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
|
||||
```
|
||||
|
||||
Options: `--min-auc FLOAT`, `--max-ate FLOAT`
|
||||
|
||||
---
|
||||
|
||||
### 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 (2 commands)
|
||||
|
||||
#### 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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
```
|
||||
@@ -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
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Unit tests for project and session management (no CloudAnalyzer needed)."""
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
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()
|
||||
@@ -0,0 +1,82 @@
|
||||
"""E2E tests — requires CloudAnalyzer and Open3D installed."""
|
||||
|
||||
import json
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
|
||||
@requires_cloudanalyzer
|
||||
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
|
||||
|
||||
|
||||
@requires_cloudanalyzer
|
||||
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
|
||||
@@ -0,0 +1,294 @@
|
||||
"""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
|
||||
|
||||
MISSING_CLOUDANALYZER_MSG = (
|
||||
"CloudAnalyzer is not installed or not importable. "
|
||||
"Install with: pip install cloudanalyzer"
|
||||
)
|
||||
|
||||
|
||||
def is_available() -> bool:
|
||||
"""Check if CloudAnalyzer is importable."""
|
||||
try:
|
||||
import ca # noqa: F401
|
||||
return True
|
||||
except ImportError:
|
||||
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:
|
||||
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."""
|
||||
_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)
|
||||
|
||||
|
||||
def evaluate_ground(
|
||||
est_ground: str, est_nonground: str,
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
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)
|
||||
|
||||
|
||||
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,
|
||||
)
|
||||
@@ -0,0 +1,567 @@
|
||||
"""cli-anything REPL Skin — Unified terminal interface for all CLI harnesses.
|
||||
|
||||
Copy this file into your CLI package at:
|
||||
cli_anything/<software>/utils/repl_skin.py
|
||||
|
||||
Usage:
|
||||
from cli_anything.<software>.utils.repl_skin import ReplSkin
|
||||
|
||||
skin = ReplSkin("shotcut", version="1.0.0")
|
||||
skin.print_banner() # auto-detects repo-root or packaged SKILL.md
|
||||
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
|
||||
from pathlib import Path
|
||||
|
||||
# ── 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"
|
||||
|
||||
_SKILL_SOURCE_REPO = os.environ.get("CLI_ANYTHING_SKILL_REPO", "HKUDS/CLI-Anything")
|
||||
|
||||
# ── 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))
|
||||
|
||||
|
||||
def _display_home_path(path: str) -> str:
|
||||
"""Display a path relative to the home directory when possible."""
|
||||
expanded = Path(path).expanduser().resolve()
|
||||
home = Path.home().resolve()
|
||||
try:
|
||||
relative = expanded.relative_to(home)
|
||||
return f"~/{relative.as_posix()}"
|
||||
except ValueError:
|
||||
return str(expanded)
|
||||
|
||||
|
||||
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-<software>/history
|
||||
skill_path: Path to the SKILL.md file for agent discovery.
|
||||
Auto-detected from the repo-root skills/ tree when present,
|
||||
otherwise from the package's skills/ directory.
|
||||
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
|
||||
software_aliases = {"iterm2_ctl": "iterm2"}
|
||||
self.skill_slug = software_aliases.get(self.software, self.software).replace("_", "-")
|
||||
self.skill_id = f"cli-anything-{self.skill_slug}"
|
||||
self.skill_install_cmd = (
|
||||
f"npx skills add {_SKILL_SOURCE_REPO} --skill {self.skill_id} -g -y"
|
||||
)
|
||||
global_skill_root = Path(
|
||||
os.environ.get("CLI_ANYTHING_GLOBAL_SKILLS_DIR", str(Path.home() / ".agents" / "skills"))
|
||||
).expanduser()
|
||||
self.global_skill_path = str(global_skill_root / self.skill_id / "SKILL.md")
|
||||
|
||||
# Prefer repo-root canonical skills/<skill-id>/SKILL.md when running
|
||||
# inside the CLI-Anything monorepo. Fall back to the packaged
|
||||
# cli_anything/<software>/skills/SKILL.md for installed harnesses.
|
||||
if skill_path is None:
|
||||
package_skill = Path(__file__).resolve().parent.parent / "skills" / "SKILL.md"
|
||||
repo_skill = None
|
||||
for parent in Path(__file__).resolve().parents:
|
||||
candidate = parent / "skills" / self.skill_id / "SKILL.md"
|
||||
if candidate.is_file():
|
||||
repo_skill = candidate
|
||||
break
|
||||
if repo_skill and repo_skill.is_file():
|
||||
skill_path = str(repo_skill)
|
||||
elif package_skill.is_file():
|
||||
skill_path = str(package_skill)
|
||||
self.skill_path = skill_path
|
||||
self.accent = _ACCENT_COLORS.get(self.software, _DEFAULT_ACCENT)
|
||||
|
||||
# History file
|
||||
if history_file is None:
|
||||
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."""
|
||||
import textwrap
|
||||
|
||||
inner = 72
|
||||
|
||||
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}"
|
||||
|
||||
def _meta_lines(label: str, value: str) -> list[str]:
|
||||
"""Wrap a metadata line for the banner box."""
|
||||
icon = self._c(_MAGENTA, "◇")
|
||||
label_text = self._c(_DARK_GRAY, label)
|
||||
prefix = f" {icon} {label_text} "
|
||||
available = max(12, inner - _visible_len(prefix))
|
||||
wrapped = textwrap.wrap(
|
||||
value,
|
||||
width=available,
|
||||
break_long_words=True,
|
||||
break_on_hyphens=False,
|
||||
) or [""]
|
||||
lines = [f"{prefix}{self._c(_LIGHT_GRAY, wrapped[0])}"]
|
||||
continuation_prefix = " " * _visible_len(prefix)
|
||||
for chunk in wrapped[1:]:
|
||||
lines.append(f"{continuation_prefix}{self._c(_LIGHT_GRAY, chunk)}")
|
||||
return lines
|
||||
|
||||
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 = ""
|
||||
|
||||
meta_lines: list[str] = []
|
||||
meta_lines.extend(_meta_lines("Install:", self.skill_install_cmd))
|
||||
meta_lines.extend(_meta_lines("Global skill:", _display_home_path(self.global_skill_path)))
|
||||
print(top)
|
||||
print(_box_line(title))
|
||||
print(_box_line(ver))
|
||||
for line in meta_lines:
|
||||
print(_box_line(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
|
||||
}
|
||||
@@ -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",
|
||||
],
|
||||
)
|
||||
Reference in New Issue
Block a user