"""Shared helpers for CLI-Anything preview bundles.""" from __future__ import annotations import hashlib import json import mimetypes import os import re from datetime import datetime, timezone from pathlib import Path from typing import Any, Dict, Iterable, Optional PROTOCOL_VERSION = "preview-bundle/v1" TRAJECTORY_PROTOCOL_VERSION = "preview-trajectory/v1" def _slug(value: str) -> str: text = (value or "preview").strip().lower() text = re.sub(r"[^a-z0-9]+", "-", text) text = re.sub(r"-{2,}", "-", text).strip("-") return text or "preview" def _json_dumps(data: Any) -> str: return json.dumps( data, ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=str, ) def hash_data(data: Any) -> str: return hashlib.sha256(_json_dumps(data).encode("utf-8")).hexdigest() def fingerprint_data(data: Any) -> str: return f"sha256:{hash_data(data)}" def fingerprint_file(path: str) -> str: resolved = os.path.abspath(path) stat = os.stat(resolved) return fingerprint_data( { "path": resolved, "size": stat.st_size, "mtime_ns": stat.st_mtime_ns, } ) def bundle_root( software: str, recipe: str, project_path: Optional[str] = None, root_dir: Optional[str] = None, ) -> Path: if root_dir: base = Path(root_dir).expanduser().resolve() elif project_path: base = Path(project_path).expanduser().resolve().parent / ".cli-anything" / "previews" else: base = Path.home() / ".cli-anything" / "previews" return base / _slug(software) / _slug(recipe) def build_cache_key( software: str, recipe: str, bundle_kind: str, source_fingerprint: str, options: Optional[Dict[str, Any]] = None, harness_version: Optional[str] = None, protocol_version: str = PROTOCOL_VERSION, ) -> str: return fingerprint_data( { "protocol_version": protocol_version, "software": software, "recipe": recipe, "bundle_kind": bundle_kind, "source_fingerprint": source_fingerprint, "options": options or {}, "harness_version": harness_version or "", } ) def _iter_manifests(search_root: Path) -> Iterable[Path]: if not search_root.exists(): return [] return sorted(search_root.rglob("manifest.json"), reverse=True) def _load_json(path: Path) -> Dict[str, Any]: with open(path, "r", encoding="utf-8") as fh: return json.load(fh) def find_cached_manifest( software: str, recipe: str, bundle_kind: str, cache_key: str, project_path: Optional[str] = None, root_dir: Optional[str] = None, ) -> Optional[Dict[str, Any]]: root = bundle_root(software, recipe, project_path=project_path, root_dir=root_dir) for manifest_path in _iter_manifests(root): try: manifest = _load_json(manifest_path) except (OSError, json.JSONDecodeError): continue if ( manifest.get("protocol_version") == PROTOCOL_VERSION and manifest.get("software") == software and manifest.get("recipe") == recipe and manifest.get("bundle_kind") == bundle_kind and manifest.get("status") in {"ok", "partial"} and manifest.get("cache_key") == cache_key ): manifest["_manifest_path"] = str(manifest_path.resolve()) manifest["_bundle_dir"] = str(manifest_path.parent.resolve()) manifest["_summary_path"] = str( (manifest_path.parent / manifest.get("summary_path", "summary.json")).resolve() ) return manifest return None def find_latest_manifest( software: str, recipe: Optional[str] = None, bundle_kind: Optional[str] = None, project_path: Optional[str] = None, root_dir: Optional[str] = None, ) -> Optional[Dict[str, Any]]: if root_dir: search_root = Path(root_dir).expanduser().resolve() / _slug(software) elif project_path: search_root = Path(project_path).expanduser().resolve().parent / ".cli-anything" / "previews" / _slug(software) else: search_root = Path.home() / ".cli-anything" / "previews" / _slug(software) if recipe: search_root = search_root / _slug(recipe) for manifest_path in _iter_manifests(search_root): try: manifest = _load_json(manifest_path) except (OSError, json.JSONDecodeError): continue if manifest.get("software") != software: continue if recipe and manifest.get("recipe") != recipe: continue if bundle_kind and manifest.get("bundle_kind") != bundle_kind: continue if manifest.get("status") not in {"ok", "partial"}: continue manifest["_manifest_path"] = str(manifest_path.resolve()) manifest["_bundle_dir"] = str(manifest_path.parent.resolve()) manifest["_summary_path"] = str( (manifest_path.parent / manifest.get("summary_path", "summary.json")).resolve() ) return manifest return None def prepare_bundle( software: str, recipe: str, bundle_kind: str, source_fingerprint: str, options: Optional[Dict[str, Any]] = None, harness_version: Optional[str] = None, project_path: Optional[str] = None, root_dir: Optional[str] = None, force: bool = False, ) -> Dict[str, Any]: cache_key = build_cache_key( software=software, recipe=recipe, bundle_kind=bundle_kind, source_fingerprint=source_fingerprint, options=options or {}, harness_version=harness_version, ) if not force: cached = find_cached_manifest( software=software, recipe=recipe, bundle_kind=bundle_kind, cache_key=cache_key, project_path=project_path, root_dir=root_dir, ) if cached: return { "cached": True, "cache_key": cache_key, "bundle_id": cached.get("bundle_id"), "bundle_dir": cached["_bundle_dir"], "manifest_path": cached["_manifest_path"], "summary_path": os.path.join(cached["_bundle_dir"], cached.get("summary_path", "summary.json")), "manifest": cached, } now = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") bundle_id = f"{now}_{cache_key.split(':', 1)[-1][:8]}_{_slug(recipe)}" out_dir = bundle_root(software, recipe, project_path=project_path, root_dir=root_dir) / bundle_id artifacts_dir = out_dir / "artifacts" artifacts_dir.mkdir(parents=True, exist_ok=False) return { "cached": False, "cache_key": cache_key, "bundle_id": bundle_id, "bundle_dir": str(out_dir.resolve()), "artifacts_dir": str(artifacts_dir.resolve()), "manifest_path": str((out_dir / "manifest.json").resolve()), "summary_path": str((out_dir / "summary.json").resolve()), } def artifact_record( bundle_dir: str, path: str, artifact_id: str, role: str, kind: str, label: str, media_type: Optional[str] = None, **extra: Any, ) -> Dict[str, Any]: bundle_path = Path(bundle_dir).resolve() file_path = Path(path).resolve() rel_path = file_path.relative_to(bundle_path).as_posix() record: Dict[str, Any] = { "artifact_id": artifact_id, "role": role, "kind": kind, "label": label, "media_type": media_type or mimetypes.guess_type(str(file_path))[0] or "application/octet-stream", "path": rel_path, } if file_path.exists(): record["bytes"] = file_path.stat().st_size record.update({k: v for k, v in extra.items() if v is not None}) return record def write_json(path: str, data: Any) -> str: output_path = Path(path).resolve() output_path.parent.mkdir(parents=True, exist_ok=True) with open(output_path, "w", encoding="utf-8") as fh: json.dump(data, fh, indent=2, ensure_ascii=False, default=str) fh.write("\n") return str(output_path) def finalize_bundle( bundle_dir: str, bundle_id: str, bundle_kind: str, software: str, recipe: str, source: Dict[str, Any], artifacts: list[Dict[str, Any]], summary: Dict[str, Any], cache_key: str, generator: Dict[str, Any], status: str = "ok", warnings: Optional[list[str]] = None, context: Optional[Dict[str, Any]] = None, metrics: Optional[Dict[str, Any]] = None, labels: Optional[list[str]] = None, source_bundles: Optional[list[Dict[str, Any]]] = None, ) -> Dict[str, Any]: bundle_path = Path(bundle_dir).resolve() summary_rel = "summary.json" summary_path = write_json(str(bundle_path / summary_rel), summary) manifest = { "protocol_version": PROTOCOL_VERSION, "bundle_id": bundle_id, "bundle_kind": bundle_kind, "software": software, "recipe": recipe, "status": status, "created_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), "cache_key": cache_key, "generator": generator, "source": source, "summary_path": summary_rel, "artifacts": artifacts, } if warnings: manifest["warnings"] = warnings if context: manifest["context"] = context if metrics: manifest["metrics"] = metrics if labels: manifest["labels"] = labels if source_bundles: manifest["source_bundles"] = source_bundles manifest_path = write_json(str(bundle_path / "manifest.json"), manifest) manifest["_manifest_path"] = manifest_path manifest["_bundle_dir"] = str(bundle_path) manifest["_summary_path"] = summary_path return manifest def _clean_none_fields(data: Dict[str, Any]) -> Dict[str, Any]: return {key: value for key, value in data.items() if value is not None} def live_trajectory_path(session_dir: str | Path) -> Path: return Path(session_dir).expanduser().resolve() / "trajectory.json" def load_live_trajectory(session_dir: str | Path) -> Dict[str, Any]: trajectory_path = live_trajectory_path(session_dir) if not trajectory_path.is_file(): return {} return _load_json(trajectory_path) def summarize_trajectory(trajectory: Dict[str, Any], *, recent_steps: int = 3) -> Dict[str, Any]: steps = list(trajectory.get("steps") or []) latest = steps[-1] if steps else {} recent = steps[-max(1, int(recent_steps)):] if steps else [] return _clean_none_fields( { "protocol_version": trajectory.get("protocol_version"), "software": trajectory.get("software"), "recipe": trajectory.get("recipe"), "step_count": trajectory.get("step_count", len(steps)), "current_step_id": trajectory.get("current_step_id"), "latest_command": latest.get("command"), "latest_publish_reason": latest.get("publish_reason"), "latest_bundle_id": latest.get("bundle_id"), "recent_steps": [ _clean_none_fields( { "step_id": item.get("step_id"), "step_index": item.get("step_index"), "bundle_id": item.get("bundle_id"), "publish_reason": item.get("publish_reason"), "command": item.get("command"), "command_finished_at": item.get("command_finished_at"), "status": item.get("status"), "cached": item.get("cached"), } ) for item in recent ], } ) def build_live_history_item( bundle_manifest: Dict[str, Any], *, step_id: Optional[str] = None, step_index: Optional[int] = None, publish_reason: Optional[str] = None, command: Optional[str] = None, command_started_at: Optional[str] = None, command_finished_at: Optional[str] = None, source_fingerprint: Optional[str] = None, stage_label: Optional[str] = None, note: Optional[str] = None, ) -> Dict[str, Any]: source = bundle_manifest.get("source") or {} resolved_command = command or (bundle_manifest.get("generator") or {}).get("command") resolved_fingerprint = ( source_fingerprint or source.get("project_fingerprint") or source.get("capture_fingerprint") ) created_at = bundle_manifest.get("created_at") return _clean_none_fields( { "step_id": step_id, "step_index": step_index, "bundle_id": bundle_manifest.get("bundle_id"), "bundle_dir": bundle_manifest.get("_bundle_dir"), "manifest_path": bundle_manifest.get("_manifest_path"), "summary_path": bundle_manifest.get("_summary_path"), "created_at": created_at, "status": bundle_manifest.get("status"), "cached": bool(bundle_manifest.get("cached")), "publish_reason": publish_reason, "command": resolved_command, "command_started_at": command_started_at or created_at, "command_finished_at": command_finished_at or created_at, "source_fingerprint": resolved_fingerprint, "stage_label": stage_label, "note": note, } ) def append_live_trajectory( session_dir: str | Path, *, software: str, recipe: str, bundle_manifest: Dict[str, Any], publish_reason: str, project_path: Optional[str] = None, project_name: Optional[str] = None, session_name: Optional[str] = None, command: Optional[str] = None, command_started_at: Optional[str] = None, command_finished_at: Optional[str] = None, source_fingerprint: Optional[str] = None, stage_label: Optional[str] = None, note: Optional[str] = None, ) -> Dict[str, Any]: session_path = Path(session_dir).expanduser().resolve() existing = load_live_trajectory(session_path) steps = list(existing.get("steps") or []) finished_at = ( command_finished_at or bundle_manifest.get("created_at") or datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") ) started_at = command_started_at or finished_at step_index = len(steps) + 1 step_id = f"step-{step_index:04d}" step = build_live_history_item( bundle_manifest, step_id=step_id, step_index=step_index, publish_reason=publish_reason, command=command, command_started_at=started_at, command_finished_at=finished_at, source_fingerprint=source_fingerprint, stage_label=stage_label, note=note, ) steps.append(step) trajectory: Dict[str, Any] = dict(existing) trajectory.update( _clean_none_fields( { "protocol_version": TRAJECTORY_PROTOCOL_VERSION, "software": software, "recipe": recipe, "session_name": session_name or session_path.name, "project_path": project_path, "project_name": project_name, "created_at": existing.get("created_at", finished_at), "updated_at": finished_at, "step_count": len(steps), "current_step_id": step_id, } ) ) trajectory["steps"] = steps trajectory_path = write_json(str(live_trajectory_path(session_path)), trajectory) trajectory["_trajectory_path"] = trajectory_path trajectory["latest_step"] = step return trajectory