Merge main into fix/cleanup-and-docs-1dcf1e0: resolve conflicts, fix test totals

Include both openscreen and cloudanalyzer in test summary.
Correctly recount all totals across badge, HTML table, prose, and test summary.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
yuhao
2026-04-09 15:25:17 +00:00
82 changed files with 9158 additions and 6 deletions
+16
View File
@@ -78,6 +78,9 @@
!/novita/
!/ollama/
!/browser/
!/seaclip/
!/pm2/
!/chromadb/
!/musescore/
!/krita/
!/freecad/
@@ -131,6 +134,12 @@
/ollama/.*
/browser/*
/browser/.*
/seaclip/*
/seaclip/.*
/pm2/*
/pm2/.*
/chromadb/*
/chromadb/.*
/musescore/*
/musescore/.*
/krita/*
@@ -149,6 +158,8 @@
/cloudcompare/.*
/openscreen/*
/openscreen/.*
/cloudanalyzer/*
/cloudanalyzer/.*
/wiremock/*
/wiremock/.*
/exa/*
@@ -177,6 +188,9 @@
!/novita/agent-harness/
!/ollama/agent-harness/
!/browser/agent-harness/
!/seaclip/agent-harness/
!/pm2/agent-harness/
!/chromadb/agent-harness/
!/musescore/agent-harness/
!/krita/agent-harness/
!/freecad/agent-harness/
@@ -187,6 +201,8 @@
!/renderdoc/agent-harness/
!/cloudcompare/agent-harness/
!/openscreen/agent-harness/
!/cloudanalyzer/
!/cloudanalyzer/agent-harness/
!/wiremock/
!/wiremock/agent-harness/
!/exa/agent-harness/
+14 -5
View File
@@ -15,7 +15,7 @@ CLI-Anything: Bridging the Gap Between AI Agents and the World's Software</stron
<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="https://hkuds.github.io/CLI-Anything/"><img src="https://img.shields.io/badge/CLI_Hub-Browse_%26_Install-ff69b4?style=for-the-badge" alt="CLI Hub"></a>
<a href="#-demonstrations"><img src="https://img.shields.io/badge/Demos-16_Apps-green?style=for-the-badge" alt="Demos"></a>
<a href="#-test-results"><img src="https://img.shields.io/badge/Tests-1%2C839_Passing-brightgreen?style=for-the-badge" alt="Tests"></a>
<a href="#-test-results"><img src="https://img.shields.io/badge/Tests-2%2C130_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>
@@ -925,12 +925,19 @@ Each application received complete, production-ready CLI interfaces — not demo
<td align="center">✅ 40</td>
</tr>
<tr>
<td align="center"><strong>☁️ <a href="cloudanalyzer/agent-harness/">CloudAnalyzer</a></strong></td>
<td>Point cloud / trajectory QA</td>
<td><code>cli-anything-cloudanalyzer</code></td>
<td>CloudAnalyzer (Python API)</td>
<td align="center">✅ 14</td>
</tr>
<tr>
<td align="center" colspan="4"><strong>Total</strong></td>
<td align="center"><strong>✅ 2,045</strong></td>
<td align="center"><strong>✅ 2,130</strong></td>
</tr>
</table>
> **100% pass rate** across all 2,146 tests — 1,571 unit tests + 556 end-to-end tests + 19 Node.js tests.
> **100% pass rate** across all 2,130 tests — 1,551 unit tests + 560 end-to-end tests + 19 Node.js tests.
---
@@ -968,8 +975,9 @@ sketch 19 passed ✅ (19 jest, Node.js)
renderdoc 59 passed ✅ (45 unit + 14 e2e)
cloudcompare 88 passed ✅ (49 unit + 39 e2e)
openscreen 101 passed ✅ (78 unit + 23 e2e)
cloudanalyzer 14 passed ✅ (7 unit + 7 e2e)
──────────────────────────────────────────────────────────────────────────────
TOTAL 2,106 passed ✅ 100% pass rate
TOTAL 2,120 passed ✅ 100% pass rate
```
---
@@ -1045,7 +1053,8 @@ cli-anything/
├── 🎬 videocaptioner/agent-harness/ # VideoCaptioner CLI (26 tests)
├── 🎬 openscreen/agent-harness/ # Openscreen CLI — screen recording editor (101 tests)
├── ☁️ cloudcompare/agent-harness/ # CloudCompare CLI (88 tests)
── 🔍 exa/agent-harness/ # Exa CLI (40 tests)
── 🔍 exa/agent-harness/ # Exa CLI (40 tests)
└── ⛅ cloudanalyzer/agent-harness/ # CloudAnalyzer CLI (14 tests)
```
Each `agent-harness/` contains an installable Python package under `cli_anything.<software>/` with Click CLI, core modules, utils (including `repl_skin.py` and backend wrapper), and comprehensive tests.
+1 -1
View File
@@ -593,7 +593,7 @@ CLI-Anything 适用于任何有代码库的软件 —— 不限领域,不限
</tr>
</table>
> 全部 1,527 项测试 **100% 通过** —— 1,073 项单元测试 + 435 项端到端测试 + 19 项 Node.js 测试。
> 全部 1,628 项测试 **100% 通过** —— 1,151 项单元测试 + 458 项端到端测试 + 19 项 Node.js 测试。
---
+148
View File
@@ -0,0 +1,148 @@
# Agent Harness: ChromaDB Vector Database CLI
## Purpose
This harness provides a standard operating procedure (SOP) and toolkit for coding
agents (Claude Code, Codex, etc.) to interact with ChromaDB vector databases via
a unified CLI interface. The goal: let AI agents query, manage, and populate vector
knowledge bases without needing a browser UI or Python SDK boilerplate.
## Backend Description
**ChromaDB HTTP API v2** running at `http://localhost:8000` (configurable via `--host`).
- Tenant: `default_tenant`
- Database: `default_database`
- Protocol: REST/JSON over HTTP
- Client: Python `requests` library wrapping all API endpoints
ChromaDB is a stateless vector database -- no session or connection state is maintained
between CLI invocations. Each command makes independent HTTP calls.
## Architecture
```
agent-harness/
├── setup.py # Package setup with click, prompt-toolkit, requests
├── CHROMADB.md # This file -- SOP and architecture
└── cli_anything/
└── chromadb/
├── __init__.py
├── __main__.py # python -m entry point
├── README.md # Usage docs
├── chromadb_cli.py # Click CLI + REPL dispatcher
├── core/
│ ├── __init__.py
│ ├── collections.py # list, create, delete, info
│ ├── documents.py # add, get, delete, count
│ ├── query.py # semantic search
│ └── server.py # heartbeat, version
├── utils/
│ ├── __init__.py
│ ├── chromadb_backend.py # HTTP client for ChromaDB v2 API
│ └── repl_skin.py # Unified REPL skin
├── skills/
│ └── SKILL.md
└── tests/
├── __init__.py
├── TEST.md # Test plan and results
├── test_core.py # Unit tests (mocked HTTP)
└── test_full_e2e.py # E2E tests (real ChromaDB)
```
## Command Groups
### server
Server health and metadata commands.
| Command | Description |
|---------|-------------|
| `server heartbeat` | Check ChromaDB server health (returns nanosecond timestamp) |
| `server version` | Get ChromaDB server version string |
### collection
Collection CRUD operations.
| Command | Description |
|---------|-------------|
| `collection list` | List all collections with IDs and metadata |
| `collection create --name NAME` | Create a new collection |
| `collection delete --name NAME` | Delete a collection by name |
| `collection info NAME` | Get detailed info about a collection |
### document
Document management within collections.
| Command | Description |
|---------|-------------|
| `document add --collection C --id ID --document TEXT` | Add document(s) to a collection |
| `document get --collection C` | Get documents (with optional --id, --limit, --offset) |
| `document delete --collection C --id ID` | Delete document(s) by ID |
| `document count --collection C` | Count documents in a collection |
### query
Semantic search against collections.
| Command | Description |
|---------|-------------|
| `query search --collection C --text T` | Semantic search with optional --n-results |
## State Model
**Stateless.** Every CLI invocation creates a fresh HTTP session and queries the
ChromaDB API directly. There is no local state file, no session persistence, and
no undo/redo. The ChromaDB server itself is the single source of truth.
This means:
- Commands are idempotent where the underlying API supports it
- No risk of stale local state
- Every command can be run independently in any order
- REPL mode maintains only the backend URL and output format preference in memory
## API Endpoints Used
All under ChromaDB v2 API (`/api/v2/`):
| Endpoint | Method | Command |
|----------|--------|---------|
| `/api/v2/heartbeat` | GET | `server heartbeat` |
| `/api/v2/version` | GET | `server version` |
| `/api/v2/tenants/{t}/databases/{d}/collections` | GET | `collection list` |
| `/api/v2/tenants/{t}/databases/{d}/collections` | POST | `collection create` |
| `/api/v2/tenants/{t}/databases/{d}/collections/{name}` | GET | `collection info` |
| `/api/v2/tenants/{t}/databases/{d}/collections/{name}` | DELETE | `collection delete` |
| `/api/v2/tenants/{t}/databases/{d}/collections/{id}/add` | POST | `document add` |
| `/api/v2/tenants/{t}/databases/{d}/collections/{id}/get` | POST | `document get` |
| `/api/v2/tenants/{t}/databases/{d}/collections/{id}/delete` | POST | `document delete` |
| `/api/v2/tenants/{t}/databases/{d}/collections/{id}/count` | POST | `document count` |
| `/api/v2/tenants/{t}/databases/{d}/collections/{id}/query` | POST | `query search` |
## Output Formats
All commands support dual output modes controlled by the `--json` flag:
- **Human-readable** (default): Colored tables, status lines, and formatted text via ReplSkin
- **Machine-readable** (`--json`): Structured JSON on stdout for agent consumption
## Current Collections (Hub)
- **hub_knowledge** -- Main knowledge base with 230+ indexed documents
- **team** -- Team information collection
## Testing Strategy
Two test suites with complementary purposes:
1. **Unit tests** (`test_core.py`): Mocked HTTP calls via `unittest.mock`. Tests CLI
argument parsing, output formatting, URL construction, and error handling. Fast,
deterministic, no external dependencies.
2. **E2E tests** (`test_full_e2e.py`): Real subprocess calls to the installed CLI binary
against a live ChromaDB at localhost:8000. Tests actual JSON output parsing, exit codes,
and end-to-end command execution.
## Key Principles
- **Stateless by design** -- No local state to get out of sync with the server
- **JSON output for agents** -- Every command supports `--json` for machine parsing
- **Fail loudly** -- Connection errors and bad responses produce clear error messages
- **Leverage the HTTP API directly** -- No ChromaDB Python SDK dependency, just `requests`
@@ -0,0 +1,52 @@
# CLI-Anything ChromaDB
CLI harness for ChromaDB vector database using the cli-anything methodology.
## Installation
```bash
cd agent-harness
pip install -e .
```
## Usage
### Interactive REPL (default)
```bash
cli-anything-chromadb
```
### Direct commands
```bash
# Server
cli-anything-chromadb server heartbeat
cli-anything-chromadb server version
# Collections
cli-anything-chromadb collection list
cli-anything-chromadb collection info hub_knowledge
cli-anything-chromadb collection create --name test_collection
cli-anything-chromadb collection delete --name test_collection
# Documents
cli-anything-chromadb document count --collection hub_knowledge
cli-anything-chromadb document get --collection hub_knowledge --limit 5
cli-anything-chromadb document add --collection hub_knowledge --id doc1 --document "Hello world"
cli-anything-chromadb document delete --collection hub_knowledge --id doc1
# Semantic search
cli-anything-chromadb query search --collection hub_knowledge --text "how does the pipeline work" --n-results 3
```
### JSON output
Add `--json` before any subcommand:
```bash
cli-anything-chromadb --json collection list
cli-anything-chromadb --json query search --collection hub_knowledge --text "pipeline" --n-results 3
```
## Configuration
- Default server: `http://localhost:8000`
- Override with `--host`: `cli-anything-chromadb --host http://other:8000 server heartbeat`
- Tenant: `default_tenant`, Database: `default_database`
@@ -0,0 +1 @@
"""cli-anything ChromaDB harness."""
@@ -0,0 +1,4 @@
"""Allow running as python -m cli_anything.chromadb."""
from .chromadb_cli import main
main()
@@ -0,0 +1,111 @@
"""ChromaDB CLI-Anything harness -- Click CLI + REPL."""
import json as json_mod
import shlex
import sys
import click
from .utils.chromadb_backend import ChromaDBBackend
from .utils.repl_skin import ReplSkin
from .core.server import server_group
from .core.collections import collection_group
from .core.documents import document_group
from .core.query import query_group
@click.group(invoke_without_command=True)
@click.option("--json", "use_json", is_flag=True, default=False,
help="Output in JSON format")
@click.option("--host", default="http://localhost:8000",
help="ChromaDB server URL (default: http://localhost:8000)")
@click.pass_context
def cli(ctx, use_json, host):
"""CLI-Anything harness for ChromaDB vector database."""
ctx.ensure_object(dict)
ctx.obj["json"] = use_json
ctx.obj["backend"] = ChromaDBBackend(base_url=host)
ctx.obj["skin"] = ReplSkin("chromadb", version="1.0.0")
if ctx.invoked_subcommand is None:
_run_repl(ctx)
cli.add_command(server_group)
cli.add_command(collection_group)
cli.add_command(document_group)
cli.add_command(query_group)
# ── REPL Commands Map (for help display) ─────────────────────────────
_REPL_COMMANDS = {
"server heartbeat": "Check server health",
"server version": "Get server version",
"collection list": "List all collections",
"collection create --name NAME": "Create a new collection",
"collection delete --name NAME": "Delete a collection",
"collection info NAME": "Get collection info",
"document add --collection C ...": "Add document(s)",
"document get --collection C": "Get documents",
"document delete --collection C": "Delete document(s)",
"document count --collection C": "Count documents",
"query search --collection C --text T": "Semantic search",
"help": "Show this help",
"quit / exit": "Exit the REPL",
}
def _run_repl(ctx):
"""Launch the interactive REPL."""
skin: ReplSkin = ctx.obj["skin"]
skin.print_banner()
session = skin.create_prompt_session()
while True:
try:
user_input = skin.get_input(session, context="chromadb")
except (EOFError, KeyboardInterrupt):
skin.print_goodbye()
break
if not user_input:
continue
cmd = user_input.strip().lower()
if cmd in ("quit", "exit", "q"):
skin.print_goodbye()
break
if cmd in ("help", "h", "?"):
skin.help(_REPL_COMMANDS)
continue
# Parse the input and dispatch through Click
try:
args = shlex.split(user_input)
except ValueError as e:
skin.error(f"Parse error: {e}")
continue
try:
# Create a fresh context for each REPL command
cli.main(args=args, obj=ctx.obj, standalone_mode=False)
except SystemExit:
# Click raises SystemExit on errors; swallow in REPL
pass
except click.exceptions.UsageError as e:
skin.error(str(e))
except Exception as e:
skin.error(f"Error: {e}")
def main():
"""Entry point."""
cli(auto_envvar_prefix="CHROMADB_CLI")
if __name__ == "__main__":
main()
@@ -0,0 +1,117 @@
"""Collection commands -- list, create, delete, info."""
import json as json_mod
import click
from ..utils.chromadb_backend import ChromaDBBackend
@click.group("collection")
@click.pass_context
def collection_group(ctx):
"""Manage ChromaDB collections."""
pass
@collection_group.command("list")
@click.pass_context
def list_collections(ctx):
"""List all collections."""
backend: ChromaDBBackend = ctx.obj["backend"]
use_json = ctx.obj.get("json", False)
skin = ctx.obj.get("skin")
try:
collections = backend.list_collections()
if use_json:
click.echo(json_mod.dumps(collections, indent=2))
else:
if not collections:
skin.info("No collections found.")
return
headers = ["Name", "ID", "Metadata"]
rows = []
for c in collections:
name = c.get("name", "?")
cid = c.get("id", "?")
meta = json_mod.dumps(c.get("metadata") or {})
rows.append([name, cid, meta])
skin.table(headers, rows)
except Exception as e:
if use_json:
click.echo(json_mod.dumps({"error": str(e)}, indent=2))
else:
skin.error(f"Failed to list collections: {e}")
raise SystemExit(1)
@collection_group.command("create")
@click.option("--name", required=True, help="Collection name")
@click.option("--metadata", default=None, help="Metadata as JSON string")
@click.pass_context
def create_collection(ctx, name, metadata):
"""Create a new collection."""
backend: ChromaDBBackend = ctx.obj["backend"]
use_json = ctx.obj.get("json", False)
skin = ctx.obj.get("skin")
try:
meta = json_mod.loads(metadata) if metadata else None
result = backend.create_collection(name, metadata=meta)
if use_json:
click.echo(json_mod.dumps(result, indent=2))
else:
skin.success(f"Collection '{name}' created")
skin.status("ID", result.get("id", "?"))
except Exception as e:
if use_json:
click.echo(json_mod.dumps({"error": str(e)}, indent=2))
else:
skin.error(f"Failed to create collection: {e}")
raise SystemExit(1)
@collection_group.command("delete")
@click.option("--name", required=True, help="Collection name to delete")
@click.pass_context
def delete_collection(ctx, name):
"""Delete a collection."""
backend: ChromaDBBackend = ctx.obj["backend"]
use_json = ctx.obj.get("json", False)
skin = ctx.obj.get("skin")
try:
backend.delete_collection(name)
if use_json:
click.echo(json_mod.dumps({"status": "deleted", "name": name}, indent=2))
else:
skin.success(f"Collection '{name}' deleted")
except Exception as e:
if use_json:
click.echo(json_mod.dumps({"error": str(e)}, indent=2))
else:
skin.error(f"Failed to delete collection: {e}")
raise SystemExit(1)
@collection_group.command("info")
@click.argument("name")
@click.pass_context
def collection_info(ctx, name):
"""Get info about a collection."""
backend: ChromaDBBackend = ctx.obj["backend"]
use_json = ctx.obj.get("json", False)
skin = ctx.obj.get("skin")
try:
info = backend.get_collection(name)
if use_json:
click.echo(json_mod.dumps(info, indent=2))
else:
skin.section(f"Collection: {name}")
skin.status("ID", info.get("id", "?"))
skin.status("Name", info.get("name", "?"))
meta = info.get("metadata") or {}
skin.status("Metadata", json_mod.dumps(meta))
except Exception as e:
if use_json:
click.echo(json_mod.dumps({"error": str(e)}, indent=2))
else:
skin.error(f"Failed to get collection info: {e}")
raise SystemExit(1)
@@ -0,0 +1,146 @@
"""Document commands -- add, get, delete, count."""
import json as json_mod
import click
from ..utils.chromadb_backend import ChromaDBBackend
def _resolve_collection_id(backend: ChromaDBBackend, collection_name: str) -> str:
"""Resolve a collection name to its ID."""
info = backend.get_collection(collection_name)
return info["id"]
@click.group("document")
@click.pass_context
def document_group(ctx):
"""Manage documents in ChromaDB collections."""
pass
@document_group.command("add")
@click.option("--collection", required=True, help="Collection name")
@click.option("--id", "doc_ids", multiple=True, required=True, help="Document ID (repeatable)")
@click.option("--document", "documents", multiple=True, required=True, help="Document text (repeatable)")
@click.option("--metadata", "metadatas", multiple=True, default=None, help="Metadata JSON string (repeatable)")
@click.pass_context
def add_documents(ctx, collection, doc_ids, documents, metadatas):
"""Add documents to a collection."""
backend: ChromaDBBackend = ctx.obj["backend"]
use_json = ctx.obj.get("json", False)
skin = ctx.obj.get("skin")
try:
collection_id = _resolve_collection_id(backend, collection)
parsed_meta = None
if metadatas:
parsed_meta = [json_mod.loads(m) for m in metadatas]
result = backend.add_documents(
collection_id=collection_id,
ids=list(doc_ids),
documents=list(documents),
metadatas=parsed_meta,
)
if use_json:
click.echo(json_mod.dumps(result, indent=2))
else:
skin.success(f"Added {len(doc_ids)} document(s) to '{collection}'")
except Exception as e:
if use_json:
click.echo(json_mod.dumps({"error": str(e)}, indent=2))
else:
skin.error(f"Failed to add documents: {e}")
raise SystemExit(1)
@document_group.command("get")
@click.option("--collection", required=True, help="Collection name")
@click.option("--id", "doc_ids", multiple=True, default=None, help="Document ID (repeatable)")
@click.option("--limit", default=None, type=int, help="Max documents to return")
@click.option("--offset", default=None, type=int, help="Offset for pagination")
@click.pass_context
def get_documents(ctx, collection, doc_ids, limit, offset):
"""Get documents from a collection."""
backend: ChromaDBBackend = ctx.obj["backend"]
use_json = ctx.obj.get("json", False)
skin = ctx.obj.get("skin")
try:
collection_id = _resolve_collection_id(backend, collection)
ids_list = list(doc_ids) if doc_ids else None
result = backend.get_documents(
collection_id=collection_id,
ids=ids_list,
limit=limit,
offset=offset,
)
if use_json:
click.echo(json_mod.dumps(result, indent=2))
else:
ids = result.get("ids", [])
docs = result.get("documents", [])
metas = result.get("metadatas", [])
if not ids:
skin.info("No documents found.")
return
headers = ["ID", "Document (preview)", "Metadata"]
rows = []
for i, doc_id in enumerate(ids):
doc_text = (docs[i][:80] + "...") if docs[i] and len(docs[i]) > 80 else (docs[i] or "")
meta = json_mod.dumps(metas[i]) if metas and i < len(metas) else "{}"
rows.append([doc_id, doc_text, meta])
skin.table(headers, rows)
skin.info(f"Showing {len(ids)} document(s)")
except Exception as e:
if use_json:
click.echo(json_mod.dumps({"error": str(e)}, indent=2))
else:
skin.error(f"Failed to get documents: {e}")
raise SystemExit(1)
@document_group.command("delete")
@click.option("--collection", required=True, help="Collection name")
@click.option("--id", "doc_ids", multiple=True, required=True, help="Document ID to delete (repeatable)")
@click.pass_context
def delete_documents(ctx, collection, doc_ids):
"""Delete documents from a collection."""
backend: ChromaDBBackend = ctx.obj["backend"]
use_json = ctx.obj.get("json", False)
skin = ctx.obj.get("skin")
try:
collection_id = _resolve_collection_id(backend, collection)
result = backend.delete_documents(collection_id, list(doc_ids))
if use_json:
click.echo(json_mod.dumps(result, indent=2))
else:
skin.success(f"Deleted {len(doc_ids)} document(s) from '{collection}'")
except Exception as e:
if use_json:
click.echo(json_mod.dumps({"error": str(e)}, indent=2))
else:
skin.error(f"Failed to delete documents: {e}")
raise SystemExit(1)
@document_group.command("count")
@click.option("--collection", required=True, help="Collection name")
@click.pass_context
def count_documents(ctx, collection):
"""Count documents in a collection."""
backend: ChromaDBBackend = ctx.obj["backend"]
use_json = ctx.obj.get("json", False)
skin = ctx.obj.get("skin")
try:
collection_id = _resolve_collection_id(backend, collection)
count = backend.count_documents(collection_id)
if use_json:
click.echo(json_mod.dumps({"collection": collection, "count": count}, indent=2))
else:
skin.status("Collection", collection)
skin.status("Documents", str(count))
except Exception as e:
if use_json:
click.echo(json_mod.dumps({"error": str(e)}, indent=2))
else:
skin.error(f"Failed to count documents: {e}")
raise SystemExit(1)
@@ -0,0 +1,71 @@
"""Query commands -- semantic search."""
import json as json_mod
import click
from ..utils.chromadb_backend import ChromaDBBackend
def _resolve_collection_id(backend: ChromaDBBackend, collection_name: str) -> str:
"""Resolve a collection name to its ID."""
info = backend.get_collection(collection_name)
return info["id"]
@click.group("query")
@click.pass_context
def query_group(ctx):
"""Semantic search against ChromaDB collections."""
pass
@query_group.command("search")
@click.option("--collection", required=True, help="Collection name")
@click.option("--text", required=True, help="Search query text")
@click.option("--n-results", default=5, type=int, help="Number of results (default: 5)")
@click.pass_context
def search(ctx, collection, text, n_results):
"""Perform semantic search on a collection."""
backend: ChromaDBBackend = ctx.obj["backend"]
use_json = ctx.obj.get("json", False)
skin = ctx.obj.get("skin")
try:
collection_id = _resolve_collection_id(backend, collection)
result = backend.query(
collection_id=collection_id,
query_texts=[text],
n_results=n_results,
)
if use_json:
click.echo(json_mod.dumps(result, indent=2))
else:
skin.section(f"Search: \"{text}\" in {collection}")
ids_list = result.get("ids", [[]])[0]
docs_list = result.get("documents", [[]])[0]
dists_list = result.get("distances", [[]])[0]
metas_list = result.get("metadatas", [[]])[0]
if not ids_list:
skin.info("No results found.")
return
for i, doc_id in enumerate(ids_list):
distance = dists_list[i] if i < len(dists_list) else "?"
doc = docs_list[i] if i < len(docs_list) else ""
meta = metas_list[i] if i < len(metas_list) else {}
skin.info(f"[{i+1}] {doc_id} (distance: {distance})")
if meta:
skin.hint(f" metadata: {json_mod.dumps(meta)}")
preview = (doc[:120] + "...") if doc and len(doc) > 120 else (doc or "")
if preview:
skin.hint(f" {preview}")
print()
skin.info(f"{len(ids_list)} result(s) returned")
except Exception as e:
if use_json:
click.echo(json_mod.dumps({"error": str(e)}, indent=2))
else:
skin.error(f"Query failed: {e}")
raise SystemExit(1)
@@ -0,0 +1,56 @@
"""Server commands -- heartbeat and version."""
import json as json_mod
import click
from ..utils.chromadb_backend import ChromaDBBackend
@click.group("server")
@click.pass_context
def server_group(ctx):
"""Server health and version commands."""
pass
@server_group.command("heartbeat")
@click.pass_context
def heartbeat(ctx):
"""Check ChromaDB server health."""
backend: ChromaDBBackend = ctx.obj["backend"]
use_json = ctx.obj.get("json", False)
skin = ctx.obj.get("skin")
try:
result = backend.heartbeat()
if use_json:
click.echo(json_mod.dumps(result, indent=2))
else:
skin.success("ChromaDB server is alive")
skin.status("Heartbeat", str(result.get("nanosecond_heartbeat", result)))
except Exception as e:
if use_json:
click.echo(json_mod.dumps({"error": str(e)}, indent=2))
else:
skin.error(f"Server unreachable: {e}")
raise SystemExit(1)
@server_group.command("version")
@click.pass_context
def version(ctx):
"""Get ChromaDB server version."""
backend: ChromaDBBackend = ctx.obj["backend"]
use_json = ctx.obj.get("json", False)
skin = ctx.obj.get("skin")
try:
result = backend.version()
if use_json:
click.echo(json_mod.dumps({"version": result}, indent=2))
else:
skin.status("Version", str(result))
except Exception as e:
if use_json:
click.echo(json_mod.dumps({"error": str(e)}, indent=2))
else:
skin.error(f"Server unreachable: {e}")
raise SystemExit(1)
@@ -0,0 +1,117 @@
---
name: >-
cli-anything-chromadb
description: >-
Command-line interface for ChromaDB - A stateless CLI for managing vector database collections, documents, and semantic search. Designed for AI agents and automation via the ChromaDB HTTP API v2.
---
# cli-anything-chromadb
A stateless command-line interface for ChromaDB vector database, built on the HTTP API v2. Designed for AI agents and power users who need to manage collections, documents, and run semantic queries without a browser UI.
## Installation
This CLI is installed as part of the cli-anything-chromadb package:
```bash
pip install cli-anything-chromadb
```
**Prerequisites:**
- Python 3.10+
- ChromaDB server running at localhost:8000 (or specify via --host)
## Usage
### Basic Commands
```bash
# Show help
cli-anything-chromadb --help
# Start interactive REPL mode
cli-anything-chromadb
# Check server health
cli-anything-chromadb --json server heartbeat
# List all collections
cli-anything-chromadb --json collection list
# Semantic search
cli-anything-chromadb --json query search --collection hub_knowledge --text "How to deploy"
```
### REPL Mode
When invoked without a subcommand, the CLI enters an interactive REPL session:
```bash
cli-anything-chromadb
# Enter commands interactively with tab-completion and history
```
## Command Groups
### server
Server health and version commands.
| Command | Description |
|---------|-------------|
| `heartbeat` | Check ChromaDB server health |
| `version` | Get ChromaDB server version |
### collection
Manage ChromaDB collections.
| Command | Description |
|---------|-------------|
| `list` | List all collections |
| `create --name NAME` | Create a new collection |
| `delete --name NAME` | Delete a collection |
| `info NAME` | Get collection info |
### document
Manage documents in collections.
| Command | Description |
|---------|-------------|
| `add --collection C --id ID --document TEXT` | Add document(s) |
| `get --collection C` | Get documents |
| `delete --collection C --id ID` | Delete document(s) |
| `count --collection C` | Count documents |
### query
Semantic search against collections.
| Command | Description |
|---------|-------------|
| `search --collection C --text T` | Semantic search |
## Output Formats
All commands support dual output modes:
- **Human-readable** (default): Tables, colors, formatted text
- **Machine-readable** (`--json` flag): Structured JSON for agent consumption
```bash
# Human output
cli-anything-chromadb server heartbeat
# JSON output for agents
cli-anything-chromadb --json server heartbeat
```
## For AI Agents
When using this CLI programmatically:
1. **Always use `--json` flag** for parseable output
2. **Check return codes** - 0 for success, non-zero for errors
3. **Parse stderr** for error messages on failure
4. **Use `--host`** to connect to non-default ChromaDB instances
## Version
1.0.0
@@ -0,0 +1,77 @@
# ChromaDB CLI Tests
## Test Plan
### Unit Tests (`test_core.py`) -- 38 tests, mocked HTTP
- [x] Backend URL construction (default, custom, trailing slash)
- [x] Tenant/database prefix generation
- [x] Session headers (Content-Type)
- [x] Heartbeat URL and response parsing
- [x] Version URL and response parsing
- [x] Collection list URL and response parsing
- [x] Collection create request body (with/without metadata)
- [x] Collection get URL construction
- [x] Collection delete returns True
- [x] Document add request body (with/without metadatas)
- [x] Document get URL and limit/offset params
- [x] Document delete request body
- [x] Document count URL and return value
- [x] Query request body and default n_results
- [x] Query URL construction
- [x] Error handling: ConnectionError on heartbeat/version
- [x] Error handling: HTTPError on list_collections
- [x] Error handling: 404 on get_collection
- [x] CLI --help flag
- [x] CLI server/collection/document/query --help subcommands
- [x] JSON output formatting for heartbeat, version, collection list
- [x] Human output mode for heartbeat
- [x] JSON error output on server failure
- [x] Custom --host flag acceptance
### E2E Tests (`test_full_e2e.py`) -- 14 tests, real ChromaDB at localhost:8000
- [x] `cli-anything-chromadb --help` returns exit code 0
- [x] `server --help` lists subcommands
- [x] Unknown command returns non-zero exit code
- [x] `--json server heartbeat` returns valid JSON with heartbeat data
- [x] `--json server version` returns valid JSON with version string
- [x] `server heartbeat` (human mode) exits 0
- [x] `--json collection list` returns valid JSON array
- [x] Collection list entries contain name field
- [x] `--json collection info hub_knowledge` returns info (skipped if not present)
- [x] `collection info` for nonexistent collection returns error
- [x] JSON output from heartbeat is parseable
- [x] JSON output from version is parseable
- [x] JSON output from collection list is parseable
- [x] Error output in JSON mode is parseable
## Test Results
```
Run date: 2026-03-23
Python: 3.14.3
pytest: 9.0.2
Platform: Darwin (macOS)
======================== 51 passed, 1 skipped in 1.69s =========================
Skipped:
- test_collection_info_hub_knowledge: hub_knowledge collection not present on test server
```
## Running Tests
```bash
cd agent-harness
pip install -e .
# Run all tests
python -m pytest cli_anything/chromadb/tests/ -v --tb=short
# Run only unit tests (no external deps)
python -m pytest cli_anything/chromadb/tests/test_core.py -v
# Run only E2E tests (requires ChromaDB at localhost:8000)
python -m pytest cli_anything/chromadb/tests/test_full_e2e.py -v
```
@@ -0,0 +1,405 @@
"""Unit tests for the ChromaDB CLI-Anything harness.
All HTTP calls are mocked -- no external dependencies required.
Tests cover: output formatting, CLI argument parsing, URL construction,
request building, and error handling.
"""
import json
import os
import sys
import pytest
from unittest.mock import patch, MagicMock
# Ensure the package is importable
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", ".."))
from cli_anything.chromadb.utils.chromadb_backend import ChromaDBBackend
# ============================================================================
# ChromaDBBackend -- URL construction and request building
# ============================================================================
class TestBackendURLConstruction:
"""Verify that the backend builds correct API URLs."""
def test_default_base_url(self):
b = ChromaDBBackend()
assert b.base_url == "http://localhost:8000"
def test_custom_base_url(self):
b = ChromaDBBackend(base_url="http://myhost:9090")
assert b.base_url == "http://myhost:9090"
def test_trailing_slash_stripped(self):
b = ChromaDBBackend(base_url="http://localhost:8000/")
assert b.base_url == "http://localhost:8000"
def test_tenant_db_prefix(self):
b = ChromaDBBackend()
expected = "http://localhost:8000/api/v2/tenants/default_tenant/databases/default_database"
assert b._tenant_db_prefix == expected
def test_custom_tenant_and_database(self):
b = ChromaDBBackend(tenant="my_tenant", database="my_db")
expected = "http://localhost:8000/api/v2/tenants/my_tenant/databases/my_db"
assert b._tenant_db_prefix == expected
def test_session_headers(self):
b = ChromaDBBackend()
assert b._session.headers["Content-Type"] == "application/json"
# ============================================================================
# ChromaDBBackend -- heartbeat and version (mocked)
# ============================================================================
class TestBackendServerCommands:
"""Test server heartbeat and version with mocked HTTP."""
@patch("cli_anything.chromadb.utils.chromadb_backend.requests.Session")
def test_heartbeat_url(self, MockSession):
mock_session = MagicMock()
mock_response = MagicMock()
mock_response.json.return_value = {"nanosecond_heartbeat": 1234567890}
mock_response.raise_for_status.return_value = None
mock_session.get.return_value = mock_response
MockSession.return_value = mock_session
b = ChromaDBBackend()
result = b.heartbeat()
mock_session.get.assert_called_with("http://localhost:8000/api/v2/heartbeat")
assert result == {"nanosecond_heartbeat": 1234567890}
@patch("cli_anything.chromadb.utils.chromadb_backend.requests.Session")
def test_version_url(self, MockSession):
mock_session = MagicMock()
mock_response = MagicMock()
mock_response.json.return_value = "0.6.0"
mock_response.raise_for_status.return_value = None
mock_session.get.return_value = mock_response
MockSession.return_value = mock_session
b = ChromaDBBackend()
result = b.version()
mock_session.get.assert_called_with("http://localhost:8000/api/v2/version")
assert result == "0.6.0"
# ============================================================================
# ChromaDBBackend -- collection operations (mocked)
# ============================================================================
class TestBackendCollections:
"""Test collection CRUD with mocked HTTP."""
def _make_backend(self):
"""Create a backend with a mocked session."""
b = ChromaDBBackend()
b._session = MagicMock()
return b
def _mock_response(self, json_data, status_code=200):
resp = MagicMock()
resp.json.return_value = json_data
resp.status_code = status_code
resp.raise_for_status.return_value = None
return resp
def test_list_collections_url(self):
b = self._make_backend()
b._session.get.return_value = self._mock_response([])
b.list_collections()
called_url = b._session.get.call_args[0][0]
assert "/collections" in called_url
assert "/tenants/default_tenant/databases/default_database" in called_url
def test_list_collections_returns_list(self):
b = self._make_backend()
collections = [{"name": "test", "id": "abc-123"}]
b._session.get.return_value = self._mock_response(collections)
result = b.list_collections()
assert isinstance(result, list)
assert result[0]["name"] == "test"
def test_create_collection_sends_name(self):
b = self._make_backend()
b._session.post.return_value = self._mock_response({"name": "new_col", "id": "xyz"})
result = b.create_collection("new_col")
call_kwargs = b._session.post.call_args
assert call_kwargs[1]["json"]["name"] == "new_col"
assert result["name"] == "new_col"
def test_create_collection_with_metadata(self):
b = self._make_backend()
b._session.post.return_value = self._mock_response({"name": "c", "id": "x"})
b.create_collection("c", metadata={"key": "val"})
body = b._session.post.call_args[1]["json"]
assert body["metadata"] == {"key": "val"}
def test_get_collection_url(self):
b = self._make_backend()
b._session.get.return_value = self._mock_response({"name": "hub_knowledge", "id": "abc"})
b.get_collection("hub_knowledge")
called_url = b._session.get.call_args[0][0]
assert called_url.endswith("/collections/hub_knowledge")
def test_delete_collection_returns_true(self):
b = self._make_backend()
b._session.delete.return_value = self._mock_response(None)
result = b.delete_collection("old_col")
assert result is True
# ============================================================================
# ChromaDBBackend -- document operations (mocked)
# ============================================================================
class TestBackendDocuments:
"""Test document operations with mocked HTTP."""
def _make_backend(self):
b = ChromaDBBackend()
b._session = MagicMock()
return b
def _mock_response(self, json_data, text=""):
resp = MagicMock()
resp.json.return_value = json_data
resp.text = text or json.dumps(json_data) if json_data else ""
resp.raise_for_status.return_value = None
return resp
def test_add_documents_body(self):
b = self._make_backend()
b._session.post.return_value = self._mock_response(None, text="")
b.add_documents("col-id", ids=["d1"], documents=["hello world"])
body = b._session.post.call_args[1]["json"]
assert body["ids"] == ["d1"]
assert body["documents"] == ["hello world"]
assert "metadatas" not in body
def test_add_documents_with_metadatas(self):
b = self._make_backend()
b._session.post.return_value = self._mock_response(None, text="")
b.add_documents("col-id", ids=["d1"], documents=["text"],
metadatas=[{"source": "test"}])
body = b._session.post.call_args[1]["json"]
assert body["metadatas"] == [{"source": "test"}]
def test_get_documents_url(self):
b = self._make_backend()
b._session.post.return_value = self._mock_response({"ids": [], "documents": []})
b.get_documents("col-id-123")
called_url = b._session.post.call_args[0][0]
assert "col-id-123/get" in called_url
def test_get_documents_with_limit_offset(self):
b = self._make_backend()
b._session.post.return_value = self._mock_response({"ids": [], "documents": []})
b.get_documents("col-id", limit=10, offset=5)
body = b._session.post.call_args[1]["json"]
assert body["limit"] == 10
assert body["offset"] == 5
def test_delete_documents_body(self):
b = self._make_backend()
b._session.post.return_value = self._mock_response(None, text="")
b.delete_documents("col-id", ids=["d1", "d2"])
body = b._session.post.call_args[1]["json"]
assert body["ids"] == ["d1", "d2"]
def test_count_documents_url(self):
b = self._make_backend()
b._session.post.return_value = self._mock_response(42)
result = b.count_documents("col-id")
called_url = b._session.post.call_args[0][0]
assert "col-id/count" in called_url
assert result == 42
# ============================================================================
# ChromaDBBackend -- query (mocked)
# ============================================================================
class TestBackendQuery:
"""Test semantic search query with mocked HTTP."""
def _make_backend(self):
b = ChromaDBBackend()
b._session = MagicMock()
return b
def _mock_response(self, json_data):
resp = MagicMock()
resp.json.return_value = json_data
resp.raise_for_status.return_value = None
return resp
def test_query_body(self):
b = self._make_backend()
b._session.post.return_value = self._mock_response({"ids": [[]], "documents": [[]]})
b.query("col-id", query_texts=["test query"], n_results=3)
body = b._session.post.call_args[1]["json"]
assert body["query_texts"] == ["test query"]
assert body["n_results"] == 3
def test_query_default_n_results(self):
b = self._make_backend()
b._session.post.return_value = self._mock_response({"ids": [[]]})
b.query("col-id", query_texts=["hello"])
body = b._session.post.call_args[1]["json"]
assert body["n_results"] == 5
def test_query_url(self):
b = self._make_backend()
b._session.post.return_value = self._mock_response({"ids": [[]]})
b.query("col-id-abc", query_texts=["x"])
called_url = b._session.post.call_args[0][0]
assert "col-id-abc/query" in called_url
# ============================================================================
# ChromaDBBackend -- error handling (mocked)
# ============================================================================
class TestBackendErrorHandling:
"""Test error handling for server-down and bad-response scenarios."""
def test_heartbeat_connection_error(self):
b = ChromaDBBackend()
b._session = MagicMock()
import requests
b._session.get.side_effect = requests.ConnectionError("Connection refused")
with pytest.raises(requests.ConnectionError):
b.heartbeat()
def test_version_connection_error(self):
b = ChromaDBBackend()
b._session = MagicMock()
import requests
b._session.get.side_effect = requests.ConnectionError("Connection refused")
with pytest.raises(requests.ConnectionError):
b.version()
def test_list_collections_http_error(self):
b = ChromaDBBackend()
b._session = MagicMock()
import requests
resp = MagicMock()
resp.raise_for_status.side_effect = requests.HTTPError("500 Server Error")
b._session.get.return_value = resp
with pytest.raises(requests.HTTPError):
b.list_collections()
def test_get_collection_404(self):
b = ChromaDBBackend()
b._session = MagicMock()
import requests
resp = MagicMock()
resp.raise_for_status.side_effect = requests.HTTPError("404 Not Found")
b._session.get.return_value = resp
with pytest.raises(requests.HTTPError):
b.get_collection("nonexistent")
# ============================================================================
# CLI argument parsing (Click CliRunner)
# ============================================================================
class TestCLIParsing:
"""Test Click CLI argument parsing and output formatting."""
@pytest.fixture
def runner(self):
from click.testing import CliRunner
return CliRunner()
@pytest.fixture
def cli(self):
from cli_anything.chromadb.chromadb_cli import cli
return cli
def test_help_flag(self, runner, cli):
result = runner.invoke(cli, ["--help"])
assert result.exit_code == 0
assert "ChromaDB" in result.output or "chromadb" in result.output.lower()
def test_server_help(self, runner, cli):
result = runner.invoke(cli, ["server", "--help"])
assert result.exit_code == 0
assert "heartbeat" in result.output
assert "version" in result.output
def test_collection_help(self, runner, cli):
result = runner.invoke(cli, ["collection", "--help"])
assert result.exit_code == 0
assert "list" in result.output
assert "create" in result.output
assert "delete" in result.output
assert "info" in result.output
def test_document_help(self, runner, cli):
result = runner.invoke(cli, ["document", "--help"])
assert result.exit_code == 0
assert "add" in result.output
assert "get" in result.output
assert "delete" in result.output
assert "count" in result.output
def test_query_help(self, runner, cli):
result = runner.invoke(cli, ["query", "--help"])
assert result.exit_code == 0
assert "search" in result.output
@patch("cli_anything.chromadb.utils.chromadb_backend.ChromaDBBackend.heartbeat")
def test_json_output_heartbeat(self, mock_hb, runner, cli):
mock_hb.return_value = {"nanosecond_heartbeat": 9999}
result = runner.invoke(cli, ["--json", "server", "heartbeat"])
assert result.exit_code == 0
data = json.loads(result.output)
assert "nanosecond_heartbeat" in data
@patch("cli_anything.chromadb.utils.chromadb_backend.ChromaDBBackend.version")
def test_json_output_version(self, mock_ver, runner, cli):
mock_ver.return_value = "0.6.0"
result = runner.invoke(cli, ["--json", "server", "version"])
assert result.exit_code == 0
data = json.loads(result.output)
assert data["version"] == "0.6.0"
@patch("cli_anything.chromadb.utils.chromadb_backend.ChromaDBBackend.list_collections")
def test_json_output_collection_list(self, mock_list, runner, cli):
mock_list.return_value = [{"name": "test_col", "id": "abc-123", "metadata": {}}]
result = runner.invoke(cli, ["--json", "collection", "list"])
assert result.exit_code == 0
data = json.loads(result.output)
assert isinstance(data, list)
assert data[0]["name"] == "test_col"
@patch("cli_anything.chromadb.utils.chromadb_backend.ChromaDBBackend.heartbeat")
def test_human_output_heartbeat(self, mock_hb, runner, cli):
mock_hb.return_value = {"nanosecond_heartbeat": 9999}
result = runner.invoke(cli, ["server", "heartbeat"])
assert result.exit_code == 0
# Human mode should not be valid JSON
assert "alive" in result.output.lower() or "heartbeat" in result.output.lower()
@patch("cli_anything.chromadb.utils.chromadb_backend.ChromaDBBackend.heartbeat")
def test_server_error_json_output(self, mock_hb, runner, cli):
import requests
mock_hb.side_effect = requests.ConnectionError("Connection refused")
result = runner.invoke(cli, ["--json", "server", "heartbeat"])
# Should exit with error
assert result.exit_code != 0
data = json.loads(result.output)
assert "error" in data
def test_custom_host_flag(self, runner, cli):
"""Verify --host flag is accepted (even if connection fails)."""
result = runner.invoke(cli, ["--host", "http://fake:9999", "server", "--help"])
assert result.exit_code == 0
@@ -0,0 +1,205 @@
#!/usr/bin/env python3
"""End-to-end tests for the ChromaDB CLI-Anything harness.
These tests call the REAL ChromaDB server at localhost:8000 via subprocess,
exercising the installed CLI binary. They validate exit codes, JSON output
parsing, and actual server responses.
Requirements:
- ChromaDB running at localhost:8000
- cli-anything-chromadb installed (pip install -e .)
"""
import json
import os
import shutil
import subprocess
import sys
import pytest
# ============================================================================
# Helper: resolve the CLI binary
# ============================================================================
def _resolve_cli():
"""Find the cli-anything-chromadb binary.
Checks in order:
1. Installed console_script on PATH
2. python -m cli_anything.chromadb fallback
"""
# Check if the console script is on PATH
cli_path = shutil.which("cli-anything-chromadb")
if cli_path:
return [cli_path]
# Fallback: run as module
return [sys.executable, "-m", "cli_anything.chromadb"]
CLI = _resolve_cli()
def _run(args, timeout=15):
"""Run the CLI with the given args and return CompletedProcess."""
cmd = CLI + args
return subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout,
)
def _chromadb_available():
"""Check if ChromaDB is reachable at localhost:8000."""
try:
import requests
r = requests.get("http://localhost:8000/api/v2/heartbeat", timeout=3)
return r.status_code == 200
except Exception:
return False
# Skip all E2E tests if ChromaDB is not running
pytestmark = pytest.mark.skipif(
not _chromadb_available(),
reason="ChromaDB server not available at localhost:8000"
)
# ============================================================================
# 1. CLI basic invocation
# ============================================================================
class TestCLIBasic:
"""Test basic CLI invocation and help."""
def test_help_returns_zero(self):
"""cli-anything-chromadb --help should exit 0."""
result = _run(["--help"])
assert result.returncode == 0
assert "chromadb" in result.stdout.lower() or "ChromaDB" in result.stdout
def test_server_help(self):
"""server --help should list subcommands."""
result = _run(["server", "--help"])
assert result.returncode == 0
assert "heartbeat" in result.stdout
assert "version" in result.stdout
def test_unknown_command(self):
"""An unknown subcommand should return non-zero."""
result = _run(["nonexistent-command"])
assert result.returncode != 0
# ============================================================================
# 2. Server commands (real ChromaDB)
# ============================================================================
class TestServerE2E:
"""Test server heartbeat and version against real ChromaDB."""
def test_heartbeat_json(self):
"""server heartbeat --json should return valid JSON with heartbeat data."""
result = _run(["--json", "server", "heartbeat"])
assert result.returncode == 0
data = json.loads(result.stdout)
assert isinstance(data, dict)
# ChromaDB heartbeat returns nanosecond_heartbeat
assert "nanosecond_heartbeat" in data or len(data) > 0
def test_version_json(self):
"""server version --json should return valid JSON with version string."""
result = _run(["--json", "server", "version"])
assert result.returncode == 0
data = json.loads(result.stdout)
assert "version" in data
assert isinstance(data["version"], str)
assert len(data["version"]) > 0
def test_heartbeat_human(self):
"""server heartbeat (human mode) should exit 0."""
result = _run(["server", "heartbeat"])
assert result.returncode == 0
# ============================================================================
# 3. Collection commands (real ChromaDB)
# ============================================================================
class TestCollectionE2E:
"""Test collection operations against real ChromaDB."""
def test_collection_list_json(self):
"""collection list --json should return a valid JSON array."""
result = _run(["--json", "collection", "list"])
assert result.returncode == 0
data = json.loads(result.stdout)
assert isinstance(data, list)
def test_collection_list_has_names(self):
"""Each collection in the list should have a name field."""
result = _run(["--json", "collection", "list"])
assert result.returncode == 0
data = json.loads(result.stdout)
for col in data:
assert "name" in col
def test_collection_info_hub_knowledge(self):
"""collection info hub_knowledge should return info if collection exists."""
# First check if hub_knowledge exists
list_result = _run(["--json", "collection", "list"])
collections = json.loads(list_result.stdout)
names = [c["name"] for c in collections]
if "hub_knowledge" not in names:
pytest.skip("hub_knowledge collection does not exist on this server")
result = _run(["--json", "collection", "info", "hub_knowledge"])
assert result.returncode == 0
data = json.loads(result.stdout)
assert data["name"] == "hub_knowledge"
assert "id" in data
def test_collection_info_nonexistent(self):
"""collection info for a nonexistent collection should return error."""
result = _run(["--json", "collection", "info", "nonexistent_collection_xyz_999"])
assert result.returncode != 0
# ============================================================================
# 4. JSON output validity
# ============================================================================
class TestJSONOutputValidity:
"""Verify that --json flag always produces parseable JSON."""
def test_heartbeat_json_parseable(self):
result = _run(["--json", "server", "heartbeat"])
assert result.returncode == 0
# Must not raise
data = json.loads(result.stdout)
assert data is not None
def test_version_json_parseable(self):
result = _run(["--json", "server", "version"])
assert result.returncode == 0
data = json.loads(result.stdout)
assert data is not None
def test_collection_list_json_parseable(self):
result = _run(["--json", "collection", "list"])
assert result.returncode == 0
data = json.loads(result.stdout)
assert data is not None
def test_error_json_parseable(self):
"""Even errors in --json mode should produce parseable JSON."""
result = _run(["--json", "collection", "info", "nonexistent_collection_xyz_999"])
# Should fail but output should still be JSON
if result.stdout.strip():
data = json.loads(result.stdout)
assert "error" in data
@@ -0,0 +1,144 @@
"""ChromaDB HTTP API client.
Talks to ChromaDB server via its v2 REST API.
Default: http://localhost:8000
Tenant: default_tenant, Database: default_database
"""
import json
import requests
class ChromaDBBackend:
"""HTTP client for ChromaDB v2 API."""
def __init__(self, base_url: str = "http://localhost:8000",
tenant: str = "default_tenant",
database: str = "default_database"):
self.base_url = base_url.rstrip("/")
self.tenant = tenant
self.database = database
self._session = requests.Session()
self._session.headers.update({"Content-Type": "application/json"})
@property
def _tenant_db_prefix(self) -> str:
return f"{self.base_url}/api/v2/tenants/{self.tenant}/databases/{self.database}"
# ── Server ────────────────────────────────────────────────────────
def heartbeat(self) -> dict:
"""Check server health."""
r = self._session.get(f"{self.base_url}/api/v2/heartbeat")
r.raise_for_status()
return r.json()
def version(self) -> str:
"""Get server version."""
r = self._session.get(f"{self.base_url}/api/v2/version")
r.raise_for_status()
return r.json()
# ── Collections ───────────────────────────────────────────────────
def list_collections(self) -> list[dict]:
"""List all collections."""
r = self._session.get(f"{self._tenant_db_prefix}/collections")
r.raise_for_status()
return r.json()
def create_collection(self, name: str, metadata: dict | None = None) -> dict:
"""Create a new collection."""
body = {"name": name}
if metadata:
body["metadata"] = metadata
r = self._session.post(
f"{self._tenant_db_prefix}/collections",
json=body,
)
r.raise_for_status()
return r.json()
def get_collection(self, name: str) -> dict:
"""Get collection info by name."""
r = self._session.get(f"{self._tenant_db_prefix}/collections/{name}")
r.raise_for_status()
return r.json()
def delete_collection(self, name: str) -> bool:
"""Delete a collection by name."""
r = self._session.delete(f"{self._tenant_db_prefix}/collections/{name}")
r.raise_for_status()
return True
# ── Documents ─────────────────────────────────────────────────────
def add_documents(self, collection_id: str, ids: list[str],
documents: list[str],
metadatas: list[dict] | None = None,
embeddings: list[list[float]] | None = None) -> dict:
"""Add documents to a collection."""
body: dict = {"ids": ids, "documents": documents}
if metadatas:
body["metadatas"] = metadatas
if embeddings:
body["embeddings"] = embeddings
r = self._session.post(
f"{self._tenant_db_prefix}/collections/{collection_id}/add",
json=body,
)
r.raise_for_status()
return r.json() if r.text else {"status": "ok"}
def get_documents(self, collection_id: str, ids: list[str] | None = None,
limit: int | None = None,
offset: int | None = None) -> dict:
"""Get documents from a collection."""
body: dict = {}
if ids:
body["ids"] = ids
if limit is not None:
body["limit"] = limit
if offset is not None:
body["offset"] = offset
r = self._session.post(
f"{self._tenant_db_prefix}/collections/{collection_id}/get",
json=body,
)
r.raise_for_status()
return r.json()
def delete_documents(self, collection_id: str, ids: list[str]) -> dict:
"""Delete documents from a collection by IDs."""
body = {"ids": ids}
r = self._session.post(
f"{self._tenant_db_prefix}/collections/{collection_id}/delete",
json=body,
)
r.raise_for_status()
return r.json() if r.text else {"status": "ok"}
def count_documents(self, collection_id: str) -> int:
"""Count documents in a collection."""
r = self._session.post(
f"{self._tenant_db_prefix}/collections/{collection_id}/count",
json={},
)
r.raise_for_status()
return r.json()
# ── Query ─────────────────────────────────────────────────────────
def query(self, collection_id: str, query_texts: list[str],
n_results: int = 5) -> dict:
"""Semantic search query against a collection."""
body = {
"query_texts": query_texts,
"n_results": n_results,
}
r = self._session.post(
f"{self._tenant_db_prefix}/collections/{collection_id}/query",
json=body,
)
r.raise_for_status()
return r.json()
@@ -0,0 +1,500 @@
"""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("ollama", version="1.0.0")
skin.print_banner()
prompt_text = skin.prompt(project_name="llama3.2", modified=False)
skin.success("Model pulled")
skin.error("Connection failed")
skin.warning("No models loaded")
skin.info("Generating...")
skin.status("Model", "llama3.2:latest")
skin.table(headers, rows)
skin.print_goodbye()
"""
import os
import sys
# ── ANSI color codes (no external deps for core styling) ──────────────
_RESET = "\033[0m"
_BOLD = "\033[1m"
_DIM = "\033[2m"
_ITALIC = "\033[3m"
_UNDERLINE = "\033[4m"
# Brand colors
_CYAN = "\033[38;5;80m" # cli-anything brand cyan
_CYAN_BG = "\033[48;5;80m"
_WHITE = "\033[97m"
_GRAY = "\033[38;5;245m"
_DARK_GRAY = "\033[38;5;240m"
_LIGHT_GRAY = "\033[38;5;250m"
# Software accent colors — each software gets a unique accent
_ACCENT_COLORS = {
"gimp": "\033[38;5;214m", # warm orange
"blender": "\033[38;5;208m", # deep orange
"inkscape": "\033[38;5;39m", # bright blue
"audacity": "\033[38;5;33m", # navy blue
"libreoffice": "\033[38;5;40m", # green
"obs_studio": "\033[38;5;55m", # purple
"kdenlive": "\033[38;5;69m", # slate blue
"shotcut": "\033[38;5;35m", # teal green
"ollama": "\033[38;5;255m", # white (Ollama branding)
}
_DEFAULT_ACCENT = "\033[38;5;75m" # default sky blue
# Status colors
_GREEN = "\033[38;5;78m"
_YELLOW = "\033[38;5;220m"
_RED = "\033[38;5;196m"
_BLUE = "\033[38;5;75m"
_MAGENTA = "\033[38;5;176m"
# ── Brand icon ────────────────────────────────────────────────────────
# The cli-anything icon: a small colored diamond/chevron mark
_ICON = f"{_CYAN}{_BOLD}{_RESET}"
_ICON_SMALL = f"{_CYAN}{_RESET}"
# ── Box drawing characters ────────────────────────────────────────────
_H_LINE = ""
_V_LINE = ""
_TL = ""
_TR = ""
_BL = ""
_BR = ""
_T_DOWN = ""
_T_UP = ""
_T_RIGHT = ""
_T_LEFT = ""
_CROSS = ""
def _strip_ansi(text: str) -> str:
"""Remove ANSI escape codes for length calculation."""
import re
return re.sub(r"\033\[[^m]*m", "", text)
def _visible_len(text: str) -> int:
"""Get visible length of text (excluding ANSI codes)."""
return len(_strip_ansi(text))
class ReplSkin:
"""Unified REPL skin for cli-anything CLIs.
Provides consistent branding, prompts, and message formatting
across all CLI harnesses built with the cli-anything methodology.
"""
def __init__(self, software: str, version: str = "1.0.0",
history_file: str | None = None):
"""Initialize the REPL skin.
Args:
software: Software name (e.g., "gimp", "shotcut", "ollama").
version: CLI version string.
history_file: Path for persistent command history.
Defaults to ~/.cli-anything-<software>/history
"""
self.software = software.lower().replace("-", "_")
self.display_name = software.replace("_", " ").title()
self.version = version
self.accent = _ACCENT_COLORS.get(self.software, _DEFAULT_ACCENT)
# History file
if history_file is None:
from pathlib import Path
hist_dir = Path.home() / f".cli-anything-{self.software}"
hist_dir.mkdir(parents=True, exist_ok=True)
self.history_file = str(hist_dir / "history")
else:
self.history_file = history_file
# Detect terminal capabilities
self._color = self._detect_color_support()
def _detect_color_support(self) -> bool:
"""Check if terminal supports color."""
if os.environ.get("NO_COLOR"):
return False
if os.environ.get("CLI_ANYTHING_NO_COLOR"):
return False
if not hasattr(sys.stdout, "isatty"):
return False
return sys.stdout.isatty()
def _c(self, code: str, text: str) -> str:
"""Apply color code if colors are supported."""
if not self._color:
return text
return f"{code}{text}{_RESET}"
# ── Banner ────────────────────────────────────────────────────────
def print_banner(self):
"""Print the startup banner with branding."""
inner = 54
def _box_line(content: str) -> str:
"""Wrap content in box drawing, padding to inner width."""
pad = inner - _visible_len(content)
vl = self._c(_DARK_GRAY, _V_LINE)
return f"{vl}{content}{' ' * max(0, pad)}{vl}"
top = self._c(_DARK_GRAY, f"{_TL}{_H_LINE * inner}{_TR}")
bot = self._c(_DARK_GRAY, f"{_BL}{_H_LINE * inner}{_BR}")
# Title: ◆ cli-anything · Ollama
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 = ""
print(top)
print(_box_line(title))
print(_box_line(ver))
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
"\033[38;5;255m": "#eeeeee", # ollama white
}
+45
View File
@@ -0,0 +1,45 @@
"""Setup for cli-anything-chromadb — CLI harness for ChromaDB vector database."""
from setuptools import setup, find_namespace_packages
setup(
name="cli-anything-chromadb",
version="1.0.0",
author="cli-anything contributors",
author_email="",
description="CLI-Anything harness for ChromaDB vector database",
url="https://github.com/HKUDS/CLI-Anything",
packages=find_namespace_packages(include=["cli_anything.*"]),
classifiers=[
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"Topic :: Software Development :: Libraries :: Python Modules",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
],
python_requires=">=3.10",
install_requires=[
"click>=8.0.0",
"prompt-toolkit>=3.0.0",
"requests>=2.28.0",
],
extras_require={
"dev": [
"pytest>=7.0.0",
"pytest-cov>=4.0.0",
],
},
entry_points={
"console_scripts": [
"cli-anything-chromadb=cli_anything.chromadb.chromadb_cli:main",
],
},
package_data={
"cli_anything.chromadb": ["skills/*.md"],
},
include_package_data=True,
zip_safe=False,
)
@@ -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,521 @@
"""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 skills/SKILL.md inside the package
prompt_text = skin.prompt(project_name="my_video.mlt", modified=True)
skin.success("Project saved")
skin.error("File not found")
skin.warning("Unsaved changes")
skin.info("Processing 24 clips...")
skin.status("Track 1", "3 clips, 00:02:30")
skin.table(headers, rows)
skin.print_goodbye()
"""
import os
import sys
# ── ANSI color codes (no external deps for core styling) ──────────────
_RESET = "\033[0m"
_BOLD = "\033[1m"
_DIM = "\033[2m"
_ITALIC = "\033[3m"
_UNDERLINE = "\033[4m"
# Brand colors
_CYAN = "\033[38;5;80m" # cli-anything brand cyan
_CYAN_BG = "\033[48;5;80m"
_WHITE = "\033[97m"
_GRAY = "\033[38;5;245m"
_DARK_GRAY = "\033[38;5;240m"
_LIGHT_GRAY = "\033[38;5;250m"
# Software accent colors — each software gets a unique accent
_ACCENT_COLORS = {
"gimp": "\033[38;5;214m", # warm orange
"blender": "\033[38;5;208m", # deep orange
"inkscape": "\033[38;5;39m", # bright blue
"audacity": "\033[38;5;33m", # navy blue
"libreoffice": "\033[38;5;40m", # green
"obs_studio": "\033[38;5;55m", # purple
"kdenlive": "\033[38;5;69m", # slate blue
"shotcut": "\033[38;5;35m", # teal green
}
_DEFAULT_ACCENT = "\033[38;5;75m" # default sky blue
# Status colors
_GREEN = "\033[38;5;78m"
_YELLOW = "\033[38;5;220m"
_RED = "\033[38;5;196m"
_BLUE = "\033[38;5;75m"
_MAGENTA = "\033[38;5;176m"
# ── Brand icon ────────────────────────────────────────────────────────
# The cli-anything icon: a small colored diamond/chevron mark
_ICON = f"{_CYAN}{_BOLD}{_RESET}"
_ICON_SMALL = f"{_CYAN}{_RESET}"
# ── Box drawing characters ────────────────────────────────────────────
_H_LINE = ""
_V_LINE = ""
_TL = ""
_TR = ""
_BL = ""
_BR = ""
_T_DOWN = ""
_T_UP = ""
_T_RIGHT = ""
_T_LEFT = ""
_CROSS = ""
def _strip_ansi(text: str) -> str:
"""Remove ANSI escape codes for length calculation."""
import re
return re.sub(r"\033\[[^m]*m", "", text)
def _visible_len(text: str) -> int:
"""Get visible length of text (excluding ANSI codes)."""
return len(_strip_ansi(text))
class ReplSkin:
"""Unified REPL skin for cli-anything CLIs.
Provides consistent branding, prompts, and message formatting
across all CLI harnesses built with the cli-anything methodology.
"""
def __init__(self, software: str, version: str = "1.0.0",
history_file: str | None = None, skill_path: str | None = None):
"""Initialize the REPL skin.
Args:
software: Software name (e.g., "gimp", "shotcut", "blender").
version: CLI version string.
history_file: Path for persistent command history.
Defaults to ~/.cli-anything-<software>/history
skill_path: Path to the SKILL.md file for agent discovery.
Auto-detected from the package's skills/ directory if not provided.
Displayed in banner for AI agents to know where to read skill info.
"""
self.software = software.lower().replace("-", "_")
self.display_name = software.replace("_", " ").title()
self.version = version
# Auto-detect skill path from package layout:
# cli_anything/<software>/utils/repl_skin.py (this file)
# cli_anything/<software>/skills/SKILL.md (target)
if skill_path is None:
from pathlib import Path
_auto = Path(__file__).resolve().parent.parent / "skills" / "SKILL.md"
if _auto.is_file():
skill_path = str(_auto)
self.skill_path = skill_path
self.accent = _ACCENT_COLORS.get(self.software, _DEFAULT_ACCENT)
# History file
if history_file is None:
from pathlib import Path
hist_dir = Path.home() / f".cli-anything-{self.software}"
hist_dir.mkdir(parents=True, exist_ok=True)
self.history_file = str(hist_dir / "history")
else:
self.history_file = history_file
# Detect terminal capabilities
self._color = self._detect_color_support()
def _detect_color_support(self) -> bool:
"""Check if terminal supports color."""
if os.environ.get("NO_COLOR"):
return False
if os.environ.get("CLI_ANYTHING_NO_COLOR"):
return False
if not hasattr(sys.stdout, "isatty"):
return False
return sys.stdout.isatty()
def _c(self, code: str, text: str) -> str:
"""Apply color code if colors are supported."""
if not self._color:
return text
return f"{code}{text}{_RESET}"
# ── Banner ────────────────────────────────────────────────────────
def print_banner(self):
"""Print the startup banner with branding."""
inner = 54
def _box_line(content: str) -> str:
"""Wrap content in box drawing, padding to inner width."""
pad = inner - _visible_len(content)
vl = self._c(_DARK_GRAY, _V_LINE)
return f"{vl}{content}{' ' * max(0, pad)}{vl}"
top = self._c(_DARK_GRAY, f"{_TL}{_H_LINE * inner}{_TR}")
bot = self._c(_DARK_GRAY, f"{_BL}{_H_LINE * inner}{_BR}")
# Title: ◆ cli-anything · Shotcut
icon = self._c(_CYAN + _BOLD, "")
brand = self._c(_CYAN + _BOLD, "cli-anything")
dot = self._c(_DARK_GRAY, "·")
name = self._c(self.accent + _BOLD, self.display_name)
title = f" {icon} {brand} {dot} {name}"
ver = f" {self._c(_DARK_GRAY, f' v{self.version}')}"
tip = f" {self._c(_DARK_GRAY, ' Type help for commands, quit to exit')}"
empty = ""
# Skill path for agent discovery
skill_line = None
if self.skill_path:
skill_icon = self._c(_MAGENTA, "")
skill_label = self._c(_DARK_GRAY, " Skill:")
skill_path_display = self._c(_LIGHT_GRAY, self.skill_path)
skill_line = f" {skill_icon} {skill_label} {skill_path_display}"
print(top)
print(_box_line(title))
print(_box_line(ver))
if skill_line:
print(_box_line(skill_line))
print(_box_line(empty))
print(_box_line(tip))
print(bot)
print()
# ── Prompt ────────────────────────────────────────────────────────
def prompt(self, project_name: str = "", modified: bool = False,
context: str = "") -> str:
"""Build a styled prompt string for prompt_toolkit or input().
Args:
project_name: Current project name (empty if none open).
modified: Whether the project has unsaved changes.
context: Optional extra context to show in prompt.
Returns:
Formatted prompt string.
"""
parts = []
# Icon
if self._color:
parts.append(f"{_CYAN}{_RESET} ")
else:
parts.append("> ")
# Software name
parts.append(self._c(self.accent + _BOLD, self.software))
# Project context
if project_name or context:
ctx = context or project_name
mod = "*" if modified else ""
parts.append(f" {self._c(_DARK_GRAY, '[')}")
parts.append(self._c(_LIGHT_GRAY, f"{ctx}{mod}"))
parts.append(self._c(_DARK_GRAY, ']'))
parts.append(self._c(_GRAY, " "))
return "".join(parts)
def prompt_tokens(self, project_name: str = "", modified: bool = False,
context: str = ""):
"""Build prompt_toolkit formatted text tokens for the prompt.
Use with prompt_toolkit's FormattedText for proper ANSI handling.
Returns:
list of (style, text) tuples for prompt_toolkit.
"""
accent_hex = _ANSI_256_TO_HEX.get(self.accent, "#5fafff")
tokens = []
tokens.append(("class:icon", ""))
tokens.append(("class:software", self.software))
if project_name or context:
ctx = context or project_name
mod = "*" if modified else ""
tokens.append(("class:bracket", " ["))
tokens.append(("class:context", f"{ctx}{mod}"))
tokens.append(("class:bracket", "]"))
tokens.append(("class:arrow", " "))
return tokens
def get_prompt_style(self):
"""Get a prompt_toolkit Style object matching the skin.
Returns:
prompt_toolkit.styles.Style
"""
try:
from prompt_toolkit.styles import Style
except ImportError:
return None
accent_hex = _ANSI_256_TO_HEX.get(self.accent, "#5fafff")
return Style.from_dict({
"icon": "#5fdfdf bold", # cyan brand color
"software": f"{accent_hex} bold",
"bracket": "#585858",
"context": "#bcbcbc",
"arrow": "#808080",
# Completion menu
"completion-menu.completion": "bg:#303030 #bcbcbc",
"completion-menu.completion.current": f"bg:{accent_hex} #000000",
"completion-menu.meta.completion": "bg:#303030 #808080",
"completion-menu.meta.completion.current": f"bg:{accent_hex} #000000",
# Auto-suggest
"auto-suggest": "#585858",
# Bottom toolbar
"bottom-toolbar": "bg:#1c1c1c #808080",
"bottom-toolbar.text": "#808080",
})
# ── Messages ──────────────────────────────────────────────────────
def success(self, message: str):
"""Print a success message with green checkmark."""
icon = self._c(_GREEN + _BOLD, "")
print(f" {icon} {self._c(_GREEN, message)}")
def error(self, message: str):
"""Print an error message with red cross."""
icon = self._c(_RED + _BOLD, "")
print(f" {icon} {self._c(_RED, message)}", file=sys.stderr)
def warning(self, message: str):
"""Print a warning message with yellow triangle."""
icon = self._c(_YELLOW + _BOLD, "")
print(f" {icon} {self._c(_YELLOW, message)}")
def info(self, message: str):
"""Print an info message with blue dot."""
icon = self._c(_BLUE, "")
print(f" {icon} {self._c(_LIGHT_GRAY, message)}")
def hint(self, message: str):
"""Print a subtle hint message."""
print(f" {self._c(_DARK_GRAY, message)}")
def section(self, title: str):
"""Print a section header."""
print()
print(f" {self._c(self.accent + _BOLD, title)}")
print(f" {self._c(_DARK_GRAY, _H_LINE * len(title))}")
# ── Status display ────────────────────────────────────────────────
def status(self, label: str, value: str):
"""Print a key-value status line."""
lbl = self._c(_GRAY, f" {label}:")
val = self._c(_WHITE, f" {value}")
print(f"{lbl}{val}")
def status_block(self, items: dict[str, str], title: str = ""):
"""Print a block of status key-value pairs.
Args:
items: Dict of label -> value pairs.
title: Optional title for the block.
"""
if title:
self.section(title)
max_key = max(len(k) for k in items) if items else 0
for label, value in items.items():
lbl = self._c(_GRAY, f" {label:<{max_key}}")
val = self._c(_WHITE, f" {value}")
print(f"{lbl}{val}")
def progress(self, current: int, total: int, label: str = ""):
"""Print a simple progress indicator.
Args:
current: Current step number.
total: Total number of steps.
label: Optional label for the progress.
"""
pct = int(current / total * 100) if total > 0 else 0
bar_width = 20
filled = int(bar_width * current / total) if total > 0 else 0
bar = "" * filled + "" * (bar_width - filled)
text = f" {self._c(_CYAN, bar)} {self._c(_GRAY, f'{pct:3d}%')}"
if label:
text += f" {self._c(_LIGHT_GRAY, label)}"
print(text)
# ── Table display ─────────────────────────────────────────────────
def table(self, headers: list[str], rows: list[list[str]],
max_col_width: int = 40):
"""Print a formatted table with box-drawing characters.
Args:
headers: Column header strings.
rows: List of rows, each a list of cell strings.
max_col_width: Maximum column width before truncation.
"""
if not headers:
return
# Calculate column widths
col_widths = [min(len(h), max_col_width) for h in headers]
for row in rows:
for i, cell in enumerate(row):
if i < len(col_widths):
col_widths[i] = min(
max(col_widths[i], len(str(cell))), max_col_width
)
def pad(text: str, width: int) -> str:
t = str(text)[:width]
return t + " " * (width - len(t))
# Header
header_cells = [
self._c(_CYAN + _BOLD, pad(h, col_widths[i]))
for i, h in enumerate(headers)
]
sep = self._c(_DARK_GRAY, f" {_V_LINE} ")
header_line = f" {sep.join(header_cells)}"
print(header_line)
# Separator
sep_parts = [self._c(_DARK_GRAY, _H_LINE * w) for w in col_widths]
sep_line = self._c(_DARK_GRAY, f" {'───'.join([_H_LINE * w for w in col_widths])}")
print(sep_line)
# Rows
for row in rows:
cells = []
for i, cell in enumerate(row):
if i < len(col_widths):
cells.append(self._c(_LIGHT_GRAY, pad(str(cell), col_widths[i])))
row_sep = self._c(_DARK_GRAY, f" {_V_LINE} ")
print(f" {row_sep.join(cells)}")
# ── Help display ──────────────────────────────────────────────────
def help(self, commands: dict[str, str]):
"""Print a formatted help listing.
Args:
commands: Dict of command -> description pairs.
"""
self.section("Commands")
max_cmd = max(len(c) for c in commands) if commands else 0
for cmd, desc in commands.items():
cmd_styled = self._c(self.accent, f" {cmd:<{max_cmd}}")
desc_styled = self._c(_GRAY, f" {desc}")
print(f"{cmd_styled}{desc_styled}")
print()
# ── Goodbye ───────────────────────────────────────────────────────
def print_goodbye(self):
"""Print a styled goodbye message."""
print(f"\n {_ICON_SMALL} {self._c(_GRAY, 'Goodbye!')}\n")
# ── Prompt toolkit session factory ────────────────────────────────
def create_prompt_session(self):
"""Create a prompt_toolkit PromptSession with skin styling.
Returns:
A configured PromptSession, or None if prompt_toolkit unavailable.
"""
try:
from prompt_toolkit import PromptSession
from prompt_toolkit.history import FileHistory
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
from prompt_toolkit.formatted_text import FormattedText
style = self.get_prompt_style()
session = PromptSession(
history=FileHistory(self.history_file),
auto_suggest=AutoSuggestFromHistory(),
style=style,
enable_history_search=True,
)
return session
except ImportError:
return None
def get_input(self, pt_session, project_name: str = "",
modified: bool = False, context: str = "") -> str:
"""Get input from user using prompt_toolkit or fallback.
Args:
pt_session: A prompt_toolkit PromptSession (or None).
project_name: Current project name.
modified: Whether project has unsaved changes.
context: Optional context string.
Returns:
User input string (stripped).
"""
if pt_session is not None:
from prompt_toolkit.formatted_text import FormattedText
tokens = self.prompt_tokens(project_name, modified, context)
return pt_session.prompt(FormattedText(tokens)).strip()
else:
raw_prompt = self.prompt(project_name, modified, context)
return input(raw_prompt).strip()
# ── Toolbar builder ───────────────────────────────────────────────
def bottom_toolbar(self, items: dict[str, str]):
"""Create a bottom toolbar callback for prompt_toolkit.
Args:
items: Dict of label -> value pairs to show in toolbar.
Returns:
A callable that returns FormattedText for the toolbar.
"""
def toolbar():
from prompt_toolkit.formatted_text import FormattedText
parts = []
for i, (k, v) in enumerate(items.items()):
if i > 0:
parts.append(("class:bottom-toolbar.text", ""))
parts.append(("class:bottom-toolbar.text", f" {k}: "))
parts.append(("class:bottom-toolbar", v))
return FormattedText(parts)
return toolbar
# ── ANSI 256-color to hex mapping (for prompt_toolkit styles) ─────────
_ANSI_256_TO_HEX = {
"\033[38;5;33m": "#0087ff", # audacity navy blue
"\033[38;5;35m": "#00af5f", # shotcut teal
"\033[38;5;39m": "#00afff", # inkscape bright blue
"\033[38;5;40m": "#00d700", # libreoffice green
"\033[38;5;55m": "#5f00af", # obs purple
"\033[38;5;69m": "#5f87ff", # kdenlive slate blue
"\033[38;5;75m": "#5fafff", # default sky blue
"\033[38;5;80m": "#5fd7d7", # brand cyan
"\033[38;5;208m": "#ff8700", # blender deep orange
"\033[38;5;214m": "#ffaf00", # gimp warm orange
}
+35
View File
@@ -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",
],
)
+101
View File
@@ -0,0 +1,101 @@
# Agent Harness SOP: PM2 Process Management
## Purpose
This harness provides a CLI-Anything interface for PM2 process management.
It wraps the PM2 CLI via subprocess calls and exposes process lifecycle, logs,
and system commands through a unified Click CLI with interactive REPL mode.
## Backend
- **Engine**: PM2 (Node.js process manager) via subprocess
- **Binary**: `pm2` resolved via `shutil.which()` with fallback paths
- **Protocol**: Subprocess execution with stdout/stderr capture
- **Data format**: PM2 outputs JSON via `pm2 jlist`; other commands return plain text
## Architecture
```
cli-anything-pm2 (Click CLI)
|
+-- pm2_cli.py # Click groups + REPL dispatcher
|
+-- core/
| +-- processes.py # list, describe, metrics
| +-- lifecycle.py # start, stop, restart, delete
| +-- logs.py # view, flush
| +-- system.py # save, startup, version
|
+-- utils/
| +-- pm2_backend.py # subprocess wrapper (_run_pm2, pm2_jlist, etc.)
| +-- repl_skin.py # cli-anything branded REPL UI
|
+-- tests/
| +-- test_core.py # Unit tests (mocked subprocess)
| +-- test_full_e2e.py # E2E tests (real pm2 binary)
| +-- TEST.md # Test plan and results
|
+-- skills/
+-- SKILL.md # Agent skill definitions
```
## Command Groups
| Group | Commands | Backend Calls |
|-------------|------------------------------|-----------------------------------|
| `process` | list, describe, metrics | `pm2 jlist` (JSON parse) |
| `lifecycle` | start, stop, restart, delete | `pm2 start/stop/restart/delete` |
| `logs` | view, flush | `pm2 logs --nostream`, `pm2 flush`|
| `system` | save, startup, version | `pm2 save/startup/--version` |
## State Model
**Stateless.** Every command queries PM2 fresh via subprocess. There is no
in-memory session state, no project files, and no undo/redo. The PM2 daemon
itself maintains process state; this harness is a read/write proxy.
## Output Modes
All commands support two output modes controlled by the `--json` flag:
- **Human mode** (default): Formatted tables, status messages with color
- **JSON mode** (`--json`): Machine-readable JSON for agent consumption
## Interaction Modes
1. **Subcommand CLI**: `cli-anything-pm2 [--json] <group> <command> [args]`
2. **Interactive REPL**: `cli-anything-pm2` (no subcommand) launches the REPL
## Key Design Decisions
- PM2 binary path is cached after first lookup (`_PM2_BIN` module-level)
- `pm2 jlist` is used instead of `pm2 describe` for JSON reliability
- Subprocess environment includes `/opt/homebrew/bin` for macOS compatibility
- Timeout defaults to 30 seconds per subprocess call
- JSON parsing has fallback extraction for non-JSON preamble in stdout
## Error Handling
| Scenario | Behavior |
|-----------------------|---------------------------------------------|
| pm2 not installed | `RuntimeError` with install instructions |
| pm2 binary vanishes | `FileNotFoundError` caught, error dict returned |
| Command timeout | `TimeoutExpired` caught, error dict returned |
| Invalid JSON output | Fallback regex extraction, then `data: None` |
| Process not found | Returns `None` or exit code 1 |
## Testing
- **Unit tests**: `test_core.py` -- all subprocess calls mocked, no pm2 required
- **E2E tests**: `test_full_e2e.py` -- calls real pm2 binary, requires pm2 installed
```bash
# Run all tests
python -m pytest cli_anything/pm2/tests/ -v
# Run only unit tests (no pm2 needed)
python -m pytest cli_anything/pm2/tests/test_core.py -v
# Run only E2E tests (pm2 must be installed and running)
python -m pytest cli_anything/pm2/tests/test_full_e2e.py -v
```
@@ -0,0 +1,41 @@
# cli-anything PM2
CLI-Anything harness for PM2 process management. All PM2 commands are executed via subprocess -- no PM2 API dependency required.
## Quick Start
```bash
# Install
cd agent-harness && pip install -e .
# REPL mode
cli-anything-pm2
# Direct commands
cli-anything-pm2 process list
cli-anything-pm2 --json process list
cli-anything-pm2 lifecycle restart seaclip-dev
cli-anything-pm2 logs view voice-agent --lines 50
cli-anything-pm2 system version
```
## Command Groups
- **process**: list, describe, metrics
- **lifecycle**: start, stop, restart, delete
- **logs**: view, flush
- **system**: save, startup, version
## Architecture
```
pm2_cli.py Click CLI + REPL entry point
core/
processes.py list, describe, metrics
lifecycle.py start, stop, restart, delete
logs.py view, flush
system.py save, startup, version
utils/
pm2_backend.py subprocess wrapper for pm2 commands
repl_skin.py cli-anything REPL skin
```
@@ -0,0 +1,3 @@
"""cli-anything PM2 — CLI harness for PM2 process management."""
__version__ = "1.0.0"
@@ -0,0 +1,5 @@
"""Allow running as python -m cli_anything.pm2."""
from .pm2_cli import main
main()
@@ -0,0 +1,118 @@
"""Lifecycle commands — start, stop, restart, delete PM2 processes."""
import json
from typing import Any
from ..utils.pm2_backend import pm2_action, pm2_start as backend_start
def restart_process(name: str, as_json: bool = False) -> dict[str, Any]:
"""Restart a PM2 process.
Args:
name: Process name or ID.
as_json: If True, return raw result dict.
Returns:
Result dict with success status and message.
"""
result = pm2_action("restart", name)
if as_json:
return {
"action": "restart",
"process": name,
"success": result["success"],
"stdout": result["stdout"],
"stderr": result["stderr"],
}
return {
"success": result["success"],
"message": f"Restarted '{name}'" if result["success"]
else f"Failed to restart '{name}': {result['stderr'].strip()}",
}
def stop_process(name: str, as_json: bool = False) -> dict[str, Any]:
"""Stop a PM2 process.
Args:
name: Process name or ID.
as_json: If True, return raw result dict.
Returns:
Result dict with success status and message.
"""
result = pm2_action("stop", name)
if as_json:
return {
"action": "stop",
"process": name,
"success": result["success"],
"stdout": result["stdout"],
"stderr": result["stderr"],
}
return {
"success": result["success"],
"message": f"Stopped '{name}'" if result["success"]
else f"Failed to stop '{name}': {result['stderr'].strip()}",
}
def delete_process(name: str, as_json: bool = False) -> dict[str, Any]:
"""Delete a PM2 process.
Args:
name: Process name or ID.
as_json: If True, return raw result dict.
Returns:
Result dict with success status and message.
"""
result = pm2_action("delete", name)
if as_json:
return {
"action": "delete",
"process": name,
"success": result["success"],
"stdout": result["stdout"],
"stderr": result["stderr"],
}
return {
"success": result["success"],
"message": f"Deleted '{name}'" if result["success"]
else f"Failed to delete '{name}': {result['stderr'].strip()}",
}
def start_process(
script: str,
name: str | None = None,
as_json: bool = False,
) -> dict[str, Any]:
"""Start a new PM2 process.
Args:
script: Path to script or ecosystem file.
name: Optional process name.
as_json: If True, return raw result dict.
Returns:
Result dict with success status and message.
"""
result = backend_start(script, name=name)
display_name = name or script
if as_json:
return {
"action": "start",
"script": script,
"name": name,
"success": result["success"],
"stdout": result["stdout"],
"stderr": result["stderr"],
}
return {
"success": result["success"],
"message": f"Started '{display_name}'" if result["success"]
else f"Failed to start '{display_name}': {result['stderr'].strip()}",
}
@@ -0,0 +1,71 @@
"""Log commands — view and flush PM2 process logs."""
from typing import Any
from ..utils.pm2_backend import pm2_logs as backend_logs, pm2_flush as backend_flush
def view_logs(name: str, lines: int = 20, as_json: bool = False) -> dict[str, Any]:
"""View recent logs for a PM2 process.
Args:
name: Process name or ID.
lines: Number of log lines to retrieve.
as_json: If True, return structured dict.
Returns:
Dict with log content and metadata.
"""
result = backend_logs(name, lines=lines)
if as_json:
return {
"process": name,
"lines_requested": lines,
"success": result["success"],
"stdout": result["stdout"],
"stderr": result["stderr"],
}
if result["success"]:
output = result["stdout"].strip() or result["stderr"].strip()
return {
"success": True,
"message": f"Logs for '{name}' (last {lines} lines)",
"content": output if output else "(no log output)",
}
else:
return {
"success": False,
"message": f"Failed to get logs for '{name}': {result['stderr'].strip()}",
"content": "",
}
def flush_logs(name: str | None = None, as_json: bool = False) -> dict[str, Any]:
"""Flush logs for a process or all processes.
Args:
name: Process name or ID. If None, flushes all logs.
as_json: If True, return structured dict.
Returns:
Dict with success status and message.
"""
result = backend_flush(name)
target = f"'{name}'" if name else "all processes"
if as_json:
return {
"action": "flush",
"process": name or "all",
"success": result["success"],
"stdout": result["stdout"],
"stderr": result["stderr"],
}
return {
"success": result["success"],
"message": f"Flushed logs for {target}" if result["success"]
else f"Failed to flush logs for {target}: {result['stderr'].strip()}",
}
@@ -0,0 +1,160 @@
"""Process commands — list, describe, and metrics for PM2 processes."""
import json
from typing import Any
from ..utils.pm2_backend import pm2_jlist, pm2_describe
def list_processes(as_json: bool = False) -> str | list[dict[str, Any]]:
"""List all PM2 processes.
Args:
as_json: If True, return raw list of dicts. Otherwise, formatted string.
Returns:
Formatted table string or list of process dicts.
"""
processes = pm2_jlist()
if as_json:
return processes
if not processes:
return "No PM2 processes running."
rows = []
for p in processes:
pm2_env = p.get("pm2_env", {})
monit = p.get("monit", {})
rows.append({
"id": p.get("pm_id", "?"),
"name": p.get("name", "unknown"),
"status": pm2_env.get("status", "unknown"),
"cpu": f"{monit.get('cpu', 0)}%",
"memory": _format_bytes(monit.get("memory", 0)),
"restarts": pm2_env.get("restart_time", 0),
"uptime": _format_uptime(pm2_env.get("pm_uptime", 0)),
})
return rows
def describe_process(name: str, as_json: bool = False) -> str | dict[str, Any] | None:
"""Get detailed info for a specific process.
Args:
name: Process name or ID.
as_json: If True, return raw dict.
Returns:
Formatted info string, raw dict, or None if not found.
"""
info = pm2_describe(name)
if info is None:
return None
if as_json:
return info
pm2_env = info.get("pm2_env", {})
monit = info.get("monit", {})
details = {
"Name": info.get("name", "unknown"),
"ID": str(info.get("pm_id", "?")),
"Status": pm2_env.get("status", "unknown"),
"Script": pm2_env.get("pm_exec_path", "N/A"),
"CWD": pm2_env.get("pm_cwd", "N/A"),
"Interpreter": pm2_env.get("exec_interpreter", "N/A"),
"CPU": f"{monit.get('cpu', 0)}%",
"Memory": _format_bytes(monit.get("memory", 0)),
"Restarts": str(pm2_env.get("restart_time", 0)),
"Uptime": _format_uptime(pm2_env.get("pm_uptime", 0)),
"PID": str(info.get("pid", "N/A")),
"Exec Mode": pm2_env.get("exec_mode", "N/A"),
"Node Version": pm2_env.get("node_version", "N/A"),
}
return details
def get_metrics(as_json: bool = False) -> str | list[dict[str, Any]]:
"""Get CPU/memory metrics for all processes.
Args:
as_json: If True, return raw list of metric dicts.
Returns:
Formatted metrics string or list of metric dicts.
"""
processes = pm2_jlist()
if as_json:
metrics = []
for p in processes:
monit = p.get("monit", {})
pm2_env = p.get("pm2_env", {})
metrics.append({
"name": p.get("name", "unknown"),
"pm_id": p.get("pm_id", "?"),
"status": pm2_env.get("status", "unknown"),
"cpu": monit.get("cpu", 0),
"memory": monit.get("memory", 0),
"memory_human": _format_bytes(monit.get("memory", 0)),
})
return metrics
if not processes:
return "No PM2 processes running."
rows = []
for p in processes:
monit = p.get("monit", {})
pm2_env = p.get("pm2_env", {})
rows.append({
"id": p.get("pm_id", "?"),
"name": p.get("name", "unknown"),
"status": pm2_env.get("status", "unknown"),
"cpu": f"{monit.get('cpu', 0)}%",
"memory": _format_bytes(monit.get("memory", 0)),
})
return rows
def _format_bytes(b: int) -> str:
"""Format bytes into human-readable string."""
if b == 0:
return "0 B"
units = ["B", "KB", "MB", "GB"]
i = 0
val = float(b)
while val >= 1024 and i < len(units) - 1:
val /= 1024
i += 1
return f"{val:.1f} {units[i]}"
def _format_uptime(pm_uptime: int) -> str:
"""Format PM2 uptime timestamp to human-readable duration."""
if pm_uptime == 0:
return "N/A"
import time
now_ms = int(time.time() * 1000)
diff_s = max(0, (now_ms - pm_uptime)) // 1000
if diff_s < 60:
return f"{diff_s}s"
elif diff_s < 3600:
return f"{diff_s // 60}m {diff_s % 60}s"
elif diff_s < 86400:
h = diff_s // 3600
m = (diff_s % 3600) // 60
return f"{h}h {m}m"
else:
d = diff_s // 86400
h = (diff_s % 86400) // 3600
return f"{d}d {h}h"
@@ -0,0 +1,80 @@
"""System commands — save, startup, version for PM2."""
from typing import Any
from ..utils.pm2_backend import pm2_save as backend_save
from ..utils.pm2_backend import pm2_startup as backend_startup
from ..utils.pm2_backend import pm2_version as backend_version
def save(as_json: bool = False) -> dict[str, Any]:
"""Save the current PM2 process list.
Args:
as_json: If True, return structured dict.
Returns:
Dict with success status and message.
"""
result = backend_save()
if as_json:
return {
"action": "save",
"success": result["success"],
"stdout": result["stdout"],
"stderr": result["stderr"],
}
return {
"success": result["success"],
"message": "PM2 process list saved" if result["success"]
else f"Failed to save: {result['stderr'].strip()}",
}
def startup(as_json: bool = False) -> dict[str, Any]:
"""Generate PM2 startup script.
Args:
as_json: If True, return structured dict.
Returns:
Dict with success status, message, and any instructions.
"""
result = backend_startup()
if as_json:
return {
"action": "startup",
"success": result["success"],
"stdout": result["stdout"],
"stderr": result["stderr"],
}
output = result["stdout"].strip()
return {
"success": result["success"],
"message": "Startup script generated" if result["success"]
else f"Startup command output:\n{output}",
"instructions": output,
}
def version(as_json: bool = False) -> dict[str, Any] | str:
"""Get PM2 version.
Args:
as_json: If True, return structured dict.
Returns:
Version string or dict.
"""
ver = backend_version()
if as_json:
return {
"version": ver,
}
return ver
@@ -0,0 +1,371 @@
"""PM2 CLI — Click-based CLI with REPL mode for PM2 process management.
Entry point: cli-anything-pm2
"""
import json
import sys
import click
from .core import processes, lifecycle, logs, system
# ── Helpers ──────────────────────────────────────────────────────────────
def _output(data, as_json: bool):
"""Print data as JSON or formatted text."""
if as_json:
if isinstance(data, str):
click.echo(json.dumps({"result": data}, indent=2))
else:
click.echo(json.dumps(data, indent=2, default=str))
else:
if isinstance(data, str):
click.echo(data)
elif isinstance(data, dict):
if "message" in data:
prefix = "OK" if data.get("success", True) else "ERROR"
click.echo(f"[{prefix}] {data['message']}")
if data.get("content"):
click.echo(data["content"])
if data.get("instructions"):
click.echo(data["instructions"])
else:
for k, v in data.items():
click.echo(f" {k}: {v}")
elif isinstance(data, list):
if data and isinstance(data[0], dict):
# Print as table
if not data:
return
keys = list(data[0].keys())
# Header
header = " ".join(f"{k:<15}" for k in keys)
click.echo(header)
click.echo("-" * len(header))
for row in data:
line = " ".join(f"{str(row.get(k, '')):<15}" for k in keys)
click.echo(line)
else:
for item in data:
click.echo(str(item))
# ── Main Group ───────────────────────────────────────────────────────────
@click.group(invoke_without_command=True)
@click.option("--json", "as_json", is_flag=True, default=False,
help="Output in JSON format.")
@click.pass_context
def main(ctx, as_json):
"""CLI-Anything PM2 — Process management harness for PM2."""
ctx.ensure_object(dict)
ctx.obj["json"] = as_json
if ctx.invoked_subcommand is None:
# Launch REPL mode
_run_repl()
# ── Process Group ────────────────────────────────────────────────────────
@main.group()
@click.pass_context
def process(ctx):
"""Process info commands: list, describe, metrics."""
pass
@process.command("list")
@click.pass_context
def process_list(ctx):
"""List all PM2 processes."""
as_json = ctx.obj["json"]
data = processes.list_processes(as_json=as_json)
_output(data, as_json)
@process.command("describe")
@click.argument("name")
@click.pass_context
def process_describe(ctx, name):
"""Show detailed info for a PM2 process."""
as_json = ctx.obj["json"]
data = processes.describe_process(name, as_json=as_json)
if data is None:
click.echo(f"Process '{name}' not found.", err=True)
sys.exit(1)
_output(data, as_json)
@process.command("metrics")
@click.pass_context
def process_metrics(ctx):
"""Show CPU/memory metrics for all processes."""
as_json = ctx.obj["json"]
data = processes.get_metrics(as_json=as_json)
_output(data, as_json)
# ── Lifecycle Group ──────────────────────────────────────────────────────
@main.group()
@click.pass_context
def lifecycle(ctx):
"""Lifecycle commands: start, stop, restart, delete."""
pass
@lifecycle.command("restart")
@click.argument("name")
@click.pass_context
def lifecycle_restart(ctx, name):
"""Restart a PM2 process."""
as_json = ctx.obj["json"]
data = lifecycle_mod.restart_process(name, as_json=as_json)
_output(data, as_json)
@lifecycle.command("stop")
@click.argument("name")
@click.pass_context
def lifecycle_stop(ctx, name):
"""Stop a PM2 process."""
as_json = ctx.obj["json"]
data = lifecycle_mod.stop_process(name, as_json=as_json)
_output(data, as_json)
@lifecycle.command("start")
@click.argument("script")
@click.option("--name", default=None, help="Process name.")
@click.pass_context
def lifecycle_start(ctx, script, name):
"""Start a new PM2 process."""
as_json = ctx.obj["json"]
data = lifecycle_mod.start_process(script, name=name, as_json=as_json)
_output(data, as_json)
@lifecycle.command("delete")
@click.argument("name")
@click.pass_context
def lifecycle_delete(ctx, name):
"""Delete a PM2 process."""
as_json = ctx.obj["json"]
data = lifecycle_mod.delete_process(name, as_json=as_json)
_output(data, as_json)
# Alias to avoid name collision with the click group
lifecycle_mod = lifecycle_module = None
def _init_lifecycle_mod():
"""Lazy-init the lifecycle module reference."""
global lifecycle_mod
if lifecycle_mod is None:
from .core import lifecycle as _lc
lifecycle_mod = _lc
# Patch lifecycle commands to use the module
_init_lifecycle_mod()
# ── Logs Group ───────────────────────────────────────────────────────────
@main.group("logs")
@click.pass_context
def logs_group(ctx):
"""Log commands: view, flush."""
pass
@logs_group.command("view")
@click.argument("name")
@click.option("--lines", default=20, help="Number of log lines.")
@click.pass_context
def logs_view(ctx, name, lines):
"""View recent logs for a PM2 process."""
as_json = ctx.obj["json"]
data = logs.view_logs(name, lines=lines, as_json=as_json)
_output(data, as_json)
@logs_group.command("flush")
@click.argument("name", required=False, default=None)
@click.pass_context
def logs_flush(ctx, name):
"""Flush logs for a process (or all if no name given)."""
as_json = ctx.obj["json"]
data = logs.flush_logs(name=name, as_json=as_json)
_output(data, as_json)
# ── System Group ─────────────────────────────────────────────────────────
@main.group("system")
@click.pass_context
def system_group(ctx):
"""System commands: save, startup, version."""
pass
@system_group.command("save")
@click.pass_context
def system_save(ctx):
"""Save current PM2 process list."""
as_json = ctx.obj["json"]
data = system.save(as_json=as_json)
_output(data, as_json)
@system_group.command("startup")
@click.pass_context
def system_startup(ctx):
"""Generate PM2 startup script."""
as_json = ctx.obj["json"]
data = system.startup(as_json=as_json)
_output(data, as_json)
@system_group.command("version")
@click.pass_context
def system_version(ctx):
"""Show PM2 version."""
as_json = ctx.obj["json"]
data = system.version(as_json=as_json)
_output(data, as_json)
# ── REPL Mode ────────────────────────────────────────────────────────────
def _run_repl():
"""Launch the interactive REPL."""
from .utils.repl_skin import ReplSkin
skin = ReplSkin("pm2", version="1.0.0")
skin.print_banner()
session = skin.create_prompt_session()
# REPL command mapping
repl_commands = {
"process list": lambda args, j: _output(processes.list_processes(as_json=j), j),
"process describe": lambda args, j: _output(
processes.describe_process(args[0], as_json=j) if args else "Usage: process describe <name>", j
),
"process metrics": lambda args, j: _output(processes.get_metrics(as_json=j), j),
"lifecycle restart": lambda args, j: _output(
lifecycle_mod.restart_process(args[0], as_json=j) if args else "Usage: lifecycle restart <name>", j
),
"lifecycle stop": lambda args, j: _output(
lifecycle_mod.stop_process(args[0], as_json=j) if args else "Usage: lifecycle stop <name>", j
),
"lifecycle start": lambda args, j: _repl_start(args, j),
"lifecycle delete": lambda args, j: _output(
lifecycle_mod.delete_process(args[0], as_json=j) if args else "Usage: lifecycle delete <name>", j
),
"logs view": lambda args, j: _repl_logs_view(args, j),
"logs flush": lambda args, j: _output(logs.flush_logs(name=args[0] if args else None, as_json=j), j),
"system save": lambda args, j: _output(system.save(as_json=j), j),
"system startup": lambda args, j: _output(system.startup(as_json=j), j),
"system version": lambda args, j: _output(system.version(as_json=j), j),
}
help_commands = {
"process list": "List all PM2 processes",
"process describe N": "Detailed info for process N",
"process metrics": "CPU/memory metrics for all processes",
"lifecycle start S": "Start script S [--name N]",
"lifecycle stop N": "Stop process N",
"lifecycle restart N":"Restart process N",
"lifecycle delete N": "Delete process N",
"logs view N": "View logs for process N [--lines 50]",
"logs flush [N]": "Flush logs (optionally for process N)",
"system save": "Save PM2 process list",
"system startup": "Generate startup script",
"system version": "Show PM2 version",
"help": "Show this help",
"quit / exit": "Exit the REPL",
}
while True:
try:
user_input = skin.get_input(session)
except (EOFError, KeyboardInterrupt):
skin.print_goodbye()
break
if not user_input:
continue
raw = user_input.strip()
if raw in ("quit", "exit", "q"):
skin.print_goodbye()
break
if raw == "help":
skin.help(help_commands)
continue
# Check for --json flag in input
as_json = False
if "--json" in raw:
as_json = True
raw = raw.replace("--json", "").strip()
# Match command
matched = False
for cmd_key, handler in repl_commands.items():
if raw.startswith(cmd_key):
remainder = raw[len(cmd_key):].strip()
args = remainder.split() if remainder else []
try:
handler(args, as_json)
except Exception as e:
skin.error(str(e))
matched = True
break
if not matched:
skin.warning(f"Unknown command: {raw}")
skin.hint("Type 'help' for available commands.")
def _repl_start(args, as_json):
"""Handle 'lifecycle start' in REPL with --name parsing."""
if not args:
click.echo("Usage: lifecycle start <script> [--name <name>]")
return
script = args[0]
name = None
if "--name" in args:
idx = args.index("--name")
if idx + 1 < len(args):
name = args[idx + 1]
_output(lifecycle_mod.start_process(script, name=name, as_json=as_json), as_json)
def _repl_logs_view(args, as_json):
"""Handle 'logs view' in REPL with --lines parsing."""
if not args:
click.echo("Usage: logs view <name> [--lines N]")
return
name = args[0]
lines = 20
if "--lines" in args:
idx = args.index("--lines")
if idx + 1 < len(args):
try:
lines = int(args[idx + 1])
except ValueError:
pass
_output(logs.view_logs(name, lines=lines, as_json=as_json), as_json)
if __name__ == "__main__":
main()
@@ -0,0 +1,111 @@
---
name: >-
cli-anything-pm2
description: >-
Command-line interface for PM2 - A stateless CLI for Node.js process management via the PM2 CLI. List, start, stop, restart processes, view logs, and manage system configuration.
---
# cli-anything-pm2
A stateless command-line interface for PM2 process management.
Communicates via the PM2 CLI subprocess. No local state or session.
## Installation
```bash
pip install -e .
```
**Prerequisites:**
- Python 3.10+
- PM2 installed globally (`npm install -g pm2`)
## Usage
### Basic Commands
```bash
# Show help
cli-anything-pm2 --help
# Start interactive REPL mode
cli-anything-pm2
# Run with JSON output (for agent consumption)
cli-anything-pm2 --json process list
cli-anything-pm2 --json system version
```
### REPL Mode
When invoked without a subcommand, the CLI enters an interactive REPL session:
```bash
cli-anything-pm2
# Enter commands interactively with tab-completion and history
```
## Command Groups
### process
Process inspection commands.
| Command | Description |
|---------|-------------|
| `list` | List all PM2 processes |
| `describe <name>` | Get detailed info for a process |
| `metrics` | Get metrics for all processes |
### lifecycle
Process lifecycle commands.
| Command | Description |
|---------|-------------|
| `start <script> --name <name>` | Start a new process |
| `stop <name>` | Stop a process |
| `restart <name>` | Restart a process |
| `delete <name>` | Delete a process |
### logs
Log management commands.
| Command | Description |
|---------|-------------|
| `view <name> --lines 50` | View recent logs |
| `flush [name]` | Flush logs |
### system
System-level commands.
| Command | Description |
|---------|-------------|
| `save` | Save current process list |
| `startup` | Generate startup script |
| `version` | Get PM2 version |
## Output Formats
All commands support dual output modes:
- **Human-readable** (default): Tables, colors, formatted text
- **Machine-readable** (`--json` flag): Structured JSON for agent consumption
```bash
# Human output
cli-anything-pm2 process list
# JSON output for agents
cli-anything-pm2 --json process list
```
## For AI Agents
When using this CLI programmatically:
1. **Always use `--json` flag** for parseable output
2. **Check return codes** - 0 for success, non-zero for errors
3. **Parse stderr** for error messages on failure
## Version
1.0.0
@@ -0,0 +1,84 @@
# PM2 CLI Tests
Test suites for the PM2 CLI-Anything harness.
## Test Files
| File | Type | Count | Dependencies |
|-------------------|--------|-------|----------------------|
| `test_core.py` | Unit | 28 | None (mocked subprocess) |
| `test_full_e2e.py`| E2E | 9 | pm2 installed, CLI on PATH |
## Running Tests
```bash
cd agent-harness
pip install -e .
# All tests
python -m pytest cli_anything/pm2/tests/ -v
# Unit tests only (no pm2 needed)
python -m pytest cli_anything/pm2/tests/test_core.py -v
# E2E tests only (requires pm2)
python -m pytest cli_anything/pm2/tests/test_full_e2e.py -v
```
## Test Plan
### Unit Tests (test_core.py)
- [x] `_find_pm2()` locates binary via shutil.which
- [x] `_find_pm2()` raises RuntimeError when pm2 missing
- [x] `run_pm2()` returns success dict on exit code 0
- [x] `run_pm2()` returns failure dict on exit code 1
- [x] `run_pm2()` parses JSON stdout with capture_json=True
- [x] `run_pm2()` extracts JSON from stdout with non-JSON preamble
- [x] `run_pm2()` handles subprocess timeout
- [x] `run_pm2()` handles FileNotFoundError for missing binary
- [x] `list_processes()` JSON mode returns raw list
- [x] `list_processes()` human mode returns formatted rows
- [x] `list_processes()` returns message when no processes
- [x] `describe_process()` returns details for known process
- [x] `describe_process()` returns None for unknown process
- [x] `get_metrics()` JSON mode returns metric dicts
- [x] `restart_process()` success message
- [x] `stop_process()` failure message
- [x] `start_process()` with name in JSON mode
- [x] `view_logs()` success with content
- [x] `flush_logs()` all processes
- [x] `version()` JSON mode
- [x] `save()` success message
- [x] `_output()` JSON string wrapping
- [x] `_output()` JSON dict passthrough
- [x] `_output()` human string echo
- [x] `_output()` human dict with [OK] prefix
- [x] `_output()` human dict with [ERROR] prefix
- [x] `_format_bytes()` zero bytes
- [x] `_format_bytes()` megabytes
### E2E Tests (test_full_e2e.py)
- [x] `--json process list` returns valid JSON array
- [x] `process list` exits with code 0
- [x] `--json process describe <name>` returns valid JSON dict
- [x] `process describe __nonexistent__` exits non-zero
- [x] `--json system version` returns JSON with version key
- [x] `system version` returns version string with digits
- [x] `--help` exits with code 0
- [x] `process --help` shows list and describe subcommands
- [x] `--json process metrics` returns JSON list
## Last Run Results
```
Platform: darwin (macOS)
Python: 3.14.3
pytest: 9.0.2
Date: 2026-03-23
37 passed in 1.53s
- test_core.py: 28 passed
- test_full_e2e.py: 9 passed
```
@@ -0,0 +1,356 @@
"""Unit tests for the PM2 CLI-Anything harness.
All subprocess calls are mocked -- no pm2 binary required.
Covers: pm2_backend, core/processes, core/lifecycle, core/logs, core/system,
pm2_cli output formatting, and error handling.
"""
import json
import subprocess
from unittest import mock
from unittest.mock import MagicMock, patch
import pytest
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_run_result(stdout="", stderr="", returncode=0):
"""Build a mock subprocess.CompletedProcess."""
r = MagicMock(spec=subprocess.CompletedProcess)
r.stdout = stdout
r.stderr = stderr
r.returncode = returncode
return r
FAKE_JLIST = json.dumps([
{
"pm_id": 0,
"name": "seaclip-dev",
"pid": 12345,
"monit": {"cpu": 2.5, "memory": 52428800},
"pm2_env": {
"status": "online",
"restart_time": 3,
"pm_uptime": 1700000000000,
"pm_exec_path": "/app/index.js",
"pm_cwd": "/app",
"exec_interpreter": "node",
"exec_mode": "fork_mode",
"node_version": "20.11.0",
},
},
{
"pm_id": 1,
"name": "hub-dashboard",
"pid": 12346,
"monit": {"cpu": 0.1, "memory": 10485760},
"pm2_env": {
"status": "stopped",
"restart_time": 0,
"pm_uptime": 0,
"pm_exec_path": "/dash/server.js",
"pm_cwd": "/dash",
"exec_interpreter": "node",
"exec_mode": "fork_mode",
"node_version": "20.11.0",
},
},
])
# ===========================================================================
# 1. pm2_backend._find_pm2
# ===========================================================================
class TestFindPm2:
"""Tests for pm2 binary discovery."""
@patch("shutil.which", return_value="/opt/homebrew/bin/pm2")
def test_find_pm2_found(self, mock_which):
from cli_anything.pm2.utils.pm2_backend import _find_pm2
assert _find_pm2() == "/opt/homebrew/bin/pm2"
@patch("shutil.which", return_value=None)
def test_find_pm2_not_found_raises(self, mock_which):
from cli_anything.pm2.utils.pm2_backend import _find_pm2
with pytest.raises(RuntimeError, match="pm2 not found"):
_find_pm2()
# ===========================================================================
# 2. pm2_backend.run_pm2
# ===========================================================================
class TestRunPm2:
"""Tests for the core run_pm2 subprocess wrapper."""
@patch("cli_anything.pm2.utils.pm2_backend._get_pm2", return_value="/usr/bin/pm2")
@patch("subprocess.run")
def test_run_pm2_success(self, mock_run, mock_get):
mock_run.return_value = _make_run_result(stdout="OK\n", returncode=0)
from cli_anything.pm2.utils.pm2_backend import run_pm2
result = run_pm2("save")
assert result["success"] is True
assert result["returncode"] == 0
assert "OK" in result["stdout"]
@patch("cli_anything.pm2.utils.pm2_backend._get_pm2", return_value="/usr/bin/pm2")
@patch("subprocess.run")
def test_run_pm2_failure(self, mock_run, mock_get):
mock_run.return_value = _make_run_result(stderr="error", returncode=1)
from cli_anything.pm2.utils.pm2_backend import run_pm2
result = run_pm2("restart", "ghost")
assert result["success"] is False
assert result["returncode"] == 1
@patch("cli_anything.pm2.utils.pm2_backend._get_pm2", return_value="/usr/bin/pm2")
@patch("subprocess.run")
def test_run_pm2_json_parsing(self, mock_run, mock_get):
mock_run.return_value = _make_run_result(stdout=FAKE_JLIST, returncode=0)
from cli_anything.pm2.utils.pm2_backend import run_pm2
result = run_pm2("jlist", capture_json=True)
assert result["success"] is True
assert isinstance(result["data"], list)
assert len(result["data"]) == 2
assert result["data"][0]["name"] == "seaclip-dev"
@patch("cli_anything.pm2.utils.pm2_backend._get_pm2", return_value="/usr/bin/pm2")
@patch("subprocess.run")
def test_run_pm2_json_with_preamble(self, mock_run, mock_get):
"""JSON extraction works even with non-JSON text before the array."""
preamble = "PM2 info line\n" + FAKE_JLIST
mock_run.return_value = _make_run_result(stdout=preamble, returncode=0)
from cli_anything.pm2.utils.pm2_backend import run_pm2
result = run_pm2("jlist", capture_json=True)
assert result["data"] is not None
assert isinstance(result["data"], list)
@patch("cli_anything.pm2.utils.pm2_backend._get_pm2", return_value="/usr/bin/pm2")
@patch("subprocess.run", side_effect=subprocess.TimeoutExpired(cmd="pm2", timeout=30))
def test_run_pm2_timeout(self, mock_run, mock_get):
from cli_anything.pm2.utils.pm2_backend import run_pm2
result = run_pm2("jlist", timeout=30)
assert result["success"] is False
assert "timed out" in result["stderr"]
@patch("cli_anything.pm2.utils.pm2_backend._get_pm2", return_value="/nonexistent/pm2")
@patch("subprocess.run", side_effect=FileNotFoundError())
def test_run_pm2_binary_missing(self, mock_run, mock_get):
from cli_anything.pm2.utils.pm2_backend import run_pm2
result = run_pm2("jlist")
assert result["success"] is False
assert "not found" in result["stderr"]
# ===========================================================================
# 3. core/processes.py
# ===========================================================================
class TestProcesses:
"""Tests for process listing, describe, and metrics."""
@patch("cli_anything.pm2.core.processes.pm2_jlist")
def test_list_processes_json(self, mock_jlist):
mock_jlist.return_value = json.loads(FAKE_JLIST)
from cli_anything.pm2.core.processes import list_processes
result = list_processes(as_json=True)
assert isinstance(result, list)
assert len(result) == 2
@patch("cli_anything.pm2.core.processes.pm2_jlist")
def test_list_processes_human(self, mock_jlist):
mock_jlist.return_value = json.loads(FAKE_JLIST)
from cli_anything.pm2.core.processes import list_processes
result = list_processes(as_json=False)
assert isinstance(result, list)
assert result[0]["name"] == "seaclip-dev"
assert result[0]["status"] == "online"
@patch("cli_anything.pm2.core.processes.pm2_jlist")
def test_list_processes_empty(self, mock_jlist):
mock_jlist.return_value = []
from cli_anything.pm2.core.processes import list_processes
result = list_processes(as_json=False)
assert result == "No PM2 processes running."
@patch("cli_anything.pm2.core.processes.pm2_describe")
def test_describe_process_found(self, mock_desc):
mock_desc.return_value = json.loads(FAKE_JLIST)[0]
from cli_anything.pm2.core.processes import describe_process
result = describe_process("seaclip-dev", as_json=False)
assert isinstance(result, dict)
assert result["Name"] == "seaclip-dev"
@patch("cli_anything.pm2.core.processes.pm2_describe")
def test_describe_process_not_found(self, mock_desc):
mock_desc.return_value = None
from cli_anything.pm2.core.processes import describe_process
result = describe_process("ghost", as_json=False)
assert result is None
@patch("cli_anything.pm2.core.processes.pm2_jlist")
def test_get_metrics_json(self, mock_jlist):
mock_jlist.return_value = json.loads(FAKE_JLIST)
from cli_anything.pm2.core.processes import get_metrics
result = get_metrics(as_json=True)
assert isinstance(result, list)
assert result[0]["cpu"] == 2.5
# ===========================================================================
# 4. core/lifecycle.py
# ===========================================================================
class TestLifecycle:
"""Tests for lifecycle commands."""
@patch("cli_anything.pm2.core.lifecycle.pm2_action")
def test_restart_success(self, mock_action):
mock_action.return_value = {
"success": True, "returncode": 0,
"stdout": "restarted", "stderr": "",
}
from cli_anything.pm2.core.lifecycle import restart_process
result = restart_process("seaclip-dev", as_json=False)
assert result["success"] is True
assert "Restarted" in result["message"]
@patch("cli_anything.pm2.core.lifecycle.pm2_action")
def test_stop_failure(self, mock_action):
mock_action.return_value = {
"success": False, "returncode": 1,
"stdout": "", "stderr": "process not found",
}
from cli_anything.pm2.core.lifecycle import stop_process
result = stop_process("ghost", as_json=False)
assert result["success"] is False
assert "Failed" in result["message"]
@patch("cli_anything.pm2.core.lifecycle.backend_start")
def test_start_with_name(self, mock_start):
mock_start.return_value = {
"success": True, "returncode": 0,
"stdout": "started", "stderr": "",
}
from cli_anything.pm2.core.lifecycle import start_process
result = start_process("/app/index.js", name="my-app", as_json=True)
assert result["success"] is True
assert result["name"] == "my-app"
# ===========================================================================
# 5. core/logs.py
# ===========================================================================
class TestLogs:
"""Tests for log commands."""
@patch("cli_anything.pm2.core.logs.backend_logs")
def test_view_logs_success(self, mock_logs):
mock_logs.return_value = {
"success": True, "returncode": 0,
"stdout": "line1\nline2\n", "stderr": "",
}
from cli_anything.pm2.core.logs import view_logs
result = view_logs("seaclip-dev", lines=20, as_json=False)
assert result["success"] is True
assert "line1" in result["content"]
@patch("cli_anything.pm2.core.logs.backend_flush")
def test_flush_all(self, mock_flush):
mock_flush.return_value = {
"success": True, "returncode": 0,
"stdout": "flushed", "stderr": "",
}
from cli_anything.pm2.core.logs import flush_logs
result = flush_logs(name=None, as_json=False)
assert result["success"] is True
assert "all processes" in result["message"]
# ===========================================================================
# 6. core/system.py
# ===========================================================================
class TestSystem:
"""Tests for system commands."""
@patch("cli_anything.pm2.core.system.backend_version")
def test_version_json(self, mock_ver):
mock_ver.return_value = "5.3.0"
from cli_anything.pm2.core.system import version
result = version(as_json=True)
assert result["version"] == "5.3.0"
@patch("cli_anything.pm2.core.system.backend_save")
def test_save_success(self, mock_save):
mock_save.return_value = {
"success": True, "returncode": 0,
"stdout": "saved", "stderr": "",
}
from cli_anything.pm2.core.system import save
result = save(as_json=False)
assert result["success"] is True
assert "saved" in result["message"].lower()
# ===========================================================================
# 7. Output formatting (_output helper)
# ===========================================================================
class TestOutputFormatting:
"""Tests for the _output helper in pm2_cli."""
def test_output_json_string(self, capsys):
from cli_anything.pm2.pm2_cli import _output
_output("hello", as_json=True)
captured = capsys.readouterr()
parsed = json.loads(captured.out)
assert parsed["result"] == "hello"
def test_output_json_dict(self, capsys):
from cli_anything.pm2.pm2_cli import _output
_output({"key": "val"}, as_json=True)
captured = capsys.readouterr()
parsed = json.loads(captured.out)
assert parsed["key"] == "val"
def test_output_human_string(self, capsys):
from cli_anything.pm2.pm2_cli import _output
_output("hello world", as_json=False)
captured = capsys.readouterr()
assert "hello world" in captured.out
def test_output_human_dict_with_message(self, capsys):
from cli_anything.pm2.pm2_cli import _output
_output({"success": True, "message": "Done"}, as_json=False)
captured = capsys.readouterr()
assert "[OK] Done" in captured.out
def test_output_human_error_message(self, capsys):
from cli_anything.pm2.pm2_cli import _output
_output({"success": False, "message": "Oops"}, as_json=False)
captured = capsys.readouterr()
assert "[ERROR] Oops" in captured.out
# ===========================================================================
# 8. Utility: _format_bytes
# ===========================================================================
class TestFormatBytes:
"""Tests for the byte formatting utility."""
def test_zero_bytes(self):
from cli_anything.pm2.core.processes import _format_bytes
assert _format_bytes(0) == "0 B"
def test_megabytes(self):
from cli_anything.pm2.core.processes import _format_bytes
result = _format_bytes(52428800) # 50 MB
assert "MB" in result
assert "50.0" in result
@@ -0,0 +1,157 @@
"""End-to-end tests for the PM2 CLI-Anything harness.
These tests call the REAL pm2 binary and the installed cli-anything-pm2
CLI. They require:
- pm2 installed globally (npm install -g pm2)
- cli-anything-pm2 installed (pip install -e .)
- PM2 daemon running with at least one process
Skip gracefully if pm2 is not available.
"""
import json
import os
import shutil
import subprocess
import sys
import pytest
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _resolve_cli() -> str:
"""Resolve the cli-anything-pm2 binary path.
Checks: shutil.which, then common venv/bin locations.
"""
binary = shutil.which("cli-anything-pm2")
if binary:
return binary
# Try the venv bin dir that matches the running Python
venv_bin = os.path.join(os.path.dirname(sys.executable), "cli-anything-pm2")
if os.path.isfile(venv_bin):
return venv_bin
pytest.skip("cli-anything-pm2 binary not found on PATH")
def _has_pm2() -> bool:
"""Check whether pm2 is installed."""
return shutil.which("pm2") is not None
def _run_cli(*args: str, timeout: int = 30) -> subprocess.CompletedProcess:
"""Run cli-anything-pm2 with the given arguments."""
cli = _resolve_cli()
cmd = [cli, *args]
return subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout,
)
# Skip entire module if pm2 is not installed
pytestmark = pytest.mark.skipif(
not _has_pm2(),
reason="pm2 is not installed -- skipping E2E tests",
)
# ===========================================================================
# E2E Tests
# ===========================================================================
class TestProcessListE2E:
"""E2E tests for the process list command."""
def test_process_list_json_returns_valid_json(self):
"""cli-anything-pm2 --json process list outputs parseable JSON."""
result = _run_cli("--json", "process", "list")
assert result.returncode == 0, f"stderr: {result.stderr}"
data = json.loads(result.stdout)
assert isinstance(data, list)
def test_process_list_human_returns_zero(self):
"""cli-anything-pm2 process list exits 0."""
result = _run_cli("process", "list")
assert result.returncode == 0
class TestProcessDescribeE2E:
"""E2E tests for process describe."""
def test_describe_existing_process_json(self):
"""Describe a known process and get valid JSON."""
# First get list to find an actual process name
list_result = _run_cli("--json", "process", "list")
if list_result.returncode != 0:
pytest.skip("Could not list processes")
processes = json.loads(list_result.stdout)
if not processes:
pytest.skip("No PM2 processes running")
# Pick first process
name = processes[0].get("name") or str(processes[0].get("pm_id", 0))
result = _run_cli("--json", "process", "describe", name)
assert result.returncode == 0
data = json.loads(result.stdout)
assert isinstance(data, dict)
def test_describe_nonexistent_process(self):
"""Describe a process that does not exist exits with code 1."""
result = _run_cli("--json", "process", "describe", "__nonexistent_process_xyz__")
assert result.returncode != 0
class TestSystemE2E:
"""E2E tests for system commands."""
def test_system_version_json(self):
"""cli-anything-pm2 --json system version returns valid JSON with version key."""
result = _run_cli("--json", "system", "version")
assert result.returncode == 0, f"stderr: {result.stderr}"
data = json.loads(result.stdout)
assert "version" in data
# PM2 version is a semver string like "5.3.0"
assert len(data["version"]) > 0
def test_system_version_human(self):
"""cli-anything-pm2 system version returns a version string."""
result = _run_cli("system", "version")
assert result.returncode == 0
# Should contain at least a digit (version number)
assert any(c.isdigit() for c in result.stdout)
class TestHelpE2E:
"""E2E tests for help output."""
def test_help_flag_exits_zero(self):
"""cli-anything-pm2 --help exits with code 0."""
result = _run_cli("--help")
assert result.returncode == 0
assert "CLI-Anything PM2" in result.stdout or "Usage" in result.stdout
def test_process_help(self):
"""cli-anything-pm2 process --help shows subcommands."""
result = _run_cli("process", "--help")
assert result.returncode == 0
assert "list" in result.stdout
assert "describe" in result.stdout
class TestProcessMetricsE2E:
"""E2E test for process metrics."""
def test_metrics_json_returns_list(self):
"""cli-anything-pm2 --json process metrics returns a JSON list."""
result = _run_cli("--json", "process", "metrics")
assert result.returncode == 0
data = json.loads(result.stdout)
assert isinstance(data, list)
@@ -0,0 +1,243 @@
"""PM2 Backend — subprocess wrapper for all PM2 CLI commands.
All PM2 interactions go through this module. It finds the pm2 binary,
runs commands via subprocess.run(), and returns structured results.
"""
import json
import os
import shutil
import subprocess
from typing import Any
# Common directories where pm2 may be installed (Homebrew, global npm, system).
_EXTRA_PATH_DIRS = ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin"]
def _augmented_path(base_path: str | None = None) -> str:
"""Return PATH string with _EXTRA_PATH_DIRS prepended if missing."""
path = base_path if base_path is not None else os.environ.get("PATH", "")
for p in _EXTRA_PATH_DIRS:
if p not in path:
path = f"{p}:{path}"
return path
def _find_pm2() -> str:
"""Locate the pm2 binary on the system.
Checks common Homebrew and global npm paths in addition to PATH.
Returns:
Absolute path to the pm2 binary.
Raises:
RuntimeError: If pm2 is not found.
"""
pm2_path = shutil.which("pm2", path=_augmented_path())
if pm2_path is None:
raise RuntimeError(
"pm2 not found on this system. "
"Install it with: npm install -g pm2"
)
return pm2_path
# Cache the pm2 path at module level
_PM2_BIN: str | None = None
def _get_pm2() -> str:
"""Get cached pm2 binary path."""
global _PM2_BIN
if _PM2_BIN is None:
_PM2_BIN = _find_pm2()
return _PM2_BIN
def _build_env() -> dict[str, str]:
"""Build environment dict with proper PATH for subprocess."""
env = os.environ.copy()
env["PATH"] = _augmented_path(env.get("PATH", ""))
return env
def run_pm2(
*args: str,
capture_json: bool = False,
timeout: int = 30,
) -> dict[str, Any]:
"""Run a pm2 command and return the result.
Args:
*args: Arguments to pass to pm2 (e.g., "jlist", "restart", "myapp").
capture_json: If True, attempt to parse stdout as JSON.
timeout: Command timeout in seconds.
Returns:
Dict with keys:
- success (bool): Whether command exited with code 0.
- returncode (int): Process return code.
- stdout (str): Raw stdout.
- stderr (str): Raw stderr.
- data (Any): Parsed JSON data if capture_json=True, else None.
"""
pm2 = _get_pm2()
cmd = [pm2, *args]
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout,
env=_build_env(),
)
except subprocess.TimeoutExpired:
return {
"success": False,
"returncode": -1,
"stdout": "",
"stderr": f"Command timed out after {timeout}s: {' '.join(cmd)}",
"data": None,
}
except FileNotFoundError:
return {
"success": False,
"returncode": -1,
"stdout": "",
"stderr": f"pm2 binary not found at: {pm2}",
"data": None,
}
parsed_data = None
if capture_json and result.returncode == 0:
try:
parsed_data = json.loads(result.stdout)
except (json.JSONDecodeError, ValueError):
# stdout may contain non-JSON preamble; try to extract JSON array
stdout = result.stdout.strip()
# Look for JSON array or object
for start_char, end_char in [("[", "]"), ("{", "}")]:
idx_start = stdout.find(start_char)
idx_end = stdout.rfind(end_char)
if idx_start != -1 and idx_end > idx_start:
try:
parsed_data = json.loads(stdout[idx_start:idx_end + 1])
break
except (json.JSONDecodeError, ValueError):
continue
return {
"success": result.returncode == 0,
"returncode": result.returncode,
"stdout": result.stdout,
"stderr": result.stderr,
"data": parsed_data,
}
def pm2_jlist() -> list[dict[str, Any]]:
"""Get JSON list of all PM2 processes.
Returns:
List of process info dicts, or empty list on failure.
"""
result = run_pm2("jlist", capture_json=True)
if result["success"] and isinstance(result["data"], list):
return result["data"]
return []
def pm2_describe(name: str) -> dict[str, Any] | None:
"""Get detailed info for a specific process.
Uses pm2 jlist and filters by name/id, since pm2 describe
does not produce JSON output.
Args:
name: Process name or ID.
Returns:
Process description dict, or None on failure.
"""
processes = pm2_jlist()
for p in processes:
if p.get("name") == name or str(p.get("pm_id")) == str(name):
return p
return None
def pm2_action(action: str, name: str) -> dict[str, Any]:
"""Run a lifecycle action (restart, stop, delete) on a process.
Args:
action: One of "restart", "stop", "delete".
name: Process name or ID.
Returns:
Result dict from run_pm2.
"""
return run_pm2(action, str(name))
def pm2_start(script: str, name: str | None = None) -> dict[str, Any]:
"""Start a new PM2 process.
Args:
script: Path to script or ecosystem file.
name: Optional process name.
Returns:
Result dict from run_pm2.
"""
args = ["start", script]
if name:
args.extend(["--name", name])
return run_pm2(*args)
def pm2_logs(name: str, lines: int = 20) -> dict[str, Any]:
"""Get recent logs for a process.
Args:
name: Process name or ID.
lines: Number of log lines to retrieve.
Returns:
Result dict from run_pm2.
"""
return run_pm2("logs", str(name), "--lines", str(lines), "--nostream")
def pm2_flush(name: str | None = None) -> dict[str, Any]:
"""Flush logs for a process or all processes.
Args:
name: Process name or ID. If None, flushes all.
Returns:
Result dict from run_pm2.
"""
args = ["flush"]
if name:
args.append(str(name))
return run_pm2(*args)
def pm2_save() -> dict[str, Any]:
"""Save the current PM2 process list."""
return run_pm2("save")
def pm2_startup() -> dict[str, Any]:
"""Generate PM2 startup script."""
return run_pm2("startup")
def pm2_version() -> str:
"""Get PM2 version string."""
result = run_pm2("--version")
if result["success"]:
return result["stdout"].strip()
return "unknown"
@@ -0,0 +1,500 @@
"""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("ollama", version="1.0.0")
skin.print_banner()
prompt_text = skin.prompt(project_name="llama3.2", modified=False)
skin.success("Model pulled")
skin.error("Connection failed")
skin.warning("No models loaded")
skin.info("Generating...")
skin.status("Model", "llama3.2:latest")
skin.table(headers, rows)
skin.print_goodbye()
"""
import os
import sys
# ── ANSI color codes (no external deps for core styling) ──────────────
_RESET = "\033[0m"
_BOLD = "\033[1m"
_DIM = "\033[2m"
_ITALIC = "\033[3m"
_UNDERLINE = "\033[4m"
# Brand colors
_CYAN = "\033[38;5;80m" # cli-anything brand cyan
_CYAN_BG = "\033[48;5;80m"
_WHITE = "\033[97m"
_GRAY = "\033[38;5;245m"
_DARK_GRAY = "\033[38;5;240m"
_LIGHT_GRAY = "\033[38;5;250m"
# Software accent colors — each software gets a unique accent
_ACCENT_COLORS = {
"gimp": "\033[38;5;214m", # warm orange
"blender": "\033[38;5;208m", # deep orange
"inkscape": "\033[38;5;39m", # bright blue
"audacity": "\033[38;5;33m", # navy blue
"libreoffice": "\033[38;5;40m", # green
"obs_studio": "\033[38;5;55m", # purple
"kdenlive": "\033[38;5;69m", # slate blue
"shotcut": "\033[38;5;35m", # teal green
"ollama": "\033[38;5;255m", # white (Ollama branding)
}
_DEFAULT_ACCENT = "\033[38;5;75m" # default sky blue
# Status colors
_GREEN = "\033[38;5;78m"
_YELLOW = "\033[38;5;220m"
_RED = "\033[38;5;196m"
_BLUE = "\033[38;5;75m"
_MAGENTA = "\033[38;5;176m"
# ── Brand icon ────────────────────────────────────────────────────────
# The cli-anything icon: a small colored diamond/chevron mark
_ICON = f"{_CYAN}{_BOLD}{_RESET}"
_ICON_SMALL = f"{_CYAN}{_RESET}"
# ── Box drawing characters ────────────────────────────────────────────
_H_LINE = ""
_V_LINE = ""
_TL = ""
_TR = ""
_BL = ""
_BR = ""
_T_DOWN = ""
_T_UP = ""
_T_RIGHT = ""
_T_LEFT = ""
_CROSS = ""
def _strip_ansi(text: str) -> str:
"""Remove ANSI escape codes for length calculation."""
import re
return re.sub(r"\033\[[^m]*m", "", text)
def _visible_len(text: str) -> int:
"""Get visible length of text (excluding ANSI codes)."""
return len(_strip_ansi(text))
class ReplSkin:
"""Unified REPL skin for cli-anything CLIs.
Provides consistent branding, prompts, and message formatting
across all CLI harnesses built with the cli-anything methodology.
"""
def __init__(self, software: str, version: str = "1.0.0",
history_file: str | None = None):
"""Initialize the REPL skin.
Args:
software: Software name (e.g., "gimp", "shotcut", "ollama").
version: CLI version string.
history_file: Path for persistent command history.
Defaults to ~/.cli-anything-<software>/history
"""
self.software = software.lower().replace("-", "_")
self.display_name = software.replace("_", " ").title()
self.version = version
self.accent = _ACCENT_COLORS.get(self.software, _DEFAULT_ACCENT)
# History file
if history_file is None:
from pathlib import Path
hist_dir = Path.home() / f".cli-anything-{self.software}"
hist_dir.mkdir(parents=True, exist_ok=True)
self.history_file = str(hist_dir / "history")
else:
self.history_file = history_file
# Detect terminal capabilities
self._color = self._detect_color_support()
def _detect_color_support(self) -> bool:
"""Check if terminal supports color."""
if os.environ.get("NO_COLOR"):
return False
if os.environ.get("CLI_ANYTHING_NO_COLOR"):
return False
if not hasattr(sys.stdout, "isatty"):
return False
return sys.stdout.isatty()
def _c(self, code: str, text: str) -> str:
"""Apply color code if colors are supported."""
if not self._color:
return text
return f"{code}{text}{_RESET}"
# ── Banner ────────────────────────────────────────────────────────
def print_banner(self):
"""Print the startup banner with branding."""
inner = 54
def _box_line(content: str) -> str:
"""Wrap content in box drawing, padding to inner width."""
pad = inner - _visible_len(content)
vl = self._c(_DARK_GRAY, _V_LINE)
return f"{vl}{content}{' ' * max(0, pad)}{vl}"
top = self._c(_DARK_GRAY, f"{_TL}{_H_LINE * inner}{_TR}")
bot = self._c(_DARK_GRAY, f"{_BL}{_H_LINE * inner}{_BR}")
# Title: ◆ cli-anything · Ollama
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 = ""
print(top)
print(_box_line(title))
print(_box_line(ver))
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
"\033[38;5;255m": "#eeeeee", # ollama white
}
+44
View File
@@ -0,0 +1,44 @@
"""Setup for cli-anything-pm2 — CLI harness for PM2 process management."""
from setuptools import setup, find_namespace_packages
setup(
name="cli-anything-pm2",
version="1.0.0",
author="cli-anything contributors",
author_email="",
description="CLI-Anything harness for PM2 process management",
url="https://github.com/HKUDS/CLI-Anything",
packages=find_namespace_packages(include=["cli_anything.*"]),
classifiers=[
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"Topic :: Software Development :: Libraries :: Python Modules",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
],
python_requires=">=3.10",
install_requires=[
"click>=8.0.0",
"prompt-toolkit>=3.0.0",
],
extras_require={
"dev": [
"pytest>=7.0.0",
"pytest-cov>=4.0.0",
],
},
entry_points={
"console_scripts": [
"cli-anything-pm2=cli_anything.pm2.pm2_cli:main",
],
},
package_data={
"cli_anything.pm2": ["skills/*.md"],
},
include_package_data=True,
zip_safe=False,
)
+56
View File
@@ -327,6 +327,48 @@
"contributor": "Alex-wuhu",
"contributor_url": "https://github.com/Alex-wuhu"
},
{
"name": "seaclip",
"display_name": "SeaClip",
"version": "1.0.0",
"description": "Kanban board, 6-agent AI pipeline, and issue management via SeaClip-Lite FastAPI + SQLite",
"requires": "SeaClip-Lite running at localhost:5200",
"homepage": "https://github.com/t4tarzan/cli-anything-seaclip",
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=seaclip/agent-harness",
"entry_point": "cli-anything-seaclip",
"skill_md": "seaclip/agent-harness/cli_anything/seaclip/skills/SKILL.md",
"category": "project-management",
"contributor": "t4tarzan",
"contributor_url": "https://github.com/t4tarzan"
},
{
"name": "pm2",
"display_name": "PM2",
"version": "1.0.0",
"description": "Node.js process management — list, start, stop, restart, logs, and metrics via PM2 CLI",
"requires": "PM2 (npm install -g pm2)",
"homepage": "https://pm2.keymetrics.io",
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=pm2/agent-harness",
"entry_point": "cli-anything-pm2",
"skill_md": "pm2/agent-harness/cli_anything/pm2/skills/SKILL.md",
"category": "devops",
"contributor": "t4tarzan",
"contributor_url": "https://github.com/t4tarzan"
},
{
"name": "chromadb",
"display_name": "ChromaDB",
"version": "1.0.0",
"description": "Vector database operations — collections, documents, semantic search via ChromaDB HTTP API",
"requires": "ChromaDB server running at localhost:8000",
"homepage": "https://www.trychroma.com",
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=chromadb/agent-harness",
"entry_point": "cli-anything-chromadb",
"skill_md": "chromadb/agent-harness/cli_anything/chromadb/skills/SKILL.md",
"category": "database",
"contributor": "t4tarzan",
"contributor_url": "https://github.com/t4tarzan"
},
{
"name": "musescore",
"display_name": "MuseScore",
@@ -522,6 +564,20 @@
"category": "ai",
"contributor": "Akabane71",
"contributor_url": "https://github.com/Akabane71"
},
{
"name": "cloudanalyzer",
"display_name": "CloudAnalyzer",
"version": "1.0.0",
"description": "Point cloud and trajectory QA: Chamfer/AUC/F1, ATE/RPE/drift, ground segmentation metrics, config-driven quality gates, baseline evolution — harness wraps the CloudAnalyzer Python API",
"requires": "Python 3.10+; cloudanalyzer (`pip install cloudanalyzer`), Open3D for full IO/viewer paths",
"homepage": "https://github.com/rsasaki0109/CloudAnalyzer",
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=cloudanalyzer/agent-harness",
"entry_point": "cli-anything-cloudanalyzer",
"skill_md": "cloudanalyzer/agent-harness/cli_anything/cloudanalyzer/skills/SKILL.md",
"category": "graphics",
"contributor": "rsasaki0109",
"contributor_url": "https://github.com/rsasaki0109"
}
]
}
+128
View File
@@ -0,0 +1,128 @@
# SeaClip-Lite - Standard Operating Procedure
## Overview
SeaClip-Lite is a lightweight project management board built on FastAPI + SQLite.
It provides issue tracking, a 6-agent CI/CD pipeline, GitHub issue scheduling,
and an activity feed. This CLI harness provides programmatic control over
SeaClip-Lite via its HTTP API and direct SQLite reads.
## Architecture
| Layer | Technology | Purpose |
|-------|-----------|---------|
| API server | FastAPI | Issue CRUD, pipeline control, scheduler sync |
| Database | SQLite | Persistent storage for issues, agents, schedules, activity |
| CLI transport | HTTP (`requests`) | JSON API calls for issues, pipeline, health |
| CLI transport | SQLite (read-only) | Direct DB reads for agents, schedules, activity |
The CLI is a thin Click application that delegates to `SeaClipBackend`, which
routes each call to either the FastAPI JSON endpoints or the SQLite database.
Some FastAPI endpoints return HTMX partials instead of JSON, so those are
served by direct SQLite queries instead.
## State Model
**Stateless.** The CLI holds no local state between invocations. Every command
reads from the API or database at call time. There is no project file, session,
or undo/redo stack.
## Backend
- **FastAPI** at `http://127.0.0.1:5200`
- **SQLite** at the path specified by the `SEACLIP_DB` environment variable
## Install
```bash
cd agent-harness
pip install -e .
```
## Usage
### One-shot commands
```bash
# Health check
cli-anything-seaclip --json server health
# Issue management
cli-anything-seaclip --json issue list
cli-anything-seaclip --json issue list --status backlog --priority high
cli-anything-seaclip --json issue create --title "Fix login bug" --priority high
cli-anything-seaclip --json issue move ISSUE_UUID --column done
cli-anything-seaclip --json issue status ISSUE_UUID --set in_progress
cli-anything-seaclip --json issue delete ISSUE_UUID
# Agent roster
cli-anything-seaclip --json agent list
# Pipeline control
cli-anything-seaclip --json pipeline start --issue UUID --mode auto
cli-anything-seaclip --json pipeline status --issue UUID
cli-anything-seaclip --json pipeline resume --issue UUID
cli-anything-seaclip --json pipeline stop --issue UUID
# Scheduler
cli-anything-seaclip --json scheduler list
cli-anything-seaclip --json scheduler add --name "nightly" --cron "0 2 * * *" --repo https://github.com/org/repo
cli-anything-seaclip --json scheduler sync SCHEDULE_ID
# Activity feed
cli-anything-seaclip --json activity list --limit 20
```
### Interactive REPL
```bash
cli-anything-seaclip
# Type help for commands, quit to exit
```
## Command Groups
| Group | Commands | Transport |
|-----------|--------------------------------|-----------|
| `issue` | list, create, move, status, delete | HTTP API |
| `agent` | list | SQLite |
| `pipeline` | start, status, resume, stop | HTTP API |
| `scheduler` | list, add, sync | SQLite (list), HTTP API (add, sync) |
| `activity` | list | SQLite |
| `server` | health | HTTP API |
## Key API Endpoints
| Method | Path | Description |
|--------|------|-------------|
| GET | `/health` | Backend health check |
| GET | `/api/issues` | List issues (query params: status, priority, search, limit) |
| POST | `/api/issues` | Create issue (JSON body: title, description, priority) |
| POST | `/api/issues/{id}/move` | Move issue to column |
| POST | `/api/issues/{id}/status` | Update issue status |
| DELETE | `/api/issues/{id}` | Delete issue |
| POST | `/api/pipeline/{id}/start` | Start pipeline |
| GET | `/api/pipeline/{id}/status` | Pipeline status |
| POST | `/api/pipeline/{id}/resume` | Resume pipeline |
| POST | `/api/pipeline/{id}/stop` | Stop pipeline |
| POST | `/api/scheduler/add` | Add schedule |
| POST | `/api/scheduler/{id}/sync` | Trigger sync |
## SQLite Tables (read-only access)
| Table | Columns used by CLI |
|-------|-------------------|
| `agents` | name, role, status, current_issue_id, last_completed_at, last_error, updated_at |
| `schedule_configs` | id, repo, enabled, interval_minutes, target_column, auto_pipeline, pipeline_mode, ai_mode, last_synced_at, issues_synced |
| `activity_log` | event_type, summary, created_at |
## Testing
```bash
cd agent-harness
pip install -e .
python -m pytest cli_anything/seaclip/tests/ -v
```
Unit tests run without a live backend (all HTTP/SQLite calls are mocked).
E2E tests require the SeaClip-Lite server running at localhost:5200.
@@ -0,0 +1,3 @@
# cli_anything.seaclip
SeaClip-Lite CLI package. Run with `cli-anything-seaclip` or `python -m cli_anything.seaclip`.
@@ -0,0 +1,3 @@
"""cli-anything-seaclip — CLI harness for SeaClip-Lite."""
__version__ = "1.0.0"
@@ -0,0 +1,3 @@
from .seaclip_cli import main
main()
@@ -0,0 +1,49 @@
"""Activity feed commands for SeaClip CLI."""
import json as json_mod
import click
from ..utils.seaclip_backend import SeaClipBackend
@click.group("activity")
def activity_group():
"""View the activity feed."""
@activity_group.command("list")
@click.option("--limit", default=20, type=int, help="Number of activity items to fetch")
@click.pass_context
def activity_list(ctx, limit):
"""List recent activity."""
backend: SeaClipBackend = ctx.obj["backend"]
skin = ctx.obj.get("skin")
as_json = ctx.obj.get("json", False)
try:
activities = backend.list_activity(limit=limit)
except Exception as e:
if as_json:
click.echo(json_mod.dumps({"error": str(e)}))
elif skin:
skin.error(f"Failed to list activity: {e}")
raise SystemExit(1)
if as_json:
click.echo(json_mod.dumps(activities, indent=2))
return
if skin:
if not activities:
skin.info("No activity found.")
return
headers = ["Time", "Type", "Detail"]
rows = []
for a in activities:
rows.append([
str(a.get("timestamp", ""))[:19],
str(a.get("type", "")),
str(a.get("detail", a.get("message", "")))[:50],
])
skin.table(headers, rows)
@@ -0,0 +1,48 @@
"""Agent commands for SeaClip CLI."""
import json as json_mod
import click
from ..utils.seaclip_backend import SeaClipBackend
@click.group("agent")
def agent_group():
"""Manage pipeline agents."""
@agent_group.command("list")
@click.pass_context
def agent_list(ctx):
"""List all pipeline agents."""
backend: SeaClipBackend = ctx.obj["backend"]
skin = ctx.obj.get("skin")
as_json = ctx.obj.get("json", False)
try:
agents = backend.list_agents()
except Exception as e:
if as_json:
click.echo(json_mod.dumps({"error": str(e)}))
elif skin:
skin.error(f"Failed to list agents: {e}")
raise SystemExit(1)
if as_json:
click.echo(json_mod.dumps(agents, indent=2))
return
if skin:
if not agents:
skin.info("No agents found.")
return
headers = ["Name", "Label", "Status"]
rows = []
for a in agents:
rows.append([
str(a.get("name", "")),
str(a.get("label", "")),
str(a.get("status", "")),
])
skin.table(headers, rows)
@@ -0,0 +1,153 @@
"""Issue commands for SeaClip CLI."""
import json as json_mod
import click
from ..utils.seaclip_backend import SeaClipBackend
@click.group("issue")
def issue_group():
"""Manage SeaClip issues."""
@issue_group.command("list")
@click.option("--status", default=None, help="Filter by status (backlog, todo, in_progress, done)")
@click.option("--priority", default=None, help="Filter by priority (low, medium, high, critical)")
@click.option("--search", default=None, help="Search issues by keyword")
@click.option("--limit", default=None, type=int, help="Limit number of results")
@click.pass_context
def issue_list(ctx, status, priority, search, limit):
"""List issues from the board."""
backend: SeaClipBackend = ctx.obj["backend"]
skin = ctx.obj.get("skin")
as_json = ctx.obj.get("json", False)
try:
issues = backend.list_issues(status=status, priority=priority, search=search, limit=limit)
except Exception as e:
if as_json:
click.echo(json_mod.dumps({"error": str(e)}))
elif skin:
skin.error(f"Failed to list issues: {e}")
raise SystemExit(1)
if as_json:
click.echo(json_mod.dumps(issues, indent=2))
return
if skin:
if not issues:
skin.info("No issues found.")
return
headers = ["ID", "Title", "Status", "Priority"]
rows = []
for i in issues:
rows.append([
str(i.get("id", ""))[:8],
str(i.get("title", ""))[:40],
str(i.get("status", "")),
str(i.get("priority", "")),
])
skin.table(headers, rows)
@issue_group.command("create")
@click.option("--title", required=True, help="Issue title")
@click.option("--description", default="", help="Issue description")
@click.option("--priority", default="medium", help="Priority (low, medium, high, critical)")
@click.pass_context
def issue_create(ctx, title, description, priority):
"""Create a new issue."""
backend: SeaClipBackend = ctx.obj["backend"]
skin = ctx.obj.get("skin")
as_json = ctx.obj.get("json", False)
try:
result = backend.create_issue(title=title, description=description, priority=priority)
except Exception as e:
if as_json:
click.echo(json_mod.dumps({"error": str(e)}))
elif skin:
skin.error(f"Failed to create issue: {e}")
raise SystemExit(1)
if as_json:
click.echo(json_mod.dumps(result, indent=2))
elif skin:
skin.success(f"Issue created: {result.get('id', 'unknown')}")
@issue_group.command("move")
@click.argument("issue_id")
@click.option("--column", required=True, help="Target column")
@click.pass_context
def issue_move(ctx, issue_id, column):
"""Move an issue to a different column."""
backend: SeaClipBackend = ctx.obj["backend"]
skin = ctx.obj.get("skin")
as_json = ctx.obj.get("json", False)
try:
result = backend.move_issue(issue_id, column)
except Exception as e:
if as_json:
click.echo(json_mod.dumps({"error": str(e)}))
elif skin:
skin.error(f"Failed to move issue: {e}")
raise SystemExit(1)
if as_json:
click.echo(json_mod.dumps(result, indent=2))
elif skin:
skin.success(f"Issue {issue_id[:8]} moved to {column}")
@issue_group.command("status")
@click.argument("issue_id")
@click.option("--set", "new_status", required=True, help="New status value")
@click.pass_context
def issue_status(ctx, issue_id, new_status):
"""Update issue status."""
backend: SeaClipBackend = ctx.obj["backend"]
skin = ctx.obj.get("skin")
as_json = ctx.obj.get("json", False)
try:
result = backend.update_issue_status(issue_id, new_status)
except Exception as e:
if as_json:
click.echo(json_mod.dumps({"error": str(e)}))
elif skin:
skin.error(f"Failed to update status: {e}")
raise SystemExit(1)
if as_json:
click.echo(json_mod.dumps(result, indent=2))
elif skin:
skin.success(f"Issue {issue_id[:8]} status set to {new_status}")
@issue_group.command("delete")
@click.argument("issue_id")
@click.pass_context
def issue_delete(ctx, issue_id):
"""Delete an issue."""
backend: SeaClipBackend = ctx.obj["backend"]
skin = ctx.obj.get("skin")
as_json = ctx.obj.get("json", False)
try:
result = backend.delete_issue(issue_id)
except Exception as e:
if as_json:
click.echo(json_mod.dumps({"error": str(e)}))
elif skin:
skin.error(f"Failed to delete issue: {e}")
raise SystemExit(1)
if as_json:
click.echo(json_mod.dumps(result, indent=2))
elif skin:
skin.success(f"Issue {issue_id[:8]} deleted")
@@ -0,0 +1,114 @@
"""Pipeline commands for SeaClip CLI."""
import json as json_mod
import click
from ..utils.seaclip_backend import SeaClipBackend
@click.group("pipeline")
def pipeline_group():
"""Manage the agent pipeline."""
@pipeline_group.command("start")
@click.option("--issue", "issue_id", required=True, help="Issue UUID to start pipeline for")
@click.option("--mode", default="auto", type=click.Choice(["auto", "manual"]), help="Pipeline mode")
@click.pass_context
def pipeline_start(ctx, issue_id, mode):
"""Start the pipeline for an issue."""
backend: SeaClipBackend = ctx.obj["backend"]
skin = ctx.obj.get("skin")
as_json = ctx.obj.get("json", False)
try:
result = backend.start_pipeline(issue_id, mode=mode)
except Exception as e:
if as_json:
click.echo(json_mod.dumps({"error": str(e)}))
elif skin:
skin.error(f"Failed to start pipeline: {e}")
raise SystemExit(1)
if as_json:
click.echo(json_mod.dumps(result, indent=2))
elif skin:
skin.success(f"Pipeline started for {issue_id[:8]} (mode: {mode})")
@pipeline_group.command("status")
@click.option("--issue", "issue_id", required=True, help="Issue UUID")
@click.pass_context
def pipeline_status(ctx, issue_id):
"""Get pipeline status for an issue."""
backend: SeaClipBackend = ctx.obj["backend"]
skin = ctx.obj.get("skin")
as_json = ctx.obj.get("json", False)
try:
result = backend.pipeline_status(issue_id)
except Exception as e:
if as_json:
click.echo(json_mod.dumps({"error": str(e)}))
elif skin:
skin.error(f"Failed to get pipeline status: {e}")
raise SystemExit(1)
if as_json:
click.echo(json_mod.dumps(result, indent=2))
elif skin:
skin.status_block({
"Issue": str(result.get("issue_id", issue_id))[:8],
"Stage": str(result.get("current_stage", "unknown")),
"Status": str(result.get("status", "unknown")),
"Mode": str(result.get("mode", "unknown")),
}, title="Pipeline Status")
@pipeline_group.command("resume")
@click.option("--issue", "issue_id", required=True, help="Issue UUID")
@click.pass_context
def pipeline_resume(ctx, issue_id):
"""Resume a paused pipeline."""
backend: SeaClipBackend = ctx.obj["backend"]
skin = ctx.obj.get("skin")
as_json = ctx.obj.get("json", False)
try:
result = backend.resume_pipeline(issue_id)
except Exception as e:
if as_json:
click.echo(json_mod.dumps({"error": str(e)}))
elif skin:
skin.error(f"Failed to resume pipeline: {e}")
raise SystemExit(1)
if as_json:
click.echo(json_mod.dumps(result, indent=2))
elif skin:
skin.success(f"Pipeline resumed for {issue_id[:8]}")
@pipeline_group.command("stop")
@click.option("--issue", "issue_id", required=True, help="Issue UUID")
@click.pass_context
def pipeline_stop(ctx, issue_id):
"""Stop a running pipeline."""
backend: SeaClipBackend = ctx.obj["backend"]
skin = ctx.obj.get("skin")
as_json = ctx.obj.get("json", False)
try:
result = backend.stop_pipeline(issue_id)
except Exception as e:
if as_json:
click.echo(json_mod.dumps({"error": str(e)}))
elif skin:
skin.error(f"Failed to stop pipeline: {e}")
raise SystemExit(1)
if as_json:
click.echo(json_mod.dumps(result, indent=2))
elif skin:
skin.success(f"Pipeline stopped for {issue_id[:8]}")
@@ -0,0 +1,103 @@
"""Scheduler commands for SeaClip CLI."""
import json as json_mod
import click
from ..utils.seaclip_backend import SeaClipBackend
@click.group("scheduler")
def scheduler_group():
"""Manage schedule configurations."""
@scheduler_group.command("list")
@click.pass_context
def scheduler_list(ctx):
"""List all schedule configurations."""
backend: SeaClipBackend = ctx.obj["backend"]
skin = ctx.obj.get("skin")
as_json = ctx.obj.get("json", False)
try:
schedules = backend.list_schedules()
except Exception as e:
if as_json:
click.echo(json_mod.dumps({"error": str(e)}))
elif skin:
skin.error(f"Failed to list schedules: {e}")
raise SystemExit(1)
if as_json:
click.echo(json_mod.dumps(schedules, indent=2))
return
if skin:
if not schedules:
skin.info("No schedules configured.")
return
headers = ["ID", "Name", "Cron", "Enabled"]
rows = []
for s in schedules:
rows.append([
str(s.get("id", ""))[:8],
str(s.get("name", "")),
str(s.get("cron", "")),
str(s.get("enabled", "")),
])
skin.table(headers, rows)
@scheduler_group.command("add")
@click.option("--name", required=True, help="Schedule name")
@click.option("--cron", required=True, help="Cron expression")
@click.option("--repo", default=None, help="Repository URL")
@click.pass_context
def scheduler_add(ctx, name, cron, repo):
"""Add a new schedule configuration."""
backend: SeaClipBackend = ctx.obj["backend"]
skin = ctx.obj.get("skin")
as_json = ctx.obj.get("json", False)
config = {"name": name, "cron": cron}
if repo:
config["repo"] = repo
try:
result = backend.add_schedule(config)
except Exception as e:
if as_json:
click.echo(json_mod.dumps({"error": str(e)}))
elif skin:
skin.error(f"Failed to add schedule: {e}")
raise SystemExit(1)
if as_json:
click.echo(json_mod.dumps(result, indent=2))
elif skin:
skin.success(f"Schedule '{name}' added")
@scheduler_group.command("sync")
@click.argument("schedule_id")
@click.pass_context
def scheduler_sync(ctx, schedule_id):
"""Trigger a manual sync for a schedule."""
backend: SeaClipBackend = ctx.obj["backend"]
skin = ctx.obj.get("skin")
as_json = ctx.obj.get("json", False)
try:
result = backend.sync_schedule(schedule_id)
except Exception as e:
if as_json:
click.echo(json_mod.dumps({"error": str(e)}))
elif skin:
skin.error(f"Failed to sync schedule: {e}")
raise SystemExit(1)
if as_json:
click.echo(json_mod.dumps(result, indent=2))
elif skin:
skin.success(f"Schedule {schedule_id[:8]} sync triggered")
@@ -0,0 +1,163 @@
"""SeaClip-Lite CLI — Click CLI with REPL mode.
Entry point for the cli-anything-seaclip harness.
Supports both one-shot commands and an interactive REPL.
"""
import json as json_mod
import shlex
import sys
import click
from .core.issues import issue_group
from .core.agents import agent_group
from .core.pipeline import pipeline_group
from .core.scheduler import scheduler_group
from .core.activity import activity_group
from .utils.seaclip_backend import SeaClipBackend
from .utils.repl_skin import ReplSkin
VERSION = "1.0.0"
# ── Server command group ─────────────────────────────────────────────
@click.group("server")
def server_group():
"""Server utilities."""
@server_group.command("health")
@click.pass_context
def server_health(ctx):
"""Check backend health."""
backend: SeaClipBackend = ctx.obj["backend"]
skin = ctx.obj.get("skin")
as_json = ctx.obj.get("json", False)
try:
result = backend.health()
except Exception as e:
if as_json:
click.echo(json_mod.dumps({"error": str(e)}))
elif skin:
skin.error(f"Health check failed: {e}")
raise SystemExit(1)
if as_json:
click.echo(json_mod.dumps(result, indent=2))
elif skin:
skin.success("SeaClip-Lite backend is healthy")
if isinstance(result, dict):
for k, v in result.items():
skin.status(k, str(v))
# ── Main CLI group ───────────────────────────────────────────────────
@click.group(invoke_without_command=True)
@click.option("--json", "as_json", is_flag=True, default=False, help="Output as JSON")
@click.option("--url", default=None, help="SeaClip backend URL (default: http://127.0.0.1:5200)")
@click.version_option(VERSION, prog_name="cli-anything-seaclip")
@click.pass_context
def cli(ctx, as_json, url):
"""SeaClip-Lite CLI — manage issues, pipelines, agents, and schedules."""
ctx.ensure_object(dict)
backend = SeaClipBackend(base_url=url)
skin = ReplSkin("seaclip", version=VERSION)
ctx.obj["backend"] = backend
ctx.obj["skin"] = skin
ctx.obj["json"] = as_json
# If no subcommand given, launch the REPL
if ctx.invoked_subcommand is None:
_run_repl(ctx, backend, skin)
# Register command groups
cli.add_command(issue_group)
cli.add_command(agent_group)
cli.add_command(pipeline_group)
cli.add_command(scheduler_group)
cli.add_command(activity_group)
cli.add_command(server_group)
# ── REPL ─────────────────────────────────────────────────────────────
REPL_COMMANDS = {
"issue list": "List issues (options: --status, --priority, --search, --limit)",
"issue create": "Create issue (--title, --description, --priority)",
"issue move": "Move issue to column (ISSUE_ID --column COL)",
"issue status": "Update issue status (ISSUE_ID --set STATUS)",
"issue delete": "Delete issue (ISSUE_ID)",
"agent list": "List pipeline agents",
"pipeline start": "Start pipeline (--issue UUID --mode auto|manual)",
"pipeline status": "Get pipeline status (--issue UUID)",
"pipeline resume": "Resume paused pipeline (--issue UUID)",
"pipeline stop": "Stop running pipeline (--issue UUID)",
"scheduler list": "List schedules",
"scheduler add": "Add schedule (--name, --cron, --repo)",
"scheduler sync": "Trigger sync (SCHEDULE_ID)",
"activity list": "Recent activity (--limit N)",
"server health": "Check backend health",
"help": "Show this help",
"quit / exit": "Exit the REPL",
}
def _run_repl(ctx, backend: SeaClipBackend, skin: ReplSkin):
"""Interactive REPL loop."""
skin.print_banner()
# Try prompt_toolkit, fall back to plain input
pt_session = skin.create_prompt_session()
while True:
try:
line = skin.get_input(pt_session, context="seaclip")
except (EOFError, KeyboardInterrupt):
skin.print_goodbye()
break
if not line:
continue
cmd = line.strip().lower()
if cmd in ("quit", "exit", "q"):
skin.print_goodbye()
break
if cmd in ("help", "?"):
skin.help(REPL_COMMANDS)
continue
# Parse and dispatch through Click
try:
args = shlex.split(line)
except ValueError as e:
skin.error(f"Parse error: {e}")
continue
try:
cli.main(args=args, standalone_mode=False, obj=ctx.obj)
except SystemExit:
pass
except click.UsageError as e:
skin.error(str(e))
except click.exceptions.MissingParameter as e:
skin.error(str(e))
except Exception as e:
skin.error(f"Error: {e}")
# ── Entry point ──────────────────────────────────────────────────────
def main():
cli(auto_envvar_prefix="SEACLIP")
if __name__ == "__main__":
main()
@@ -0,0 +1,134 @@
---
name: >-
cli-anything-seaclip
description: >-
Command-line interface for SeaClip-Lite - A stateless CLI for managing issues, pipelines, agents, schedules, and activity on the SeaClip-Lite project management board.
---
# cli-anything-seaclip
A stateless command-line interface for SeaClip-Lite project management.
Communicates via HTTP API and direct SQLite reads. No local state or session.
## Installation
```bash
pip install -e .
```
**Prerequisites:**
- Python 3.10+
- SeaClip-Lite backend running at localhost:5200
## Usage
### Basic Commands
```bash
# Show help
cli-anything-seaclip --help
# Start interactive REPL mode
cli-anything-seaclip
# Run with JSON output (for agent consumption)
cli-anything-seaclip --json server health
cli-anything-seaclip --json issue list
cli-anything-seaclip --json agent list
```
### REPL Mode
When invoked without a subcommand, the CLI enters an interactive REPL session:
```bash
cli-anything-seaclip
# Enter commands interactively with tab-completion and history
```
## Command Groups
### Issue
Issue management commands.
| Command | Description |
|---------|-------------|
| `list` | List issues (--status, --priority, --search, --limit) |
| `create` | Create a new issue (--title, --description, --priority) |
| `move` | Move issue to column (ISSUE_ID --column COL) |
| `status` | Update issue status (ISSUE_ID --set STATUS) |
| `delete` | Delete an issue (ISSUE_ID) |
### Agent
Pipeline agent commands.
| Command | Description |
|---------|-------------|
| `list` | List all pipeline agents |
### Pipeline
Pipeline control commands.
| Command | Description |
|---------|-------------|
| `start` | Start pipeline (--issue UUID --mode auto/manual) |
| `status` | Get pipeline status (--issue UUID) |
| `resume` | Resume paused pipeline (--issue UUID) |
| `stop` | Stop running pipeline (--issue UUID) |
### Scheduler
Schedule configuration commands.
| Command | Description |
|---------|-------------|
| `list` | List all schedule configs |
| `add` | Add schedule (--name, --cron, --repo) |
| `sync` | Trigger sync (SCHEDULE_ID) |
### Activity
Activity feed commands.
| Command | Description |
|---------|-------------|
| `list` | Recent activity (--limit N) |
### Server
Server utility commands.
| Command | Description |
|---------|-------------|
| `health` | Check backend health |
## Output Formats
All commands support dual output modes:
- **Human-readable** (default): Tables, colors, formatted text
- **Machine-readable** (`--json` flag): Structured JSON for agent consumption
```bash
# Human output
cli-anything-seaclip issue list
# JSON output for agents
cli-anything-seaclip --json issue list
```
## For AI Agents
When using this CLI programmatically:
1. **Always use `--json` flag** for parseable output
2. **Check return codes** - 0 for success, non-zero for errors
3. **Parse stderr** for error messages on failure
4. **Error responses** include `{"error": "message"}` in JSON mode
## Version
1.0.0
@@ -0,0 +1,77 @@
# SeaClip CLI Harness - Test Documentation
## Test Inventory
| File | Test Classes | Test Count | Focus |
|------|-------------|------------|-------|
| `test_core.py` | 5 | 25 | Unit tests for backend, JSON output, human output, arg parsing, error handling |
| `test_full_e2e.py` | 1 | 10 | E2E subprocess tests against live backend + CLI binary |
| **Total** | **6** | **35** | |
## Unit Tests (`test_core.py`)
All unit tests mock HTTP and SQLite calls. No live backend required.
### TestBackendURLConstruction (4 tests)
- Default base URL is `http://127.0.0.1:5200`
- Custom base URL with trailing slash stripped
- URL helper concatenates path correctly
- `SEACLIP_URL` environment variable overrides default
### TestJSONOutput (6 tests)
- `server health` returns valid JSON with status field
- `issue list` returns JSON array with issue objects
- `issue create` returns JSON with new issue ID
- `agent list` returns JSON array with agent objects
- `scheduler list` returns JSON array
- `activity list --limit 5` returns JSON array
### TestHumanOutput (2 tests)
- `issue list` with results renders without error (table output)
- `issue list` with empty results renders info message
### TestCLIArgParsing (7 tests)
- `issue list` passes --status, --priority, --limit to backend
- `issue move` passes issue_id and --column to backend
- `issue move` without --column fails with non-zero exit
- `pipeline start` passes --issue and --mode to backend
- `pipeline start` with invalid --mode fails
- `activity list` uses default limit of 20
- `scheduler add` passes --name and --cron as config dict
### TestErrorHandling (6 tests)
- Connection error on `server health` produces `{"error": "..."}` and exit 1
- Exception on `issue list` produces JSON error object
- DB lock error on `agent list` produces JSON error with message
- Unknown subcommand exits non-zero
- `--version` flag prints version 1.0.0
- `--help` flag prints CLI description
## End-to-End Tests (`test_full_e2e.py`)
E2E tests shell out via `subprocess` to the installed `cli-anything-seaclip` binary.
Tests requiring the live backend are skipped gracefully if localhost:5200 is unreachable.
### TestCLISubprocess (10 tests)
- `server health` returns valid JSON from live API
- `issue list` returns JSON array from live API
- `issue list --limit 3` accepts limit parameter without error
- `issue list --status backlog` accepts status filter
- `agent list` returns JSON array (SQLite read)
- `scheduler list` returns JSON array (SQLite read)
- `activity list --limit 5` returns JSON array (SQLite read)
- `--version` subprocess prints version string
- `--help` subprocess prints CLI description
- Invalid subcommand exits non-zero
## Test Results
```
============================= test session starts ==============================
platform darwin -- Python 3.14.3, pytest-9.0.2, pluggy-1.6.0
test_core.py 25 passed
test_full_e2e.py 10 passed
============================= 35 passed in 1.17s ==============================
```
@@ -0,0 +1,229 @@
"""Unit tests for SeaClip CLI core modules.
All HTTP and SQLite calls are mocked -- no live backend required.
"""
import json
import os
import sys
import pytest
from unittest.mock import patch, MagicMock
from click.testing import CliRunner
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
from cli_anything.seaclip.seaclip_cli import cli
from cli_anything.seaclip.utils.seaclip_backend import SeaClipBackend
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def invoke(*args):
"""Invoke the CLI with --json and return the CliRunner result."""
runner = CliRunner()
return runner.invoke(cli, list(args), catch_exceptions=False)
def invoke_json(*args):
"""Invoke the CLI with --json flag and parse the output as JSON."""
runner = CliRunner()
result = runner.invoke(cli, ["--json"] + list(args), catch_exceptions=False)
return result, json.loads(result.output) if result.output.strip() else None
# ===========================================================================
# 1. Backend unit tests
# ===========================================================================
class TestBackendURLConstruction:
"""Verify SeaClipBackend builds correct URLs."""
def test_default_base_url(self):
b = SeaClipBackend()
assert b.base_url == "http://127.0.0.1:5200"
def test_custom_base_url(self):
b = SeaClipBackend(base_url="http://myhost:9000/")
assert b.base_url == "http://myhost:9000" # trailing slash stripped
def test_url_helper(self):
b = SeaClipBackend(base_url="http://localhost:5200")
assert b._url("/health") == "http://localhost:5200/health"
def test_env_var_url(self, monkeypatch):
monkeypatch.setenv("SEACLIP_URL", "http://envhost:1234")
b = SeaClipBackend()
assert b.base_url == "http://envhost:1234"
# ===========================================================================
# 2. JSON output format tests
# ===========================================================================
class TestJSONOutput:
"""Verify --json flag produces valid JSON for every command group."""
@patch.object(SeaClipBackend, "health", return_value={"status": "ok", "version": "1.0"})
def test_server_health_json(self, mock_health):
result, data = invoke_json("server", "health")
assert result.exit_code == 0
assert data["status"] == "ok"
@patch.object(SeaClipBackend, "list_issues", return_value=[
{"id": "abc-123", "title": "Bug", "status": "backlog", "priority": "high"}
])
def test_issue_list_json(self, mock_list):
result, data = invoke_json("issue", "list")
assert result.exit_code == 0
assert isinstance(data, list)
assert data[0]["title"] == "Bug"
@patch.object(SeaClipBackend, "create_issue", return_value={"id": "new-uuid", "title": "Task"})
def test_issue_create_json(self, mock_create):
result, data = invoke_json("issue", "create", "--title", "Task")
assert result.exit_code == 0
assert data["id"] == "new-uuid"
@patch.object(SeaClipBackend, "list_agents", return_value=[
{"name": "triage", "role": "triage", "status": "idle"}
])
def test_agent_list_json(self, mock_agents):
result, data = invoke_json("agent", "list")
assert result.exit_code == 0
assert isinstance(data, list)
assert data[0]["name"] == "triage"
@patch.object(SeaClipBackend, "list_schedules", return_value=[
{"id": 1, "repo": "org/repo", "enabled": True}
])
def test_scheduler_list_json(self, mock_sched):
result, data = invoke_json("scheduler", "list")
assert result.exit_code == 0
assert isinstance(data, list)
@patch.object(SeaClipBackend, "list_activity", return_value=[
{"event_type": "issue_created", "summary": "New issue", "created_at": "2026-03-23T10:00:00"}
])
def test_activity_list_json(self, mock_act):
result, data = invoke_json("activity", "list", "--limit", "5")
assert result.exit_code == 0
assert isinstance(data, list)
# ===========================================================================
# 3. Human-readable output tests
# ===========================================================================
class TestHumanOutput:
"""Verify non-JSON output works without crashing."""
@patch.object(SeaClipBackend, "list_issues", return_value=[
{"id": "abc", "title": "Bug", "status": "backlog", "priority": "high"}
])
def test_issue_list_human(self, mock_list):
result = invoke("issue", "list")
assert result.exit_code == 0
@patch.object(SeaClipBackend, "list_issues", return_value=[])
def test_issue_list_empty_human(self, mock_list):
result = invoke("issue", "list")
assert result.exit_code == 0
# ===========================================================================
# 4. CLI argument parsing tests
# ===========================================================================
class TestCLIArgParsing:
"""Verify Click argument/option parsing for each command group."""
@patch.object(SeaClipBackend, "list_issues", return_value=[])
def test_issue_list_with_filters(self, mock_list):
result, _ = invoke_json("issue", "list", "--status", "backlog", "--priority", "high", "--limit", "5")
assert result.exit_code == 0
mock_list.assert_called_once_with(status="backlog", priority="high", search=None, limit=5)
@patch.object(SeaClipBackend, "move_issue", return_value={"ok": True})
def test_issue_move_requires_column(self, mock_move):
result, data = invoke_json("issue", "move", "abc-123", "--column", "done")
assert result.exit_code == 0
mock_move.assert_called_once_with("abc-123", "done")
def test_issue_move_missing_column_fails(self):
runner = CliRunner()
result = runner.invoke(cli, ["--json", "issue", "move", "abc-123"])
assert result.exit_code != 0
@patch.object(SeaClipBackend, "start_pipeline", return_value={"started": True})
def test_pipeline_start_mode(self, mock_start):
result, data = invoke_json("pipeline", "start", "--issue", "uuid-1", "--mode", "manual")
assert result.exit_code == 0
mock_start.assert_called_once_with("uuid-1", mode="manual")
def test_pipeline_start_invalid_mode(self):
runner = CliRunner()
result = runner.invoke(cli, ["--json", "pipeline", "start", "--issue", "x", "--mode", "bogus"])
assert result.exit_code != 0
@patch.object(SeaClipBackend, "list_activity", return_value=[])
def test_activity_default_limit(self, mock_act):
result, _ = invoke_json("activity", "list")
assert result.exit_code == 0
mock_act.assert_called_once_with(limit=20)
@patch.object(SeaClipBackend, "add_schedule", return_value={"id": 1})
def test_scheduler_add_parsing(self, mock_add):
result, data = invoke_json("scheduler", "add", "--name", "nightly", "--cron", "0 2 * * *")
assert result.exit_code == 0
mock_add.assert_called_once_with({"name": "nightly", "cron": "0 2 * * *"})
# ===========================================================================
# 5. Error handling tests
# ===========================================================================
class TestErrorHandling:
"""Verify error paths produce JSON error objects and non-zero exit."""
@patch.object(SeaClipBackend, "health", side_effect=ConnectionError("Connection refused"))
def test_server_health_connection_error(self, mock_health):
runner = CliRunner()
result = runner.invoke(cli, ["--json", "server", "health"])
assert result.exit_code != 0
data = json.loads(result.output)
assert "error" in data
assert "Connection refused" in data["error"]
@patch.object(SeaClipBackend, "list_issues", side_effect=Exception("timeout"))
def test_issue_list_error(self, mock_list):
runner = CliRunner()
result = runner.invoke(cli, ["--json", "issue", "list"])
assert result.exit_code != 0
data = json.loads(result.output)
assert "error" in data
@patch.object(SeaClipBackend, "list_agents", side_effect=Exception("DB locked"))
def test_agent_list_error(self, mock_agents):
runner = CliRunner()
result = runner.invoke(cli, ["--json", "agent", "list"])
assert result.exit_code != 0
data = json.loads(result.output)
assert "DB locked" in data["error"]
def test_unknown_command(self):
runner = CliRunner()
result = runner.invoke(cli, ["--json", "nonexistent"])
assert result.exit_code != 0
def test_version_flag(self):
result = invoke("--version")
assert result.exit_code == 0
assert "1.0.0" in result.output
def test_help_flag(self):
result = invoke("--help")
assert result.exit_code == 0
assert "SeaClip-Lite CLI" in result.output
@@ -0,0 +1,171 @@
"""End-to-end tests for SeaClip CLI.
These tests call the REAL SeaClip-Lite API at localhost:5200.
They require the backend to be running. Skip gracefully if it is not.
"""
import json
import os
import shutil
import subprocess
import sys
from pathlib import Path
import pytest
import requests
SEACLIP_URL = os.environ.get("SEACLIP_URL", "http://127.0.0.1:5200")
def _backend_available():
"""Check whether the SeaClip-Lite backend is reachable."""
try:
r = requests.get(f"{SEACLIP_URL}/health", timeout=3)
return r.status_code == 200
except Exception:
return False
skip_no_backend = pytest.mark.skipif(
not _backend_available(),
reason=f"SeaClip-Lite backend not reachable at {SEACLIP_URL}",
)
# ---------------------------------------------------------------------------
# Resolve the CLI binary
# ---------------------------------------------------------------------------
def _resolve_cli(name):
"""Resolve installed CLI command; fall back to python -m for local dev."""
force = os.environ.get("CLI_ANYTHING_FORCE_INSTALLED", "").strip() == "1"
path = shutil.which(name)
if path:
return [path], None
if force:
raise RuntimeError(f"{name} not found in PATH. Install with: pip install -e .")
module = "cli_anything.seaclip.seaclip_cli"
package_root = Path(__file__).resolve().parents[3]
env = os.environ.copy()
current = env.get("PYTHONPATH", "")
env["PYTHONPATH"] = (
f"{package_root}{os.pathsep}{current}" if current else str(package_root)
)
return [sys.executable, "-m", module], env
# ---------------------------------------------------------------------------
# Subprocess helpers
# ---------------------------------------------------------------------------
class TestCLISubprocess:
"""E2E tests that shell out to the real CLI binary."""
CLI_CMD, CLI_ENV = _resolve_cli("cli-anything-seaclip")
def _run(self, *args, expect_ok=True):
"""Run the CLI and return (returncode, parsed_json_or_None, raw_stdout)."""
cmd = self.CLI_CMD + ["--json"] + list(args)
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=30,
env=self.CLI_ENV,
)
stdout = result.stdout.strip()
data = None
if stdout:
try:
data = json.loads(stdout)
except json.JSONDecodeError:
pass
if expect_ok:
assert result.returncode == 0, (
f"CLI exited {result.returncode}\nstdout: {stdout}\nstderr: {result.stderr}"
)
return result.returncode, data, stdout
# ---- Health ----
@skip_no_backend
def test_server_health(self):
rc, data, _ = self._run("server", "health")
assert rc == 0
assert isinstance(data, dict)
assert "status" in data or "version" in data or data # any valid response
# ---- Issues ----
@skip_no_backend
def test_issue_list(self):
rc, data, _ = self._run("issue", "list")
assert rc == 0
assert isinstance(data, list)
@skip_no_backend
def test_issue_list_with_limit(self):
rc, data, _ = self._run("issue", "list", "--limit", "3")
assert rc == 0
assert isinstance(data, list)
# Note: the API may not enforce the limit server-side;
# we only verify the CLI passes the param without error.
@skip_no_backend
def test_issue_list_with_status_filter(self):
rc, data, _ = self._run("issue", "list", "--status", "backlog")
assert rc == 0
assert isinstance(data, list)
# ---- Agents ----
@skip_no_backend
def test_agent_list(self):
rc, data, _ = self._run("agent", "list")
assert rc == 0
assert isinstance(data, list)
# ---- Scheduler ----
@skip_no_backend
def test_scheduler_list(self):
rc, data, _ = self._run("scheduler", "list")
assert rc == 0
assert isinstance(data, list)
# ---- Activity ----
@skip_no_backend
def test_activity_list(self):
rc, data, _ = self._run("activity", "list", "--limit", "5")
assert rc == 0
assert isinstance(data, list)
assert len(data) <= 5
# ---- Version / Help ----
def test_version_subprocess(self):
cmd = self.CLI_CMD + ["--version"]
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=10, env=self.CLI_ENV,
)
assert result.returncode == 0
assert "1.0.0" in result.stdout
def test_help_subprocess(self):
cmd = self.CLI_CMD + ["--help"]
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=10, env=self.CLI_ENV,
)
assert result.returncode == 0
assert "SeaClip-Lite CLI" in result.stdout
# ---- Error path (no backend needed) ----
def test_invalid_subcommand(self):
cmd = self.CLI_CMD + ["--json", "nonexistent"]
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=10, env=self.CLI_ENV,
)
assert result.returncode != 0
@@ -0,0 +1,500 @@
"""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("ollama", version="1.0.0")
skin.print_banner()
prompt_text = skin.prompt(project_name="llama3.2", modified=False)
skin.success("Model pulled")
skin.error("Connection failed")
skin.warning("No models loaded")
skin.info("Generating...")
skin.status("Model", "llama3.2:latest")
skin.table(headers, rows)
skin.print_goodbye()
"""
import os
import sys
# ── ANSI color codes (no external deps for core styling) ──────────────
_RESET = "\033[0m"
_BOLD = "\033[1m"
_DIM = "\033[2m"
_ITALIC = "\033[3m"
_UNDERLINE = "\033[4m"
# Brand colors
_CYAN = "\033[38;5;80m" # cli-anything brand cyan
_CYAN_BG = "\033[48;5;80m"
_WHITE = "\033[97m"
_GRAY = "\033[38;5;245m"
_DARK_GRAY = "\033[38;5;240m"
_LIGHT_GRAY = "\033[38;5;250m"
# Software accent colors — each software gets a unique accent
_ACCENT_COLORS = {
"gimp": "\033[38;5;214m", # warm orange
"blender": "\033[38;5;208m", # deep orange
"inkscape": "\033[38;5;39m", # bright blue
"audacity": "\033[38;5;33m", # navy blue
"libreoffice": "\033[38;5;40m", # green
"obs_studio": "\033[38;5;55m", # purple
"kdenlive": "\033[38;5;69m", # slate blue
"shotcut": "\033[38;5;35m", # teal green
"ollama": "\033[38;5;255m", # white (Ollama branding)
}
_DEFAULT_ACCENT = "\033[38;5;75m" # default sky blue
# Status colors
_GREEN = "\033[38;5;78m"
_YELLOW = "\033[38;5;220m"
_RED = "\033[38;5;196m"
_BLUE = "\033[38;5;75m"
_MAGENTA = "\033[38;5;176m"
# ── Brand icon ────────────────────────────────────────────────────────
# The cli-anything icon: a small colored diamond/chevron mark
_ICON = f"{_CYAN}{_BOLD}{_RESET}"
_ICON_SMALL = f"{_CYAN}{_RESET}"
# ── Box drawing characters ────────────────────────────────────────────
_H_LINE = ""
_V_LINE = ""
_TL = ""
_TR = ""
_BL = ""
_BR = ""
_T_DOWN = ""
_T_UP = ""
_T_RIGHT = ""
_T_LEFT = ""
_CROSS = ""
def _strip_ansi(text: str) -> str:
"""Remove ANSI escape codes for length calculation."""
import re
return re.sub(r"\033\[[^m]*m", "", text)
def _visible_len(text: str) -> int:
"""Get visible length of text (excluding ANSI codes)."""
return len(_strip_ansi(text))
class ReplSkin:
"""Unified REPL skin for cli-anything CLIs.
Provides consistent branding, prompts, and message formatting
across all CLI harnesses built with the cli-anything methodology.
"""
def __init__(self, software: str, version: str = "1.0.0",
history_file: str | None = None):
"""Initialize the REPL skin.
Args:
software: Software name (e.g., "gimp", "shotcut", "ollama").
version: CLI version string.
history_file: Path for persistent command history.
Defaults to ~/.cli-anything-<software>/history
"""
self.software = software.lower().replace("-", "_")
self.display_name = software.replace("_", " ").title()
self.version = version
self.accent = _ACCENT_COLORS.get(self.software, _DEFAULT_ACCENT)
# History file
if history_file is None:
from pathlib import Path
hist_dir = Path.home() / f".cli-anything-{self.software}"
hist_dir.mkdir(parents=True, exist_ok=True)
self.history_file = str(hist_dir / "history")
else:
self.history_file = history_file
# Detect terminal capabilities
self._color = self._detect_color_support()
def _detect_color_support(self) -> bool:
"""Check if terminal supports color."""
if os.environ.get("NO_COLOR"):
return False
if os.environ.get("CLI_ANYTHING_NO_COLOR"):
return False
if not hasattr(sys.stdout, "isatty"):
return False
return sys.stdout.isatty()
def _c(self, code: str, text: str) -> str:
"""Apply color code if colors are supported."""
if not self._color:
return text
return f"{code}{text}{_RESET}"
# ── Banner ────────────────────────────────────────────────────────
def print_banner(self):
"""Print the startup banner with branding."""
inner = 54
def _box_line(content: str) -> str:
"""Wrap content in box drawing, padding to inner width."""
pad = inner - _visible_len(content)
vl = self._c(_DARK_GRAY, _V_LINE)
return f"{vl}{content}{' ' * max(0, pad)}{vl}"
top = self._c(_DARK_GRAY, f"{_TL}{_H_LINE * inner}{_TR}")
bot = self._c(_DARK_GRAY, f"{_BL}{_H_LINE * inner}{_BR}")
# Title: ◆ cli-anything · Ollama
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 = ""
print(top)
print(_box_line(title))
print(_box_line(ver))
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
"\033[38;5;255m": "#eeeeee", # ollama white
}
@@ -0,0 +1,148 @@
"""HTTP client for SeaClip-Lite FastAPI backend.
Some endpoints return HTMX partials, not JSON. For those we fall back
to direct SQLite reads from the SeaClip-Lite database.
"""
import os
import sqlite3
import requests
from typing import Any
DEFAULT_BASE_URL = "http://127.0.0.1:5200"
class SeaClipBackend:
"""Thin HTTP client wrapping the SeaClip-Lite FastAPI endpoints."""
def __init__(self, base_url: str | None = None, db_path: str | None = None, timeout: int = 30):
self.base_url = (
base_url
or os.environ.get("SEACLIP_URL")
or DEFAULT_BASE_URL
).rstrip("/")
self.db_path = db_path or os.environ.get("SEACLIP_DB")
self.timeout = timeout
self.session = requests.Session()
# ── helpers ───────────────────────────────────────────────────────
def _url(self, path: str) -> str:
return f"{self.base_url}{path}"
def _get(self, path: str, params: dict | None = None) -> Any:
r = self.session.get(self._url(path), params=params, timeout=self.timeout)
r.raise_for_status()
return r.json()
def _post(self, path: str, json: dict | None = None) -> Any:
r = self.session.post(self._url(path), json=json, timeout=self.timeout)
r.raise_for_status()
return r.json()
def _delete(self, path: str) -> Any:
r = self.session.delete(self._url(path), timeout=self.timeout)
r.raise_for_status()
return r.json()
# ── health ────────────────────────────────────────────────────────
def health(self) -> dict:
return self._get("/health")
# ── issues ────────────────────────────────────────────────────────
def list_issues(
self,
status: str | None = None,
priority: str | None = None,
search: str | None = None,
limit: int | None = None,
) -> list[dict]:
params: dict[str, Any] = {}
if status:
params["status"] = status
if priority:
params["priority"] = priority
if search:
params["search"] = search
if limit:
params["limit"] = limit
return self._get("/api/issues", params=params)
def create_issue(
self, title: str, description: str = "", priority: str = "medium"
) -> dict:
return self._post(
"/api/issues",
json={"title": title, "description": description, "priority": priority},
)
def move_issue(self, issue_id: str, column: str) -> dict:
return self._post(f"/api/issues/{issue_id}/move", json={"column": column})
def update_issue_status(self, issue_id: str, status: str) -> dict:
return self._post(f"/api/issues/{issue_id}/status", json={"status": status})
def delete_issue(self, issue_id: str) -> dict:
return self._delete(f"/api/issues/{issue_id}")
# ── helpers (SQLite) ─────────────────────────────────────────────
def _query_db(self, sql: str, params: tuple = ()) -> list[dict]:
"""Run a read-only query against SeaClip-Lite's SQLite database."""
if not self.db_path:
raise RuntimeError(
"SEACLIP_DB environment variable is required for direct database queries. "
"Set it to the path of your SeaClip-Lite seaclip.db file."
)
conn = sqlite3.connect(f"file:{self.db_path}?mode=ro", uri=True)
conn.row_factory = sqlite3.Row
try:
rows = conn.execute(sql, params).fetchall()
return [dict(r) for r in rows]
finally:
conn.close()
# ── agents ────────────────────────────────────────────────────────
def list_agents(self) -> list[dict]:
return self._query_db(
"SELECT name, role, status, current_issue_id, last_completed_at, last_error, updated_at FROM agents ORDER BY created_at"
)
# ── pipeline ──────────────────────────────────────────────────────
def start_pipeline(self, issue_id: str, mode: str = "auto") -> dict:
return self._post(f"/api/pipeline/{issue_id}/start", json={"mode": mode})
def pipeline_status(self, issue_id: str) -> dict:
return self._get(f"/api/pipeline/{issue_id}/status")
def resume_pipeline(self, issue_id: str) -> dict:
return self._post(f"/api/pipeline/{issue_id}/resume")
def stop_pipeline(self, issue_id: str) -> dict:
return self._post(f"/api/pipeline/{issue_id}/stop")
# ── scheduler ─────────────────────────────────────────────────────
def list_schedules(self) -> list[dict]:
return self._query_db(
"SELECT id, repo, enabled, interval_minutes, target_column, auto_pipeline, pipeline_mode, ai_mode, last_synced_at, issues_synced FROM schedule_configs ORDER BY id"
)
def add_schedule(self, config: dict) -> dict:
return self._post("/api/scheduler/add", json=config)
def sync_schedule(self, schedule_id: str) -> dict:
return self._post(f"/api/scheduler/{schedule_id}/sync")
# ── activity ──────────────────────────────────────────────────────
def list_activity(self, limit: int | None = None) -> list[dict]:
lim = limit or 20
return self._query_db(
"SELECT event_type, summary, created_at FROM activity_log ORDER BY created_at DESC LIMIT ?",
(lim,),
)
+45
View File
@@ -0,0 +1,45 @@
"""Setup for cli-anything-seaclip — CLI harness for SeaClip-Lite."""
from setuptools import setup, find_namespace_packages
setup(
name="cli-anything-seaclip",
version="1.0.0",
author="cli-anything contributors",
author_email="",
description="CLI-Anything harness for SeaClip-Lite project management",
url="https://github.com/HKUDS/CLI-Anything",
packages=find_namespace_packages(include=["cli_anything.*"]),
classifiers=[
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"Topic :: Software Development :: Libraries :: Python Modules",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
],
python_requires=">=3.10",
install_requires=[
"click>=8.0.0",
"prompt-toolkit>=3.0.0",
"requests>=2.28.0",
],
extras_require={
"dev": [
"pytest>=7.0.0",
"pytest-cov>=4.0.0",
],
},
entry_points={
"console_scripts": [
"cli-anything-seaclip=cli_anything.seaclip.seaclip_cli:main",
],
},
package_data={
"cli_anything.seaclip": ["skills/*.md"],
},
include_package_data=True,
zip_safe=False,
)