Add Mermaid Live Editor harness

This commit is contained in:
getmored-create
2026-03-16 13:08:53 +09:00
parent 1f7dd63f91
commit b7186f82ff
19 changed files with 956 additions and 8 deletions

4
.gitignore vendored
View File

@@ -33,6 +33,7 @@
!/anygen/
!/zoom/
!/drawio/
!/mermaid/
# Step 5: Inside each software dir, ignore everything (including dotfiles)
/gimp/*
@@ -57,6 +58,8 @@
/zoom/.*
/drawio/*
/drawio/.*
/mermaid/*
/mermaid/.*
# Step 6: ...except agent-harness/
!/gimp/agent-harness/
@@ -70,6 +73,7 @@
!/anygen/agent-harness/
!/zoom/agent-harness/
!/drawio/agent-harness/
!/mermaid/agent-harness/
# Step 7: Ignore build artifacts within allowed dirs
**/__pycache__/

View File

@@ -7,7 +7,7 @@ CLI-Anything: Bridging the Gap Between AI Agents and the World's Software</stron
<p align="center">
<a href="#-quick-start"><img src="https://img.shields.io/badge/Quick_Start-5_min-blue?style=for-the-badge" alt="Quick Start"></a>
<a href="#-demonstrations"><img src="https://img.shields.io/badge/Demos-11_Apps-green?style=for-the-badge" alt="Demos"></a>
<a href="#-demonstrations"><img src="https://img.shields.io/badge/Demos-12_Apps-green?style=for-the-badge" alt="Demos"></a>
<a href="#-test-results"><img src="https://img.shields.io/badge/Tests-1%2C508_Passing-brightgreen?style=for-the-badge" alt="Tests"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-yellow?style=for-the-badge" alt="License"></a>
</p>
@@ -361,7 +361,7 @@ AI agents are great at reasoning but terrible at using real professional softwar
| 💸 "UI automation breaks constantly" | No screenshots, no clicking, no RPA fragility. Pure command-line reliability with structured interfaces |
| 📊 "Agents need structured data" | Built-in JSON output for seamless agent consumption + human-readable formats for debugging |
| 🔧 "Custom integrations are expensive" | One Claude plugin auto-generates CLIs for ANY codebase through proven 7-phase pipeline |
| ⚡ "Prototype vs Production gap" | 1,508+ tests with real software validation. Battle-tested across 11 major applications |
| ⚡ "Prototype vs Production gap" | 1,518+ tests with real software validation. Battle-tested across 12 major applications |
---
@@ -450,7 +450,7 @@ All CLIs organized under cli_anything.* namespace — conflict-free, pip-install
CLI-Anything works on any software with a codebase — no domain restrictions or architectural limitations.
### 🏭 Professional-Grade Testing
Tested across 11 diverse, complex applications spanning creative, productivity, communication, diagramming, and AI content generation domains previously inaccessible to AI agents.
Tested across 12 diverse, complex applications spanning creative, productivity, communication, diagramming, and AI content generation domains previously inaccessible to AI agents.
### 🎨 Diverse Domain Coverage
From creative workflows (image editing, 3D modeling, vector graphics) to production tools (audio, office, live streaming, video editing).
@@ -537,6 +537,13 @@ Each application received complete, production-ready CLI interfaces — not demo
<td align="center">✅ 138</td>
</tr>
<tr>
<td align="center"><strong>🧜 Mermaid Live Editor</strong></td>
<td>Diagramming</td>
<td><code>cli-anything-mermaid</code></td>
<td>Mermaid state + mermaid.ink renderer</td>
<td align="center">✅ 10</td>
</tr>
<tr>
<td align="center"><strong>✨ AnyGen</strong></td>
<td>AI Content Generation</td>
<td><code>cli-anything-anygen</code></td>
@@ -545,11 +552,11 @@ Each application received complete, production-ready CLI interfaces — not demo
</tr>
<tr>
<td align="center" colspan="4"><strong>Total</strong></td>
<td align="center"><strong>✅ 1,508</strong></td>
<td align="center"><strong>✅ 1,518</strong></td>
</tr>
</table>
> **100% pass rate** across all 1,508 tests — 1,073 unit tests + 435 end-to-end tests.
> **100% pass rate** across all 1,518 tests — 1,078 unit tests + 440 end-to-end tests.
---
@@ -576,9 +583,10 @@ kdenlive 155 passed ✅ (111 unit + 44 e2e)
shotcut 154 passed ✅ (110 unit + 44 e2e)
zoom 22 passed ✅ (22 unit + 0 e2e)
drawio 138 passed ✅ (116 unit + 22 e2e)
mermaid 10 passed ✅ (5 unit + 5 e2e)
anygen 50 passed ✅ (40 unit + 10 e2e)
──────────────────────────────────────────────────────────────────────────────
TOTAL 1,508 passed ✅ 100% pass rate
TOTAL 1,518 passed ✅ 100% pass rate
```
---
@@ -637,6 +645,7 @@ cli-anything/
├── 🎬 shotcut/agent-harness/ # Shotcut CLI (154 tests)
├── 📞 zoom/agent-harness/ # Zoom CLI (22 tests)
├── 📐 drawio/agent-harness/ # Draw.io CLI (138 tests)
├── 🧜 mermaid/agent-harness/ # Mermaid Live Editor CLI (10 tests)
└── ✨ anygen/agent-harness/ # AnyGen CLI (50 tests)
```
@@ -738,7 +747,7 @@ HARNESS.md is our definitive SOP for making any software agent-accessible via au
It encodes proven patterns and methodologies refined through automated generation processes.
The playbook distills key insights from successfully building all 11 diverse, production-ready harnesses.
The playbook distills key insights from successfully building all 12 diverse, production-ready harnesses.
### Critical Lessons
@@ -863,7 +872,7 @@ MIT License — free to use, modify, and distribute.
**CLI-Anything***Make any software with a codebase Agent-native.*
<sub>A methodology for the age of AI agents | 11 professional software demos | 1,508 passing tests</sub>
<sub>A methodology for the age of AI agents | 12 professional software demos | 1,518 passing tests</sub>
<br>

View File

@@ -0,0 +1,60 @@
# Mermaid Live Editor - CLI Harness Analysis
## Software Overview
**Mermaid Live Editor** is the official browser editor for Mermaid diagrams. It edits Mermaid source text, previews diagrams in real time, generates shareable URLs, and renders diagrams through the Mermaid renderer service.
## Architecture
### State model
The live editor centers around a serialized state object. The fields relevant to the CLI are:
- `code`: Mermaid diagram source
- `mermaid`: Mermaid config JSON string
- `updateDiagram`: whether the view should refresh
- `rough`: rough-sketch rendering toggle
- `panZoom`: pan/zoom enablement
- `grid`: editor grid toggle
For the CLI harness, this state is stored as a JSON project file with a `.mermaid.json` suffix.
### Native share/render format
The live editor serializes its state as:
1. compact JSON
2. zlib compression
3. URL-safe base64
4. `pako:` prefix
That serialized token is used by the official endpoints:
- Edit URL: `https://mermaid.live/edit#<serialized>`
- View URL: `https://mermaid.live/view#<serialized>`
- SVG URL: `https://mermaid.ink/svg/<serialized>`
- PNG URL: `https://mermaid.ink/img/<serialized>?type=png`
## Backend strategy
This harness uses the same renderer path that Mermaid Live Editor uses in production. The backend module generates the same serialized state payload and invokes the official Mermaid renderer endpoint.
That keeps the harness aligned with the actual application instead of inventing a parallel diagram format.
## CLI scope
- `project new/open/save/info/samples`
- `diagram set/show`
- `export render/share`
- `session status/undo/redo`
- REPL mode by default
## Expected validation
The CLI should prove that an agent can:
1. create a Mermaid project
2. update diagram text
3. inspect current source
4. generate live-editor share/view URLs
5. render a real SVG and PNG artifact

View File

@@ -0,0 +1,71 @@
# cli-anything-mermaid
A CLI harness for **Mermaid Live Editor** that lets agents create Mermaid state files, inspect diagram source, generate share URLs, and render diagrams through the official Mermaid renderer path.
## Prerequisites
- Python 3.10+
- Internet access to `https://mermaid.live` and `https://mermaid.ink`, or a compatible self-hosted Mermaid renderer service
The upstream Mermaid Live Editor defaults to `mermaid.ink` for render links. This harness uses the same serialized state format and endpoint shape.
## Installation
```bash
cd mermaid/agent-harness
python -m pip install -e .[dev]
```
## Usage
### One-shot commands
```bash
# Create a new Mermaid project
cli-anything-mermaid project new --sample flowchart -o diagram.mermaid.json
# Replace the current diagram source
cli-anything-mermaid --project diagram.mermaid.json diagram set --text "graph TD; A[Test] --> B[Works]"
# Show a shareable URL
cli-anything-mermaid --project diagram.mermaid.json export share --mode view
# Render via the Mermaid renderer backend
cli-anything-mermaid --project diagram.mermaid.json export render output.svg -f svg --overwrite
cli-anything-mermaid --project diagram.mermaid.json export render output.png -f png --overwrite
```
### JSON mode
```bash
cli-anything-mermaid --json project new -o diagram.mermaid.json
cli-anything-mermaid --json --project diagram.mermaid.json export share --mode edit
cli-anything-mermaid --json --project diagram.mermaid.json export render output.svg -f svg --overwrite
```
### Interactive REPL
```bash
cli-anything-mermaid
cli-anything-mermaid --project diagram.mermaid.json
```
## Command reference
- `project new/open/save/info/samples`
- `diagram set/show`
- `export render/share`
- `session status/undo/redo`
## Notes
- Project files are stored as `.mermaid.json`
- Rendered output is produced through the same serialized state payload the live editor uses
- `export share` emits URLs that open in Mermaid Live Editor
## Running tests
```bash
cd mermaid/agent-harness
python -m pytest cli_anything/mermaid/tests/ -v --tb=no
```

View File

@@ -0,0 +1,2 @@
"""Mermaid CLI harness package."""

View File

@@ -0,0 +1,5 @@
from .mermaid_cli import main
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,2 @@
"""Core Mermaid CLI modules."""

View File

@@ -0,0 +1,24 @@
"""Diagram text operations."""
from __future__ import annotations
from .session import Session
def set_diagram(session: Session, text: str) -> dict:
if not session.is_open:
raise RuntimeError("No project is open")
session.set_code(text)
return {
"action": "set_diagram",
"line_count": len(text.splitlines()),
}
def show_diagram(session: Session) -> dict:
if not session.is_open or session.state is None:
raise RuntimeError("No project is open")
return {
"action": "show_diagram",
"code": session.state["code"],
}

View File

@@ -0,0 +1,30 @@
"""Render and share operations for Mermaid Live Editor state."""
from __future__ import annotations
import os
from .session import Session
from ..utils import mermaid_backend
def render(session: Session, output_path: str, fmt: str = "svg", overwrite: bool = False) -> dict:
if not session.is_open or session.state is None:
raise RuntimeError("No project is open")
if os.path.exists(output_path) and not overwrite:
raise FileExistsError(f"Output already exists: {output_path}")
serialized = mermaid_backend.serialize_state(session.state)
result = mermaid_backend.render_to_file(serialized, output_path, fmt)
result["action"] = "render"
return result
def share(session: Session, mode: str = "edit") -> dict:
if not session.is_open or session.state is None:
raise RuntimeError("No project is open")
serialized = mermaid_backend.serialize_state(session.state)
return {
"action": "share",
"mode": mode,
"url": mermaid_backend.build_live_url(serialized, mode),
}

View File

@@ -0,0 +1,47 @@
"""Project commands for Mermaid state files."""
from __future__ import annotations
from .session import SAMPLE_DIAGRAMS, Session
def new_project(session: Session, sample: str = "flowchart", theme: str = "default") -> dict:
state = session.new_project(sample=sample, theme=theme)
return {
"action": "new_project",
"sample": sample,
"theme": theme,
"line_count": len(state["code"].splitlines()),
}
def open_project(session: Session, path: str) -> dict:
state = session.open_project(path)
return {
"action": "open_project",
"path": session.project_path,
"line_count": len(state["code"].splitlines()),
}
def save_project(session: Session, path: str | None = None) -> dict:
saved = session.save_project(path)
return {
"action": "save_project",
"path": saved,
}
def project_info(session: Session) -> dict:
if not session.is_open or session.state is None:
raise RuntimeError("No project is open")
return {
"project_path": session.project_path,
"line_count": len(session.state["code"].splitlines()),
"theme_json": session.state["mermaid"],
"modified": session.modified,
}
def list_samples() -> dict:
return SAMPLE_DIAGRAMS.copy()

View File

@@ -0,0 +1,112 @@
"""Session state for Mermaid projects."""
from __future__ import annotations
import copy
import json
import os
from dataclasses import dataclass, field
SAMPLE_DIAGRAMS = {
"flowchart": "flowchart TD\n A[Start] --> B{Ready?}\n B -->|Yes| C[Run test]\n B -->|No| D[Fix input]\n",
"sequence": "sequenceDiagram\n participant U as User\n participant C as CLI\n U->>C: Run command\n C-->>U: JSON result\n",
"er": "erDiagram\n USER ||--o{ ORDER : places\n USER {\n int id\n string email\n }\n ORDER {\n int id\n decimal total\n }\n",
}
def default_state(sample: str = "flowchart", theme: str = "default") -> dict:
if sample not in SAMPLE_DIAGRAMS:
raise ValueError(f"Unknown sample: {sample}")
return {
"code": SAMPLE_DIAGRAMS[sample],
"mermaid": json.dumps({"theme": theme}, indent=2),
"updateDiagram": True,
"rough": False,
"panZoom": True,
"grid": True,
}
@dataclass
class Session:
state: dict | None = None
project_path: str | None = None
modified: bool = False
undo_stack: list[dict] = field(default_factory=list)
redo_stack: list[dict] = field(default_factory=list)
@property
def is_open(self) -> bool:
return self.state is not None
def checkpoint(self) -> None:
if self.state is None:
return
self.undo_stack.append(copy.deepcopy(self.state))
self.redo_stack.clear()
def new_project(self, sample: str = "flowchart", theme: str = "default") -> dict:
self.state = default_state(sample=sample, theme=theme)
self.project_path = None
self.modified = True
self.undo_stack.clear()
self.redo_stack.clear()
return copy.deepcopy(self.state)
def open_project(self, path: str) -> dict:
if not os.path.exists(path):
raise FileNotFoundError(f"Project not found: {path}")
with open(path, "r", encoding="utf-8") as fh:
self.state = json.load(fh)
self.project_path = os.path.abspath(path)
self.modified = False
self.undo_stack.clear()
self.redo_stack.clear()
return copy.deepcopy(self.state)
def save_project(self, path: str | None = None) -> str:
if self.state is None:
raise RuntimeError("No project is open")
target = os.path.abspath(path or self.project_path or "diagram.mermaid.json")
parent = os.path.dirname(target)
if parent:
os.makedirs(parent, exist_ok=True)
with open(target, "w", encoding="utf-8") as fh:
json.dump(self.state, fh, indent=2)
self.project_path = target
self.modified = False
return target
def set_code(self, code: str) -> None:
if self.state is None:
raise RuntimeError("No project is open")
self.checkpoint()
self.state["code"] = code
self.state["updateDiagram"] = True
self.modified = True
def status(self) -> dict:
return {
"project_open": self.is_open,
"project_path": self.project_path,
"modified": self.modified,
"undo_depth": len(self.undo_stack),
"redo_depth": len(self.redo_stack),
}
def undo(self) -> bool:
if self.state is None or not self.undo_stack:
return False
self.redo_stack.append(copy.deepcopy(self.state))
self.state = self.undo_stack.pop()
self.modified = True
return True
def redo(self) -> bool:
if self.state is None or not self.redo_stack:
return False
self.undo_stack.append(copy.deepcopy(self.state))
self.state = self.redo_stack.pop()
self.modified = True
return True

View File

@@ -0,0 +1,242 @@
"""Stateful CLI harness for Mermaid Live Editor."""
from __future__ import annotations
import json
import click
from .core import diagram as diagram_mod
from .core import export as export_mod
from .core import project as project_mod
from .core.session import Session
from .utils.repl_skin import ReplSkin
_session: Session | None = None
_json_output = False
def get_session() -> Session:
global _session
if _session is None:
_session = Session()
return _session
def emit(data, message: str | None = None) -> None:
if _json_output:
click.echo(json.dumps(data, indent=2))
return
if message:
click.echo(message)
if isinstance(data, dict):
for key, value in data.items():
click.echo(f"{key}: {value}")
else:
click.echo(str(data))
@click.group(invoke_without_command=True)
@click.option("--json", "json_mode", is_flag=True, help="Emit machine-readable JSON")
@click.option("--project", "project_path", default=None, help="Open a Mermaid project file")
@click.pass_context
def cli(ctx, json_mode: bool, project_path: str | None) -> None:
"""CLI harness for Mermaid Live Editor state files and renderer URLs."""
global _json_output, _session
_json_output = json_mode
_session = Session()
if project_path:
_session.open_project(project_path)
@ctx.call_on_close
def _auto_save() -> None:
if project_path and _session and _session.is_open and _session.modified:
_session.save_project()
if ctx.invoked_subcommand is None:
ctx.invoke(repl)
@cli.group()
def project() -> None:
"""Project lifecycle commands."""
@project.command("new")
@click.option("--sample", default="flowchart", help="Sample diagram preset")
@click.option("--theme", default="default", help="Mermaid theme")
@click.option("-o", "--output", "output_path", default=None, help="Optional file path to save")
def project_new(sample: str, theme: str, output_path: str | None) -> None:
session = get_session()
result = project_mod.new_project(session, sample=sample, theme=theme)
if output_path:
saved = project_mod.save_project(session, output_path)
result["path"] = saved["path"]
emit(result, "Created Mermaid project")
@project.command("open")
@click.argument("path")
def project_open(path: str) -> None:
emit(project_mod.open_project(get_session(), path), "Opened Mermaid project")
@project.command("save")
@click.argument("path", required=False)
def project_save(path: str | None) -> None:
emit(project_mod.save_project(get_session(), path), "Saved Mermaid project")
@project.command("info")
def project_info() -> None:
emit(project_mod.project_info(get_session()), "Project info")
@project.command("samples")
def project_samples() -> None:
emit(project_mod.list_samples(), "Available samples")
@cli.group()
def diagram() -> None:
"""Diagram source commands."""
@diagram.command("set")
@click.option("--text", default=None, help="Inline Mermaid source")
@click.option("--file", "file_path", default=None, help="Read Mermaid source from file")
def diagram_set(text: str | None, file_path: str | None) -> None:
session = get_session()
if not session.is_open:
raise click.ClickException("No project is open")
if bool(text) == bool(file_path):
raise click.ClickException("Provide exactly one of --text or --file")
if file_path:
with open(file_path, "r", encoding="utf-8") as fh:
text = fh.read()
emit(diagram_mod.set_diagram(session, text or ""), "Updated Mermaid source")
@diagram.command("show")
def diagram_show() -> None:
result = diagram_mod.show_diagram(get_session())
if _json_output:
emit(result)
else:
click.echo(result["code"])
@cli.group()
def export() -> None:
"""Render and share commands."""
@export.command("render")
@click.argument("output_path")
@click.option("--format", "-f", "fmt", type=click.Choice(["svg", "png"]), default="svg")
@click.option("--overwrite", is_flag=True, help="Overwrite existing output")
def export_render(output_path: str, fmt: str, overwrite: bool) -> None:
emit(export_mod.render(get_session(), output_path, fmt=fmt, overwrite=overwrite), "Rendered output")
@export.command("share")
@click.option("--mode", type=click.Choice(["edit", "view"]), default="edit")
def export_share(mode: str) -> None:
emit(export_mod.share(get_session(), mode=mode), "Generated share URL")
@cli.group()
def session() -> None:
"""Session state commands."""
@session.command("status")
def session_status() -> None:
emit(get_session().status(), "Session status")
@session.command("undo")
def session_undo() -> None:
success = get_session().undo()
emit({"action": "undo", "success": success}, "Undo complete" if success else "Nothing to undo")
@session.command("redo")
def session_redo() -> None:
success = get_session().redo()
emit({"action": "redo", "success": success}, "Redo complete" if success else "Nothing to redo")
REPL_COMMANDS = {
"new [sample]": "Create a Mermaid project",
"open <path>": "Open a Mermaid project file",
"save [path]": "Save the current project",
"show": "Print the current Mermaid source",
"set <text>": "Replace the Mermaid source text",
"render <path> [svg|png]": "Render an artifact through the Mermaid renderer",
"share [edit|view]": "Generate a Mermaid Live URL",
"status": "Show session status",
"undo": "Undo the last source change",
"redo": "Redo the last undone change",
"quit": "Exit the REPL",
}
@cli.command()
def repl() -> None:
"""Interactive REPL."""
session = get_session()
skin = ReplSkin("mermaid", version="1.0.0")
skin.print_banner()
prompt_session = skin.create_prompt_session()
while True:
project_name = session.project_path or "(unsaved)" if session.is_open else ""
line = skin.get_input(prompt_session, project_name=project_name, modified=session.modified).strip()
if not line:
continue
if line in {"quit", "exit"}:
skin.print_goodbye()
break
if line == "help":
skin.help(REPL_COMMANDS)
continue
try:
parts = line.split()
command, args = parts[0], parts[1:]
if command == "new":
sample = args[0] if args else "flowchart"
emit(project_mod.new_project(session, sample=sample), "Created Mermaid project")
elif command == "open":
emit(project_mod.open_project(session, args[0]), "Opened Mermaid project")
elif command == "save":
path = args[0] if args else None
emit(project_mod.save_project(session, path), "Saved Mermaid project")
elif command == "show":
click.echo(diagram_mod.show_diagram(session)["code"])
elif command == "set":
emit(diagram_mod.set_diagram(session, " ".join(args)), "Updated Mermaid source")
elif command == "render":
fmt = args[1] if len(args) > 1 else "svg"
emit(export_mod.render(session, args[0], fmt=fmt, overwrite=True), "Rendered output")
elif command == "share":
mode = args[0] if args else "edit"
emit(export_mod.share(session, mode=mode), "Generated share URL")
elif command == "status":
emit(session.status(), "Session status")
elif command == "undo":
emit({"action": "undo", "success": session.undo()})
elif command == "redo":
emit({"action": "redo", "success": session.redo()})
else:
skin.error(f"Unknown command: {command}")
except Exception as exc: # pragma: no cover
skin.error(str(exc))
def main() -> None:
cli()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,93 @@
# Mermaid CLI - Test Plan & Results
## Test Plan
### Test Inventory Plan
- `test_core.py`: 5 unit tests planned
- `test_full_e2e.py`: 5 E2E tests planned
### Unit Test Plan
**Session and project state**
- default state generation
- project save/open roundtrip
- undo/redo state transitions
- expected count: 3
**Backend serialization and URLs**
- serialized `pako:` state generation
- Mermaid renderer URL generation
- Mermaid live edit/view URL generation
- expected count: 2
**Share command behavior**
- edit/view share payload generation
- expected count: 1
### E2E Test Plan
**Installed CLI**
- `--help` output from the installed command
- JSON project creation through subprocess
**Real artifact generation**
- render SVG through the Mermaid renderer service
- render PNG through the Mermaid renderer service
- verify SVG content and PNG magic bytes
**Workflow validation**
- create a project
- replace diagram text
- auto-save through `--project`
- generate a live-editor URL
### Realistic Workflow Scenarios
**Workflow name**: Mermaid smoke path
**Simulates**: an agent creating and sharing a small flowchart
**Operations chained**: create project, set code, render SVG, render PNG, generate view URL
**Verified**: saved project file, renderer response, SVG markup, PNG magic bytes, live URL format
## Test Results
Command run:
```bash
python -m pytest cli_anything/mermaid/tests/ -v --tb=no
```
Latest result:
```text
============================= test session starts =============================
platform win32 -- Python 3.12.10, pytest-9.0.2, pluggy-1.6.0 -- C:\Users\gram\AppData\Local\Programs\Python\Python312\python.exe
cachedir: .pytest_cache
rootdir: C:\Users\gram\Downloads\코덱스 프로젝트\오픈소스 CLI변환\CLI-Anything\mermaid\agent-harness
plugins: cov-7.0.0
collecting ... collected 10 items
cli_anything/mermaid/tests/test_core.py::test_default_state PASSED
cli_anything/mermaid/tests/test_core.py::test_save_open_roundtrip PASSED
cli_anything/mermaid/tests/test_core.py::test_undo_redo PASSED
cli_anything/mermaid/tests/test_core.py::test_backend_serialization_and_urls PASSED
cli_anything/mermaid/tests/test_core.py::test_share_payload PASSED
cli_anything/mermaid/tests/test_full_e2e.py::TestMermaidCLI::test_help PASSED
cli_anything/mermaid/tests/test_full_e2e.py::TestMermaidCLI::test_project_new_json PASSED
cli_anything/mermaid/tests/test_full_e2e.py::TestMermaidCLI::test_render_svg PASSED
cli_anything/mermaid/tests/test_full_e2e.py::TestMermaidCLI::test_render_png PASSED
cli_anything/mermaid/tests/test_full_e2e.py::TestMermaidCLI::test_share_view_url PASSED
============================= 10 passed in 10.60s =============================
```
Summary:
- Total tests: 10
- Pass rate: 100%
- Execution time: 10.60s
Coverage notes:
- The harness verifies real SVG and PNG artifacts from the Mermaid renderer service.
- The harness does not currently cover gist import, browser-only editor options, or external Mermaid Chart save flows.

View File

@@ -0,0 +1,55 @@
import json
import os
import tempfile
from cli_anything.mermaid.core import export as export_mod
from cli_anything.mermaid.core import project as project_mod
from cli_anything.mermaid.core.session import Session, default_state
from cli_anything.mermaid.utils import mermaid_backend
def test_default_state():
state = default_state()
assert "flowchart TD" in state["code"]
assert json.loads(state["mermaid"])["theme"] == "default"
def test_save_open_roundtrip():
session = Session()
project_mod.new_project(session, sample="sequence")
with tempfile.NamedTemporaryFile(suffix=".mermaid.json", delete=False) as fh:
path = fh.name
try:
project_mod.save_project(session, path)
other = Session()
result = project_mod.open_project(other, path)
assert result["line_count"] > 0
assert "sequenceDiagram" in other.state["code"]
finally:
os.unlink(path)
def test_undo_redo():
session = Session()
session.new_project()
original = session.state["code"]
session.set_code("graph TD\n A --> B\n")
assert session.undo() is True
assert session.state["code"] == original
assert session.redo() is True
assert "A --> B" in session.state["code"]
def test_backend_serialization_and_urls():
serialized = mermaid_backend.serialize_state(default_state())
assert serialized.startswith("pako:")
assert mermaid_backend.build_render_url(serialized, "svg").startswith("https://")
assert mermaid_backend.build_live_url(serialized, "view").startswith("https://")
def test_share_payload():
session = Session()
session.new_project()
result = export_mod.share(session, mode="edit")
assert result["mode"] == "edit"
assert result["url"].startswith("https://mermaid.live/edit#")

View File

@@ -0,0 +1,64 @@
import json
import os
import shutil
import subprocess
import sys
def _resolve_cli(name: str):
path = shutil.which(name)
if path:
return [path]
return [sys.executable, "-m", "cli_anything.mermaid"]
class TestMermaidCLI:
CLI = _resolve_cli("cli-anything-mermaid")
def _run(self, args):
return subprocess.run(self.CLI + args, capture_output=True, text=True, check=True, timeout=60)
def test_help(self):
result = self._run(["--help"])
assert "Mermaid" in result.stdout
def test_project_new_json(self, tmp_path):
path = str(tmp_path / "demo.mermaid.json")
result = self._run(["--json", "project", "new", "-o", path])
data = json.loads(result.stdout)
assert data["action"] == "new_project"
assert os.path.exists(path)
def test_render_svg(self, tmp_path):
project_path = str(tmp_path / "demo.mermaid.json")
output_path = str(tmp_path / "demo.svg")
self._run(["project", "new", "-o", project_path])
self._run(["--project", project_path, "diagram", "set", "--text", "graph TD; A[Test] --> B[Works]"])
result = self._run(
["--json", "--project", project_path, "export", "render", output_path, "-f", "svg", "--overwrite"]
)
data = json.loads(result.stdout)
assert data["action"] == "render"
assert os.path.exists(output_path)
with open(output_path, "r", encoding="utf-8") as fh:
assert "<svg" in fh.read(200)
def test_render_png(self, tmp_path):
project_path = str(tmp_path / "demo.mermaid.json")
output_path = str(tmp_path / "demo.png")
self._run(["project", "new", "-o", project_path])
self._run(["--project", project_path, "diagram", "set", "--text", "graph TD; A[Test] --> B[Works]"])
result = self._run(
["--json", "--project", project_path, "export", "render", output_path, "-f", "png", "--overwrite"]
)
data = json.loads(result.stdout)
assert data["format"] == "png"
with open(output_path, "rb") as fh:
assert fh.read(4) == b"\x89PNG"
def test_share_view_url(self, tmp_path):
project_path = str(tmp_path / "demo.mermaid.json")
self._run(["project", "new", "-o", project_path])
result = self._run(["--json", "--project", project_path, "export", "share", "--mode", "view"])
data = json.loads(result.stdout)
assert data["url"].startswith("https://mermaid.live/view#")

View File

@@ -0,0 +1,2 @@
"""Mermaid utility modules."""

View File

@@ -0,0 +1,53 @@
"""Backend helpers for Mermaid Live Editor render/share state."""
from __future__ import annotations
import base64
import json
import os
import urllib.request
import zlib
RENDER_BASE = os.environ.get("MERMAID_RENDERER_URL", "https://mermaid.ink").rstrip("/")
LIVE_BASE = os.environ.get("MERMAID_LIVE_URL", "https://mermaid.live").rstrip("/")
def serialize_state(state: dict) -> str:
payload = json.dumps(state, separators=(",", ":")).encode("utf-8")
compressed = zlib.compress(payload, level=9)
encoded = base64.urlsafe_b64encode(compressed).decode("ascii").rstrip("=")
return f"pako:{encoded}"
def build_render_url(serialized: str, fmt: str) -> str:
if fmt == "svg":
return f"{RENDER_BASE}/svg/{serialized}"
if fmt == "png":
return f"{RENDER_BASE}/img/{serialized}?type=png"
raise ValueError(f"Unsupported format: {fmt}")
def build_live_url(serialized: str, mode: str) -> str:
if mode not in {"edit", "view"}:
raise ValueError(f"Unsupported share mode: {mode}")
return f"{LIVE_BASE}/{mode}#{serialized}"
def render_to_file(serialized: str, output_path: str, fmt: str) -> dict:
url = build_render_url(serialized, fmt)
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
with urllib.request.urlopen(req, timeout=60) as response:
data = response.read()
parent = os.path.dirname(os.path.abspath(output_path))
if parent:
os.makedirs(parent, exist_ok=True)
with open(output_path, "wb") as fh:
fh.write(data)
return {
"output": os.path.abspath(output_path),
"format": fmt,
"method": "mermaid-renderer",
"file_size": os.path.getsize(output_path),
"url": url,
}

View File

@@ -0,0 +1,44 @@
"""Minimal REPL skin compatible with CLI-Anything REPL usage."""
from __future__ import annotations
from prompt_toolkit import PromptSession
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
from prompt_toolkit.history import InMemoryHistory
class ReplSkin:
def __init__(self, software: str, version: str = "1.0.0"):
self.software = software
self.version = version
def print_banner(self) -> None:
print(f"cli-anything-{self.software} v{self.version}")
print("Type help for commands, quit to exit")
def create_prompt_session(self) -> PromptSession:
return PromptSession(history=InMemoryHistory(), auto_suggest=AutoSuggestFromHistory())
def get_input(self, session: PromptSession, project_name: str = "", modified: bool = False) -> str:
suffix = "*" if modified else ""
ctx = f"[{project_name}{suffix}]" if project_name else ""
return session.prompt(f"{self.software}{ctx}> ")
def help(self, commands: dict[str, str]) -> None:
for command, desc in commands.items():
print(f"{command}: {desc}")
def success(self, message: str) -> None:
print(f"OK {message}")
def error(self, message: str) -> None:
print(f"ERROR {message}")
def warning(self, message: str) -> None:
print(f"WARN {message}")
def info(self, message: str) -> None:
print(f"INFO {message}")
def print_goodbye(self) -> None:
print("Goodbye!")

View File

@@ -0,0 +1,29 @@
from setuptools import find_namespace_packages, setup
with open("cli_anything/mermaid/README.md", "r", encoding="utf-8") as fh:
long_description = fh.read()
setup(
name="cli-anything-mermaid",
version="1.0.0",
description="CLI harness for Mermaid Live Editor state files and renderer URLs",
long_description=long_description,
long_description_content_type="text/markdown",
url="https://github.com/HKUDS/CLI-Anything",
packages=find_namespace_packages(include=["cli_anything.*"]),
install_requires=[
"click>=8.0.0",
"prompt-toolkit>=3.0.0",
],
extras_require={
"dev": [
"pytest>=9.0.0",
]
},
entry_points={
"console_scripts": [
"cli-anything-mermaid=cli_anything.mermaid.mermaid_cli:main",
]
},
python_requires=">=3.10",
)