mirror of
https://github.com/HKUDS/CLI-Anything.git
synced 2026-08-28 23:27:04 +08:00
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:
+16
@@ -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/
|
||||
|
||||
@@ -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
@@ -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 测试。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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",
|
||||
],
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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,),
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user