feat: Add SeaClip, PM2, and ChromaDB CLI harnesses

Three new agent-native CLI harnesses for the CLI-Anything ecosystem:

**cli-anything-seaclip** — Kanban board + 6-agent AI pipeline management
- Wraps SeaClip-Lite FastAPI (HTTP) + SQLite (direct reads)
- Commands: issue (list/create/move/delete), agent (list), pipeline (start/stop/resume/status), scheduler (list/sync), activity (list), server (health)
- 35 tests (25 unit + 10 E2E)

**cli-anything-pm2** — Node.js process management
- Wraps PM2 CLI via subprocess
- Commands: process (list/describe/metrics), lifecycle (start/stop/restart/delete), logs (view/flush), system (save/version)
- 37 tests (28 unit + 9 E2E)

**cli-anything-chromadb** — Vector database operations
- Wraps ChromaDB HTTP API v2
- Commands: collection (list/create/delete/info), document (add/get/delete/count), query (search), server (heartbeat/version)
- 51 tests (38 unit + 14 E2E, 1 skip)

All harnesses follow the standard CLI-Anything patterns:
- Dual output mode (human-readable + --json)
- Interactive REPL with unified repl_skin
- Full SOP documentation (<SOFTWARE>.md)
- SKILL.md with YAML frontmatter
- Comprehensive test suites (test_core.py + test_full_e2e.py)
- TEST.md with test plan and results

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
t4tarzan
2026-03-23 01:48:22 +05:30
parent 175d3b46a9
commit aa71e17094
63 changed files with 6632 additions and 3 deletions
+12
View File
@@ -47,6 +47,9 @@
!/novita/
!/ollama/
!/browser/
!/seaclip/
!/pm2/
!/chromadb/
# Step 5: Inside each software dir, ignore everything (including dotfiles)
/gimp/*
@@ -81,6 +84,12 @@
/ollama/.*
/browser/*
/browser/.*
/seaclip/*
/seaclip/.*
/pm2/*
/pm2/.*
/chromadb/*
/chromadb/.*
# Step 6: ...except agent-harness/
!/gimp/agent-harness/
@@ -100,6 +109,9 @@
!/novita/agent-harness/
!/ollama/agent-harness/
!/browser/agent-harness/
!/seaclip/agent-harness/
!/pm2/agent-harness/
!/chromadb/agent-harness/
# Step 7: Ignore build artifacts within allowed dirs
**/__pycache__/
+148
View File
@@ -0,0 +1,148 @@
# Agent Harness: ChromaDB Vector Database CLI
## Purpose
This harness provides a standard operating procedure (SOP) and toolkit for coding
agents (Claude Code, Codex, etc.) to interact with ChromaDB vector databases via
a unified CLI interface. The goal: let AI agents query, manage, and populate vector
knowledge bases without needing a browser UI or Python SDK boilerplate.
## Backend Description
**ChromaDB HTTP API v2** running at `http://localhost:8000` (configurable via `--host`).
- Tenant: `default_tenant`
- Database: `default_database`
- Protocol: REST/JSON over HTTP
- Client: Python `requests` library wrapping all API endpoints
ChromaDB is a stateless vector database -- no session or connection state is maintained
between CLI invocations. Each command makes independent HTTP calls.
## Architecture
```
agent-harness/
├── setup.py # Package setup with click, prompt-toolkit, requests
├── CHROMADB.md # This file -- SOP and architecture
└── cli_anything/
└── chromadb/
├── __init__.py
├── __main__.py # python -m entry point
├── README.md # Usage docs
├── chromadb_cli.py # Click CLI + REPL dispatcher
├── core/
│ ├── __init__.py
│ ├── collections.py # list, create, delete, info
│ ├── documents.py # add, get, delete, count
│ ├── query.py # semantic search
│ └── server.py # heartbeat, version
├── utils/
│ ├── __init__.py
│ ├── chromadb_backend.py # HTTP client for ChromaDB v2 API
│ └── repl_skin.py # Unified REPL skin
├── skills/
│ └── SKILL.md
└── tests/
├── __init__.py
├── TEST.md # Test plan and results
├── test_core.py # Unit tests (mocked HTTP)
└── test_full_e2e.py # E2E tests (real ChromaDB)
```
## Command Groups
### server
Server health and metadata commands.
| Command | Description |
|---------|-------------|
| `server heartbeat` | Check ChromaDB server health (returns nanosecond timestamp) |
| `server version` | Get ChromaDB server version string |
### collection
Collection CRUD operations.
| Command | Description |
|---------|-------------|
| `collection list` | List all collections with IDs and metadata |
| `collection create --name NAME` | Create a new collection |
| `collection delete --name NAME` | Delete a collection by name |
| `collection info NAME` | Get detailed info about a collection |
### document
Document management within collections.
| Command | Description |
|---------|-------------|
| `document add --collection C --id ID --document TEXT` | Add document(s) to a collection |
| `document get --collection C` | Get documents (with optional --id, --limit, --offset) |
| `document delete --collection C --id ID` | Delete document(s) by ID |
| `document count --collection C` | Count documents in a collection |
### query
Semantic search against collections.
| Command | Description |
|---------|-------------|
| `query search --collection C --text T` | Semantic search with optional --n-results |
## State Model
**Stateless.** Every CLI invocation creates a fresh HTTP session and queries the
ChromaDB API directly. There is no local state file, no session persistence, and
no undo/redo. The ChromaDB server itself is the single source of truth.
This means:
- Commands are idempotent where the underlying API supports it
- No risk of stale local state
- Every command can be run independently in any order
- REPL mode maintains only the backend URL and output format preference in memory
## API Endpoints Used
All under ChromaDB v2 API (`/api/v2/`):
| Endpoint | Method | Command |
|----------|--------|---------|
| `/api/v2/heartbeat` | GET | `server heartbeat` |
| `/api/v2/version` | GET | `server version` |
| `/api/v2/tenants/{t}/databases/{d}/collections` | GET | `collection list` |
| `/api/v2/tenants/{t}/databases/{d}/collections` | POST | `collection create` |
| `/api/v2/tenants/{t}/databases/{d}/collections/{name}` | GET | `collection info` |
| `/api/v2/tenants/{t}/databases/{d}/collections/{name}` | DELETE | `collection delete` |
| `/api/v2/tenants/{t}/databases/{d}/collections/{id}/add` | POST | `document add` |
| `/api/v2/tenants/{t}/databases/{d}/collections/{id}/get` | POST | `document get` |
| `/api/v2/tenants/{t}/databases/{d}/collections/{id}/delete` | POST | `document delete` |
| `/api/v2/tenants/{t}/databases/{d}/collections/{id}/count` | POST | `document count` |
| `/api/v2/tenants/{t}/databases/{d}/collections/{id}/query` | POST | `query search` |
## Output Formats
All commands support dual output modes controlled by the `--json` flag:
- **Human-readable** (default): Colored tables, status lines, and formatted text via ReplSkin
- **Machine-readable** (`--json`): Structured JSON on stdout for agent consumption
## Current Collections (Hub)
- **hub_knowledge** -- Main knowledge base with 230+ indexed documents
- **team** -- Team information collection
## Testing Strategy
Two test suites with complementary purposes:
1. **Unit tests** (`test_core.py`): Mocked HTTP calls via `unittest.mock`. Tests CLI
argument parsing, output formatting, URL construction, and error handling. Fast,
deterministic, no external dependencies.
2. **E2E tests** (`test_full_e2e.py`): Real subprocess calls to the installed CLI binary
against a live ChromaDB at localhost:8000. Tests actual JSON output parsing, exit codes,
and end-to-end command execution.
## Key Principles
- **Stateless by design** -- No local state to get out of sync with the server
- **JSON output for agents** -- Every command supports `--json` for machine parsing
- **Fail loudly** -- Connection errors and bad responses produce clear error messages
- **Leverage the HTTP API directly** -- No ChromaDB Python SDK dependency, just `requests`
@@ -0,0 +1,52 @@
# CLI-Anything ChromaDB
CLI harness for ChromaDB vector database using the cli-anything methodology.
## Installation
```bash
cd agent-harness
pip install -e .
```
## Usage
### Interactive REPL (default)
```bash
cli-anything-chromadb
```
### Direct commands
```bash
# Server
cli-anything-chromadb server heartbeat
cli-anything-chromadb server version
# Collections
cli-anything-chromadb collection list
cli-anything-chromadb collection info hub_knowledge
cli-anything-chromadb collection create --name test_collection
cli-anything-chromadb collection delete --name test_collection
# Documents
cli-anything-chromadb document count --collection hub_knowledge
cli-anything-chromadb document get --collection hub_knowledge --limit 5
cli-anything-chromadb document add --collection hub_knowledge --id doc1 --document "Hello world"
cli-anything-chromadb document delete --collection hub_knowledge --id doc1
# Semantic search
cli-anything-chromadb query search --collection hub_knowledge --text "how does the pipeline work" --n-results 3
```
### JSON output
Add `--json` before any subcommand:
```bash
cli-anything-chromadb --json collection list
cli-anything-chromadb --json query search --collection hub_knowledge --text "pipeline" --n-results 3
```
## Configuration
- Default server: `http://localhost:8000`
- Override with `--host`: `cli-anything-chromadb --host http://other:8000 server heartbeat`
- Tenant: `default_tenant`, Database: `default_database`
@@ -0,0 +1 @@
"""cli-anything ChromaDB harness."""
@@ -0,0 +1,4 @@
"""Allow running as python -m cli_anything.chromadb."""
from .chromadb_cli import main
main()
@@ -0,0 +1,111 @@
"""ChromaDB CLI-Anything harness -- Click CLI + REPL."""
import json as json_mod
import shlex
import sys
import click
from .utils.chromadb_backend import ChromaDBBackend
from .utils.repl_skin import ReplSkin
from .core.server import server_group
from .core.collections import collection_group
from .core.documents import document_group
from .core.query import query_group
@click.group(invoke_without_command=True)
@click.option("--json", "use_json", is_flag=True, default=False,
help="Output in JSON format")
@click.option("--host", default="http://localhost:8000",
help="ChromaDB server URL (default: http://localhost:8000)")
@click.pass_context
def cli(ctx, use_json, host):
"""CLI-Anything harness for ChromaDB vector database."""
ctx.ensure_object(dict)
ctx.obj["json"] = use_json
ctx.obj["backend"] = ChromaDBBackend(base_url=host)
ctx.obj["skin"] = ReplSkin("chromadb", version="1.0.0")
if ctx.invoked_subcommand is None:
_run_repl(ctx)
cli.add_command(server_group)
cli.add_command(collection_group)
cli.add_command(document_group)
cli.add_command(query_group)
# ── REPL Commands Map (for help display) ─────────────────────────────
_REPL_COMMANDS = {
"server heartbeat": "Check server health",
"server version": "Get server version",
"collection list": "List all collections",
"collection create --name NAME": "Create a new collection",
"collection delete --name NAME": "Delete a collection",
"collection info NAME": "Get collection info",
"document add --collection C ...": "Add document(s)",
"document get --collection C": "Get documents",
"document delete --collection C": "Delete document(s)",
"document count --collection C": "Count documents",
"query search --collection C --text T": "Semantic search",
"help": "Show this help",
"quit / exit": "Exit the REPL",
}
def _run_repl(ctx):
"""Launch the interactive REPL."""
skin: ReplSkin = ctx.obj["skin"]
skin.print_banner()
session = skin.create_prompt_session()
while True:
try:
user_input = skin.get_input(session, context="chromadb")
except (EOFError, KeyboardInterrupt):
skin.print_goodbye()
break
if not user_input:
continue
cmd = user_input.strip().lower()
if cmd in ("quit", "exit", "q"):
skin.print_goodbye()
break
if cmd in ("help", "h", "?"):
skin.help(_REPL_COMMANDS)
continue
# Parse the input and dispatch through Click
try:
args = shlex.split(user_input)
except ValueError as e:
skin.error(f"Parse error: {e}")
continue
try:
# Create a fresh context for each REPL command
cli.main(args=args, obj=ctx.obj, standalone_mode=False)
except SystemExit:
# Click raises SystemExit on errors; swallow in REPL
pass
except click.exceptions.UsageError as e:
skin.error(str(e))
except Exception as e:
skin.error(f"Error: {e}")
def main():
"""Entry point."""
cli(auto_envvar_prefix="CHROMADB_CLI")
if __name__ == "__main__":
main()
@@ -0,0 +1,117 @@
"""Collection commands -- list, create, delete, info."""
import json as json_mod
import click
from ..utils.chromadb_backend import ChromaDBBackend
@click.group("collection")
@click.pass_context
def collection_group(ctx):
"""Manage ChromaDB collections."""
pass
@collection_group.command("list")
@click.pass_context
def list_collections(ctx):
"""List all collections."""
backend: ChromaDBBackend = ctx.obj["backend"]
use_json = ctx.obj.get("json", False)
skin = ctx.obj.get("skin")
try:
collections = backend.list_collections()
if use_json:
click.echo(json_mod.dumps(collections, indent=2))
else:
if not collections:
skin.info("No collections found.")
return
headers = ["Name", "ID", "Metadata"]
rows = []
for c in collections:
name = c.get("name", "?")
cid = c.get("id", "?")
meta = json_mod.dumps(c.get("metadata") or {})
rows.append([name, cid, meta])
skin.table(headers, rows)
except Exception as e:
if use_json:
click.echo(json_mod.dumps({"error": str(e)}, indent=2))
else:
skin.error(f"Failed to list collections: {e}")
raise SystemExit(1)
@collection_group.command("create")
@click.option("--name", required=True, help="Collection name")
@click.option("--metadata", default=None, help="Metadata as JSON string")
@click.pass_context
def create_collection(ctx, name, metadata):
"""Create a new collection."""
backend: ChromaDBBackend = ctx.obj["backend"]
use_json = ctx.obj.get("json", False)
skin = ctx.obj.get("skin")
try:
meta = json_mod.loads(metadata) if metadata else None
result = backend.create_collection(name, metadata=meta)
if use_json:
click.echo(json_mod.dumps(result, indent=2))
else:
skin.success(f"Collection '{name}' created")
skin.status("ID", result.get("id", "?"))
except Exception as e:
if use_json:
click.echo(json_mod.dumps({"error": str(e)}, indent=2))
else:
skin.error(f"Failed to create collection: {e}")
raise SystemExit(1)
@collection_group.command("delete")
@click.option("--name", required=True, help="Collection name to delete")
@click.pass_context
def delete_collection(ctx, name):
"""Delete a collection."""
backend: ChromaDBBackend = ctx.obj["backend"]
use_json = ctx.obj.get("json", False)
skin = ctx.obj.get("skin")
try:
backend.delete_collection(name)
if use_json:
click.echo(json_mod.dumps({"status": "deleted", "name": name}, indent=2))
else:
skin.success(f"Collection '{name}' deleted")
except Exception as e:
if use_json:
click.echo(json_mod.dumps({"error": str(e)}, indent=2))
else:
skin.error(f"Failed to delete collection: {e}")
raise SystemExit(1)
@collection_group.command("info")
@click.argument("name")
@click.pass_context
def collection_info(ctx, name):
"""Get info about a collection."""
backend: ChromaDBBackend = ctx.obj["backend"]
use_json = ctx.obj.get("json", False)
skin = ctx.obj.get("skin")
try:
info = backend.get_collection(name)
if use_json:
click.echo(json_mod.dumps(info, indent=2))
else:
skin.section(f"Collection: {name}")
skin.status("ID", info.get("id", "?"))
skin.status("Name", info.get("name", "?"))
meta = info.get("metadata") or {}
skin.status("Metadata", json_mod.dumps(meta))
except Exception as e:
if use_json:
click.echo(json_mod.dumps({"error": str(e)}, indent=2))
else:
skin.error(f"Failed to get collection info: {e}")
raise SystemExit(1)
@@ -0,0 +1,146 @@
"""Document commands -- add, get, delete, count."""
import json as json_mod
import click
from ..utils.chromadb_backend import ChromaDBBackend
def _resolve_collection_id(backend: ChromaDBBackend, collection_name: str) -> str:
"""Resolve a collection name to its ID."""
info = backend.get_collection(collection_name)
return info["id"]
@click.group("document")
@click.pass_context
def document_group(ctx):
"""Manage documents in ChromaDB collections."""
pass
@document_group.command("add")
@click.option("--collection", required=True, help="Collection name")
@click.option("--id", "doc_ids", multiple=True, required=True, help="Document ID (repeatable)")
@click.option("--document", "documents", multiple=True, required=True, help="Document text (repeatable)")
@click.option("--metadata", "metadatas", multiple=True, default=None, help="Metadata JSON string (repeatable)")
@click.pass_context
def add_documents(ctx, collection, doc_ids, documents, metadatas):
"""Add documents to a collection."""
backend: ChromaDBBackend = ctx.obj["backend"]
use_json = ctx.obj.get("json", False)
skin = ctx.obj.get("skin")
try:
collection_id = _resolve_collection_id(backend, collection)
parsed_meta = None
if metadatas:
parsed_meta = [json_mod.loads(m) for m in metadatas]
result = backend.add_documents(
collection_id=collection_id,
ids=list(doc_ids),
documents=list(documents),
metadatas=parsed_meta,
)
if use_json:
click.echo(json_mod.dumps(result, indent=2))
else:
skin.success(f"Added {len(doc_ids)} document(s) to '{collection}'")
except Exception as e:
if use_json:
click.echo(json_mod.dumps({"error": str(e)}, indent=2))
else:
skin.error(f"Failed to add documents: {e}")
raise SystemExit(1)
@document_group.command("get")
@click.option("--collection", required=True, help="Collection name")
@click.option("--id", "doc_ids", multiple=True, default=None, help="Document ID (repeatable)")
@click.option("--limit", default=None, type=int, help="Max documents to return")
@click.option("--offset", default=None, type=int, help="Offset for pagination")
@click.pass_context
def get_documents(ctx, collection, doc_ids, limit, offset):
"""Get documents from a collection."""
backend: ChromaDBBackend = ctx.obj["backend"]
use_json = ctx.obj.get("json", False)
skin = ctx.obj.get("skin")
try:
collection_id = _resolve_collection_id(backend, collection)
ids_list = list(doc_ids) if doc_ids else None
result = backend.get_documents(
collection_id=collection_id,
ids=ids_list,
limit=limit,
offset=offset,
)
if use_json:
click.echo(json_mod.dumps(result, indent=2))
else:
ids = result.get("ids", [])
docs = result.get("documents", [])
metas = result.get("metadatas", [])
if not ids:
skin.info("No documents found.")
return
headers = ["ID", "Document (preview)", "Metadata"]
rows = []
for i, doc_id in enumerate(ids):
doc_text = (docs[i][:80] + "...") if docs[i] and len(docs[i]) > 80 else (docs[i] or "")
meta = json_mod.dumps(metas[i]) if metas and i < len(metas) else "{}"
rows.append([doc_id, doc_text, meta])
skin.table(headers, rows)
skin.info(f"Showing {len(ids)} document(s)")
except Exception as e:
if use_json:
click.echo(json_mod.dumps({"error": str(e)}, indent=2))
else:
skin.error(f"Failed to get documents: {e}")
raise SystemExit(1)
@document_group.command("delete")
@click.option("--collection", required=True, help="Collection name")
@click.option("--id", "doc_ids", multiple=True, required=True, help="Document ID to delete (repeatable)")
@click.pass_context
def delete_documents(ctx, collection, doc_ids):
"""Delete documents from a collection."""
backend: ChromaDBBackend = ctx.obj["backend"]
use_json = ctx.obj.get("json", False)
skin = ctx.obj.get("skin")
try:
collection_id = _resolve_collection_id(backend, collection)
result = backend.delete_documents(collection_id, list(doc_ids))
if use_json:
click.echo(json_mod.dumps(result, indent=2))
else:
skin.success(f"Deleted {len(doc_ids)} document(s) from '{collection}'")
except Exception as e:
if use_json:
click.echo(json_mod.dumps({"error": str(e)}, indent=2))
else:
skin.error(f"Failed to delete documents: {e}")
raise SystemExit(1)
@document_group.command("count")
@click.option("--collection", required=True, help="Collection name")
@click.pass_context
def count_documents(ctx, collection):
"""Count documents in a collection."""
backend: ChromaDBBackend = ctx.obj["backend"]
use_json = ctx.obj.get("json", False)
skin = ctx.obj.get("skin")
try:
collection_id = _resolve_collection_id(backend, collection)
count = backend.count_documents(collection_id)
if use_json:
click.echo(json_mod.dumps({"collection": collection, "count": count}, indent=2))
else:
skin.status("Collection", collection)
skin.status("Documents", str(count))
except Exception as e:
if use_json:
click.echo(json_mod.dumps({"error": str(e)}, indent=2))
else:
skin.error(f"Failed to count documents: {e}")
raise SystemExit(1)
@@ -0,0 +1,71 @@
"""Query commands -- semantic search."""
import json as json_mod
import click
from ..utils.chromadb_backend import ChromaDBBackend
def _resolve_collection_id(backend: ChromaDBBackend, collection_name: str) -> str:
"""Resolve a collection name to its ID."""
info = backend.get_collection(collection_name)
return info["id"]
@click.group("query")
@click.pass_context
def query_group(ctx):
"""Semantic search against ChromaDB collections."""
pass
@query_group.command("search")
@click.option("--collection", required=True, help="Collection name")
@click.option("--text", required=True, help="Search query text")
@click.option("--n-results", default=5, type=int, help="Number of results (default: 5)")
@click.pass_context
def search(ctx, collection, text, n_results):
"""Perform semantic search on a collection."""
backend: ChromaDBBackend = ctx.obj["backend"]
use_json = ctx.obj.get("json", False)
skin = ctx.obj.get("skin")
try:
collection_id = _resolve_collection_id(backend, collection)
result = backend.query(
collection_id=collection_id,
query_texts=[text],
n_results=n_results,
)
if use_json:
click.echo(json_mod.dumps(result, indent=2))
else:
skin.section(f"Search: \"{text}\" in {collection}")
ids_list = result.get("ids", [[]])[0]
docs_list = result.get("documents", [[]])[0]
dists_list = result.get("distances", [[]])[0]
metas_list = result.get("metadatas", [[]])[0]
if not ids_list:
skin.info("No results found.")
return
for i, doc_id in enumerate(ids_list):
distance = dists_list[i] if i < len(dists_list) else "?"
doc = docs_list[i] if i < len(docs_list) else ""
meta = metas_list[i] if i < len(metas_list) else {}
skin.info(f"[{i+1}] {doc_id} (distance: {distance})")
if meta:
skin.hint(f" metadata: {json_mod.dumps(meta)}")
preview = (doc[:120] + "...") if doc and len(doc) > 120 else (doc or "")
if preview:
skin.hint(f" {preview}")
print()
skin.info(f"{len(ids_list)} result(s) returned")
except Exception as e:
if use_json:
click.echo(json_mod.dumps({"error": str(e)}, indent=2))
else:
skin.error(f"Query failed: {e}")
raise SystemExit(1)
@@ -0,0 +1,56 @@
"""Server commands -- heartbeat and version."""
import json as json_mod
import click
from ..utils.chromadb_backend import ChromaDBBackend
@click.group("server")
@click.pass_context
def server_group(ctx):
"""Server health and version commands."""
pass
@server_group.command("heartbeat")
@click.pass_context
def heartbeat(ctx):
"""Check ChromaDB server health."""
backend: ChromaDBBackend = ctx.obj["backend"]
use_json = ctx.obj.get("json", False)
skin = ctx.obj.get("skin")
try:
result = backend.heartbeat()
if use_json:
click.echo(json_mod.dumps(result, indent=2))
else:
skin.success("ChromaDB server is alive")
skin.status("Heartbeat", str(result.get("nanosecond_heartbeat", result)))
except Exception as e:
if use_json:
click.echo(json_mod.dumps({"error": str(e)}, indent=2))
else:
skin.error(f"Server unreachable: {e}")
raise SystemExit(1)
@server_group.command("version")
@click.pass_context
def version(ctx):
"""Get ChromaDB server version."""
backend: ChromaDBBackend = ctx.obj["backend"]
use_json = ctx.obj.get("json", False)
skin = ctx.obj.get("skin")
try:
result = backend.version()
if use_json:
click.echo(json_mod.dumps({"version": result}, indent=2))
else:
skin.status("Version", str(result))
except Exception as e:
if use_json:
click.echo(json_mod.dumps({"error": str(e)}, indent=2))
else:
skin.error(f"Server unreachable: {e}")
raise SystemExit(1)
@@ -0,0 +1,117 @@
---
name: >-
cli-anything-chromadb
description: >-
Command-line interface for ChromaDB - A stateless CLI for managing vector database collections, documents, and semantic search. Designed for AI agents and automation via the ChromaDB HTTP API v2.
---
# cli-anything-chromadb
A stateless command-line interface for ChromaDB vector database, built on the HTTP API v2. Designed for AI agents and power users who need to manage collections, documents, and run semantic queries without a browser UI.
## Installation
This CLI is installed as part of the cli-anything-chromadb package:
```bash
pip install cli-anything-chromadb
```
**Prerequisites:**
- Python 3.10+
- ChromaDB server running at localhost:8000 (or specify via --host)
## Usage
### Basic Commands
```bash
# Show help
cli-anything-chromadb --help
# Start interactive REPL mode
cli-anything-chromadb
# Check server health
cli-anything-chromadb --json server heartbeat
# List all collections
cli-anything-chromadb --json collection list
# Semantic search
cli-anything-chromadb --json query search --collection hub_knowledge --text "How to deploy"
```
### REPL Mode
When invoked without a subcommand, the CLI enters an interactive REPL session:
```bash
cli-anything-chromadb
# Enter commands interactively with tab-completion and history
```
## Command Groups
### server
Server health and version commands.
| Command | Description |
|---------|-------------|
| `heartbeat` | Check ChromaDB server health |
| `version` | Get ChromaDB server version |
### collection
Manage ChromaDB collections.
| Command | Description |
|---------|-------------|
| `list` | List all collections |
| `create --name NAME` | Create a new collection |
| `delete --name NAME` | Delete a collection |
| `info NAME` | Get collection info |
### document
Manage documents in collections.
| Command | Description |
|---------|-------------|
| `add --collection C --id ID --document TEXT` | Add document(s) |
| `get --collection C` | Get documents |
| `delete --collection C --id ID` | Delete document(s) |
| `count --collection C` | Count documents |
### query
Semantic search against collections.
| Command | Description |
|---------|-------------|
| `search --collection C --text T` | Semantic search |
## Output Formats
All commands support dual output modes:
- **Human-readable** (default): Tables, colors, formatted text
- **Machine-readable** (`--json` flag): Structured JSON for agent consumption
```bash
# Human output
cli-anything-chromadb server heartbeat
# JSON output for agents
cli-anything-chromadb --json server heartbeat
```
## For AI Agents
When using this CLI programmatically:
1. **Always use `--json` flag** for parseable output
2. **Check return codes** - 0 for success, non-zero for errors
3. **Parse stderr** for error messages on failure
4. **Use `--host`** to connect to non-default ChromaDB instances
## Version
1.0.0
@@ -0,0 +1,81 @@
# 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
# Activate the CLI-Anything venv
source /Users/whitenoise-oc/projects/CLI-Anything/.venv/bin/activate
# Install the package in dev mode
cd /Users/whitenoise-oc/projects/cli-anything-chromadb/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
}
+21
View File
@@ -0,0 +1,21 @@
"""Setup for cli-anything-chromadb."""
from setuptools import setup, find_packages
setup(
name="cli-anything-chromadb",
version="1.0.0",
description="CLI-Anything harness for ChromaDB vector database",
packages=find_packages(),
python_requires=">=3.10",
install_requires=[
"click>=8.0",
"prompt-toolkit>=3.0",
"requests>=2.28",
],
entry_points={
"console_scripts": [
"cli-anything-chromadb=cli_anything.chromadb.chromadb_cli:main",
],
},
)
+101
View File
@@ -0,0 +1,101 @@
# Agent Harness SOP: PM2 Process Management
## Purpose
This harness provides a CLI-Anything interface for PM2 process management.
It wraps the PM2 CLI via subprocess calls and exposes process lifecycle, logs,
and system commands through a unified Click CLI with interactive REPL mode.
## Backend
- **Engine**: PM2 (Node.js process manager) via subprocess
- **Binary**: `pm2` resolved via `shutil.which()` with fallback paths
- **Protocol**: Subprocess execution with stdout/stderr capture
- **Data format**: PM2 outputs JSON via `pm2 jlist`; other commands return plain text
## Architecture
```
cli-anything-pm2 (Click CLI)
|
+-- pm2_cli.py # Click groups + REPL dispatcher
|
+-- core/
| +-- processes.py # list, describe, metrics
| +-- lifecycle.py # start, stop, restart, delete
| +-- logs.py # view, flush
| +-- system.py # save, startup, version
|
+-- utils/
| +-- pm2_backend.py # subprocess wrapper (_run_pm2, pm2_jlist, etc.)
| +-- repl_skin.py # cli-anything branded REPL UI
|
+-- tests/
| +-- test_core.py # Unit tests (mocked subprocess)
| +-- test_full_e2e.py # E2E tests (real pm2 binary)
| +-- TEST.md # Test plan and results
|
+-- skills/
+-- SKILL.md # Agent skill definitions
```
## Command Groups
| Group | Commands | Backend Calls |
|-------------|------------------------------|-----------------------------------|
| `process` | list, describe, metrics | `pm2 jlist` (JSON parse) |
| `lifecycle` | start, stop, restart, delete | `pm2 start/stop/restart/delete` |
| `logs` | view, flush | `pm2 logs --nostream`, `pm2 flush`|
| `system` | save, startup, version | `pm2 save/startup/--version` |
## State Model
**Stateless.** Every command queries PM2 fresh via subprocess. There is no
in-memory session state, no project files, and no undo/redo. The PM2 daemon
itself maintains process state; this harness is a read/write proxy.
## Output Modes
All commands support two output modes controlled by the `--json` flag:
- **Human mode** (default): Formatted tables, status messages with color
- **JSON mode** (`--json`): Machine-readable JSON for agent consumption
## Interaction Modes
1. **Subcommand CLI**: `cli-anything-pm2 [--json] <group> <command> [args]`
2. **Interactive REPL**: `cli-anything-pm2` (no subcommand) launches the REPL
## Key Design Decisions
- PM2 binary path is cached after first lookup (`_PM2_BIN` module-level)
- `pm2 jlist` is used instead of `pm2 describe` for JSON reliability
- Subprocess environment includes `/opt/homebrew/bin` for macOS compatibility
- Timeout defaults to 30 seconds per subprocess call
- JSON parsing has fallback extraction for non-JSON preamble in stdout
## Error Handling
| Scenario | Behavior |
|-----------------------|---------------------------------------------|
| pm2 not installed | `RuntimeError` with install instructions |
| pm2 binary vanishes | `FileNotFoundError` caught, error dict returned |
| Command timeout | `TimeoutExpired` caught, error dict returned |
| Invalid JSON output | Fallback regex extraction, then `data: None` |
| Process not found | Returns `None` or exit code 1 |
## Testing
- **Unit tests**: `test_core.py` -- all subprocess calls mocked, no pm2 required
- **E2E tests**: `test_full_e2e.py` -- calls real pm2 binary, requires pm2 installed
```bash
# Run all tests
python -m pytest cli_anything/pm2/tests/ -v
# Run only unit tests (no pm2 needed)
python -m pytest cli_anything/pm2/tests/test_core.py -v
# Run only E2E tests (pm2 must be installed and running)
python -m pytest cli_anything/pm2/tests/test_full_e2e.py -v
```
@@ -0,0 +1,41 @@
# cli-anything PM2
CLI-Anything harness for PM2 process management. All PM2 commands are executed via subprocess -- no PM2 API dependency required.
## Quick Start
```bash
# Install
cd agent-harness && pip install -e .
# REPL mode
cli-anything-pm2
# Direct commands
cli-anything-pm2 process list
cli-anything-pm2 --json process list
cli-anything-pm2 lifecycle restart seaclip-dev
cli-anything-pm2 logs view voice-agent --lines 50
cli-anything-pm2 system version
```
## Command Groups
- **process**: list, describe, metrics
- **lifecycle**: start, stop, restart, delete
- **logs**: view, flush
- **system**: save, startup, version
## Architecture
```
pm2_cli.py Click CLI + REPL entry point
core/
processes.py list, describe, metrics
lifecycle.py start, stop, restart, delete
logs.py view, flush
system.py save, startup, version
utils/
pm2_backend.py subprocess wrapper for pm2 commands
repl_skin.py cli-anything REPL skin
```
@@ -0,0 +1,3 @@
"""cli-anything PM2 — CLI harness for PM2 process management."""
__version__ = "1.0.0"
@@ -0,0 +1,5 @@
"""Allow running as python -m cli_anything.pm2."""
from .pm2_cli import main
main()
@@ -0,0 +1,118 @@
"""Lifecycle commands — start, stop, restart, delete PM2 processes."""
import json
from typing import Any
from ..utils.pm2_backend import pm2_action, pm2_start as backend_start
def restart_process(name: str, as_json: bool = False) -> dict[str, Any]:
"""Restart a PM2 process.
Args:
name: Process name or ID.
as_json: If True, return raw result dict.
Returns:
Result dict with success status and message.
"""
result = pm2_action("restart", name)
if as_json:
return {
"action": "restart",
"process": name,
"success": result["success"],
"stdout": result["stdout"],
"stderr": result["stderr"],
}
return {
"success": result["success"],
"message": f"Restarted '{name}'" if result["success"]
else f"Failed to restart '{name}': {result['stderr'].strip()}",
}
def stop_process(name: str, as_json: bool = False) -> dict[str, Any]:
"""Stop a PM2 process.
Args:
name: Process name or ID.
as_json: If True, return raw result dict.
Returns:
Result dict with success status and message.
"""
result = pm2_action("stop", name)
if as_json:
return {
"action": "stop",
"process": name,
"success": result["success"],
"stdout": result["stdout"],
"stderr": result["stderr"],
}
return {
"success": result["success"],
"message": f"Stopped '{name}'" if result["success"]
else f"Failed to stop '{name}': {result['stderr'].strip()}",
}
def delete_process(name: str, as_json: bool = False) -> dict[str, Any]:
"""Delete a PM2 process.
Args:
name: Process name or ID.
as_json: If True, return raw result dict.
Returns:
Result dict with success status and message.
"""
result = pm2_action("delete", name)
if as_json:
return {
"action": "delete",
"process": name,
"success": result["success"],
"stdout": result["stdout"],
"stderr": result["stderr"],
}
return {
"success": result["success"],
"message": f"Deleted '{name}'" if result["success"]
else f"Failed to delete '{name}': {result['stderr'].strip()}",
}
def start_process(
script: str,
name: str | None = None,
as_json: bool = False,
) -> dict[str, Any]:
"""Start a new PM2 process.
Args:
script: Path to script or ecosystem file.
name: Optional process name.
as_json: If True, return raw result dict.
Returns:
Result dict with success status and message.
"""
result = backend_start(script, name=name)
display_name = name or script
if as_json:
return {
"action": "start",
"script": script,
"name": name,
"success": result["success"],
"stdout": result["stdout"],
"stderr": result["stderr"],
}
return {
"success": result["success"],
"message": f"Started '{display_name}'" if result["success"]
else f"Failed to start '{display_name}': {result['stderr'].strip()}",
}
@@ -0,0 +1,71 @@
"""Log commands — view and flush PM2 process logs."""
from typing import Any
from ..utils.pm2_backend import pm2_logs as backend_logs, pm2_flush as backend_flush
def view_logs(name: str, lines: int = 20, as_json: bool = False) -> dict[str, Any]:
"""View recent logs for a PM2 process.
Args:
name: Process name or ID.
lines: Number of log lines to retrieve.
as_json: If True, return structured dict.
Returns:
Dict with log content and metadata.
"""
result = backend_logs(name, lines=lines)
if as_json:
return {
"process": name,
"lines_requested": lines,
"success": result["success"],
"stdout": result["stdout"],
"stderr": result["stderr"],
}
if result["success"]:
output = result["stdout"].strip() or result["stderr"].strip()
return {
"success": True,
"message": f"Logs for '{name}' (last {lines} lines)",
"content": output if output else "(no log output)",
}
else:
return {
"success": False,
"message": f"Failed to get logs for '{name}': {result['stderr'].strip()}",
"content": "",
}
def flush_logs(name: str | None = None, as_json: bool = False) -> dict[str, Any]:
"""Flush logs for a process or all processes.
Args:
name: Process name or ID. If None, flushes all logs.
as_json: If True, return structured dict.
Returns:
Dict with success status and message.
"""
result = backend_flush(name)
target = f"'{name}'" if name else "all processes"
if as_json:
return {
"action": "flush",
"process": name or "all",
"success": result["success"],
"stdout": result["stdout"],
"stderr": result["stderr"],
}
return {
"success": result["success"],
"message": f"Flushed logs for {target}" if result["success"]
else f"Failed to flush logs for {target}: {result['stderr'].strip()}",
}
@@ -0,0 +1,160 @@
"""Process commands — list, describe, and metrics for PM2 processes."""
import json
from typing import Any
from ..utils.pm2_backend import pm2_jlist, pm2_describe
def list_processes(as_json: bool = False) -> str | list[dict[str, Any]]:
"""List all PM2 processes.
Args:
as_json: If True, return raw list of dicts. Otherwise, formatted string.
Returns:
Formatted table string or list of process dicts.
"""
processes = pm2_jlist()
if as_json:
return processes
if not processes:
return "No PM2 processes running."
rows = []
for p in processes:
pm2_env = p.get("pm2_env", {})
monit = p.get("monit", {})
rows.append({
"id": p.get("pm_id", "?"),
"name": p.get("name", "unknown"),
"status": pm2_env.get("status", "unknown"),
"cpu": f"{monit.get('cpu', 0)}%",
"memory": _format_bytes(monit.get("memory", 0)),
"restarts": pm2_env.get("restart_time", 0),
"uptime": _format_uptime(pm2_env.get("pm_uptime", 0)),
})
return rows
def describe_process(name: str, as_json: bool = False) -> str | dict[str, Any] | None:
"""Get detailed info for a specific process.
Args:
name: Process name or ID.
as_json: If True, return raw dict.
Returns:
Formatted info string, raw dict, or None if not found.
"""
info = pm2_describe(name)
if info is None:
return None
if as_json:
return info
pm2_env = info.get("pm2_env", {})
monit = info.get("monit", {})
details = {
"Name": info.get("name", "unknown"),
"ID": str(info.get("pm_id", "?")),
"Status": pm2_env.get("status", "unknown"),
"Script": pm2_env.get("pm_exec_path", "N/A"),
"CWD": pm2_env.get("pm_cwd", "N/A"),
"Interpreter": pm2_env.get("exec_interpreter", "N/A"),
"CPU": f"{monit.get('cpu', 0)}%",
"Memory": _format_bytes(monit.get("memory", 0)),
"Restarts": str(pm2_env.get("restart_time", 0)),
"Uptime": _format_uptime(pm2_env.get("pm_uptime", 0)),
"PID": str(info.get("pid", "N/A")),
"Exec Mode": pm2_env.get("exec_mode", "N/A"),
"Node Version": pm2_env.get("node_version", "N/A"),
}
return details
def get_metrics(as_json: bool = False) -> str | list[dict[str, Any]]:
"""Get CPU/memory metrics for all processes.
Args:
as_json: If True, return raw list of metric dicts.
Returns:
Formatted metrics string or list of metric dicts.
"""
processes = pm2_jlist()
if as_json:
metrics = []
for p in processes:
monit = p.get("monit", {})
pm2_env = p.get("pm2_env", {})
metrics.append({
"name": p.get("name", "unknown"),
"pm_id": p.get("pm_id", "?"),
"status": pm2_env.get("status", "unknown"),
"cpu": monit.get("cpu", 0),
"memory": monit.get("memory", 0),
"memory_human": _format_bytes(monit.get("memory", 0)),
})
return metrics
if not processes:
return "No PM2 processes running."
rows = []
for p in processes:
monit = p.get("monit", {})
pm2_env = p.get("pm2_env", {})
rows.append({
"id": p.get("pm_id", "?"),
"name": p.get("name", "unknown"),
"status": pm2_env.get("status", "unknown"),
"cpu": f"{monit.get('cpu', 0)}%",
"memory": _format_bytes(monit.get("memory", 0)),
})
return rows
def _format_bytes(b: int) -> str:
"""Format bytes into human-readable string."""
if b == 0:
return "0 B"
units = ["B", "KB", "MB", "GB"]
i = 0
val = float(b)
while val >= 1024 and i < len(units) - 1:
val /= 1024
i += 1
return f"{val:.1f} {units[i]}"
def _format_uptime(pm_uptime: int) -> str:
"""Format PM2 uptime timestamp to human-readable duration."""
if pm_uptime == 0:
return "N/A"
import time
now_ms = int(time.time() * 1000)
diff_s = max(0, (now_ms - pm_uptime)) // 1000
if diff_s < 60:
return f"{diff_s}s"
elif diff_s < 3600:
return f"{diff_s // 60}m {diff_s % 60}s"
elif diff_s < 86400:
h = diff_s // 3600
m = (diff_s % 3600) // 60
return f"{h}h {m}m"
else:
d = diff_s // 86400
h = (diff_s % 86400) // 3600
return f"{d}d {h}h"
@@ -0,0 +1,80 @@
"""System commands — save, startup, version for PM2."""
from typing import Any
from ..utils.pm2_backend import pm2_save as backend_save
from ..utils.pm2_backend import pm2_startup as backend_startup
from ..utils.pm2_backend import pm2_version as backend_version
def save(as_json: bool = False) -> dict[str, Any]:
"""Save the current PM2 process list.
Args:
as_json: If True, return structured dict.
Returns:
Dict with success status and message.
"""
result = backend_save()
if as_json:
return {
"action": "save",
"success": result["success"],
"stdout": result["stdout"],
"stderr": result["stderr"],
}
return {
"success": result["success"],
"message": "PM2 process list saved" if result["success"]
else f"Failed to save: {result['stderr'].strip()}",
}
def startup(as_json: bool = False) -> dict[str, Any]:
"""Generate PM2 startup script.
Args:
as_json: If True, return structured dict.
Returns:
Dict with success status, message, and any instructions.
"""
result = backend_startup()
if as_json:
return {
"action": "startup",
"success": result["success"],
"stdout": result["stdout"],
"stderr": result["stderr"],
}
output = result["stdout"].strip()
return {
"success": result["success"],
"message": "Startup script generated" if result["success"]
else f"Startup command output:\n{output}",
"instructions": output,
}
def version(as_json: bool = False) -> dict[str, Any] | str:
"""Get PM2 version.
Args:
as_json: If True, return structured dict.
Returns:
Version string or dict.
"""
ver = backend_version()
if as_json:
return {
"version": ver,
}
return ver
@@ -0,0 +1,371 @@
"""PM2 CLI — Click-based CLI with REPL mode for PM2 process management.
Entry point: cli-anything-pm2
"""
import json
import sys
import click
from .core import processes, lifecycle, logs, system
# ── Helpers ──────────────────────────────────────────────────────────────
def _output(data, as_json: bool):
"""Print data as JSON or formatted text."""
if as_json:
if isinstance(data, str):
click.echo(json.dumps({"result": data}, indent=2))
else:
click.echo(json.dumps(data, indent=2, default=str))
else:
if isinstance(data, str):
click.echo(data)
elif isinstance(data, dict):
if "message" in data:
prefix = "OK" if data.get("success", True) else "ERROR"
click.echo(f"[{prefix}] {data['message']}")
if data.get("content"):
click.echo(data["content"])
if data.get("instructions"):
click.echo(data["instructions"])
else:
for k, v in data.items():
click.echo(f" {k}: {v}")
elif isinstance(data, list):
if data and isinstance(data[0], dict):
# Print as table
if not data:
return
keys = list(data[0].keys())
# Header
header = " ".join(f"{k:<15}" for k in keys)
click.echo(header)
click.echo("-" * len(header))
for row in data:
line = " ".join(f"{str(row.get(k, '')):<15}" for k in keys)
click.echo(line)
else:
for item in data:
click.echo(str(item))
# ── Main Group ───────────────────────────────────────────────────────────
@click.group(invoke_without_command=True)
@click.option("--json", "as_json", is_flag=True, default=False,
help="Output in JSON format.")
@click.pass_context
def main(ctx, as_json):
"""CLI-Anything PM2 — Process management harness for PM2."""
ctx.ensure_object(dict)
ctx.obj["json"] = as_json
if ctx.invoked_subcommand is None:
# Launch REPL mode
_run_repl()
# ── Process Group ────────────────────────────────────────────────────────
@main.group()
@click.pass_context
def process(ctx):
"""Process info commands: list, describe, metrics."""
pass
@process.command("list")
@click.pass_context
def process_list(ctx):
"""List all PM2 processes."""
as_json = ctx.obj["json"]
data = processes.list_processes(as_json=as_json)
_output(data, as_json)
@process.command("describe")
@click.argument("name")
@click.pass_context
def process_describe(ctx, name):
"""Show detailed info for a PM2 process."""
as_json = ctx.obj["json"]
data = processes.describe_process(name, as_json=as_json)
if data is None:
click.echo(f"Process '{name}' not found.", err=True)
sys.exit(1)
_output(data, as_json)
@process.command("metrics")
@click.pass_context
def process_metrics(ctx):
"""Show CPU/memory metrics for all processes."""
as_json = ctx.obj["json"]
data = processes.get_metrics(as_json=as_json)
_output(data, as_json)
# ── Lifecycle Group ──────────────────────────────────────────────────────
@main.group()
@click.pass_context
def lifecycle(ctx):
"""Lifecycle commands: start, stop, restart, delete."""
pass
@lifecycle.command("restart")
@click.argument("name")
@click.pass_context
def lifecycle_restart(ctx, name):
"""Restart a PM2 process."""
as_json = ctx.obj["json"]
data = lifecycle_mod.restart_process(name, as_json=as_json)
_output(data, as_json)
@lifecycle.command("stop")
@click.argument("name")
@click.pass_context
def lifecycle_stop(ctx, name):
"""Stop a PM2 process."""
as_json = ctx.obj["json"]
data = lifecycle_mod.stop_process(name, as_json=as_json)
_output(data, as_json)
@lifecycle.command("start")
@click.argument("script")
@click.option("--name", default=None, help="Process name.")
@click.pass_context
def lifecycle_start(ctx, script, name):
"""Start a new PM2 process."""
as_json = ctx.obj["json"]
data = lifecycle_mod.start_process(script, name=name, as_json=as_json)
_output(data, as_json)
@lifecycle.command("delete")
@click.argument("name")
@click.pass_context
def lifecycle_delete(ctx, name):
"""Delete a PM2 process."""
as_json = ctx.obj["json"]
data = lifecycle_mod.delete_process(name, as_json=as_json)
_output(data, as_json)
# Alias to avoid name collision with the click group
lifecycle_mod = lifecycle_module = None
def _init_lifecycle_mod():
"""Lazy-init the lifecycle module reference."""
global lifecycle_mod
if lifecycle_mod is None:
from .core import lifecycle as _lc
lifecycle_mod = _lc
# Patch lifecycle commands to use the module
_init_lifecycle_mod()
# ── Logs Group ───────────────────────────────────────────────────────────
@main.group("logs")
@click.pass_context
def logs_group(ctx):
"""Log commands: view, flush."""
pass
@logs_group.command("view")
@click.argument("name")
@click.option("--lines", default=20, help="Number of log lines.")
@click.pass_context
def logs_view(ctx, name, lines):
"""View recent logs for a PM2 process."""
as_json = ctx.obj["json"]
data = logs.view_logs(name, lines=lines, as_json=as_json)
_output(data, as_json)
@logs_group.command("flush")
@click.argument("name", required=False, default=None)
@click.pass_context
def logs_flush(ctx, name):
"""Flush logs for a process (or all if no name given)."""
as_json = ctx.obj["json"]
data = logs.flush_logs(name=name, as_json=as_json)
_output(data, as_json)
# ── System Group ─────────────────────────────────────────────────────────
@main.group("system")
@click.pass_context
def system_group(ctx):
"""System commands: save, startup, version."""
pass
@system_group.command("save")
@click.pass_context
def system_save(ctx):
"""Save current PM2 process list."""
as_json = ctx.obj["json"]
data = system.save(as_json=as_json)
_output(data, as_json)
@system_group.command("startup")
@click.pass_context
def system_startup(ctx):
"""Generate PM2 startup script."""
as_json = ctx.obj["json"]
data = system.startup(as_json=as_json)
_output(data, as_json)
@system_group.command("version")
@click.pass_context
def system_version(ctx):
"""Show PM2 version."""
as_json = ctx.obj["json"]
data = system.version(as_json=as_json)
_output(data, as_json)
# ── REPL Mode ────────────────────────────────────────────────────────────
def _run_repl():
"""Launch the interactive REPL."""
from .utils.repl_skin import ReplSkin
skin = ReplSkin("pm2", version="1.0.0")
skin.print_banner()
session = skin.create_prompt_session()
# REPL command mapping
repl_commands = {
"process list": lambda args, j: _output(processes.list_processes(as_json=j), j),
"process describe": lambda args, j: _output(
processes.describe_process(args[0], as_json=j) if args else "Usage: process describe <name>", j
),
"process metrics": lambda args, j: _output(processes.get_metrics(as_json=j), j),
"lifecycle restart": lambda args, j: _output(
lifecycle_mod.restart_process(args[0], as_json=j) if args else "Usage: lifecycle restart <name>", j
),
"lifecycle stop": lambda args, j: _output(
lifecycle_mod.stop_process(args[0], as_json=j) if args else "Usage: lifecycle stop <name>", j
),
"lifecycle start": lambda args, j: _repl_start(args, j),
"lifecycle delete": lambda args, j: _output(
lifecycle_mod.delete_process(args[0], as_json=j) if args else "Usage: lifecycle delete <name>", j
),
"logs view": lambda args, j: _repl_logs_view(args, j),
"logs flush": lambda args, j: _output(logs.flush_logs(name=args[0] if args else None, as_json=j), j),
"system save": lambda args, j: _output(system.save(as_json=j), j),
"system startup": lambda args, j: _output(system.startup(as_json=j), j),
"system version": lambda args, j: _output(system.version(as_json=j), j),
}
help_commands = {
"process list": "List all PM2 processes",
"process describe N": "Detailed info for process N",
"process metrics": "CPU/memory metrics for all processes",
"lifecycle start S": "Start script S [--name N]",
"lifecycle stop N": "Stop process N",
"lifecycle restart N":"Restart process N",
"lifecycle delete N": "Delete process N",
"logs view N": "View logs for process N [--lines 50]",
"logs flush [N]": "Flush logs (optionally for process N)",
"system save": "Save PM2 process list",
"system startup": "Generate startup script",
"system version": "Show PM2 version",
"help": "Show this help",
"quit / exit": "Exit the REPL",
}
while True:
try:
user_input = skin.get_input(session)
except (EOFError, KeyboardInterrupt):
skin.print_goodbye()
break
if not user_input:
continue
raw = user_input.strip()
if raw in ("quit", "exit", "q"):
skin.print_goodbye()
break
if raw == "help":
skin.help(help_commands)
continue
# Check for --json flag in input
as_json = False
if "--json" in raw:
as_json = True
raw = raw.replace("--json", "").strip()
# Match command
matched = False
for cmd_key, handler in repl_commands.items():
if raw.startswith(cmd_key):
remainder = raw[len(cmd_key):].strip()
args = remainder.split() if remainder else []
try:
handler(args, as_json)
except Exception as e:
skin.error(str(e))
matched = True
break
if not matched:
skin.warning(f"Unknown command: {raw}")
skin.hint("Type 'help' for available commands.")
def _repl_start(args, as_json):
"""Handle 'lifecycle start' in REPL with --name parsing."""
if not args:
click.echo("Usage: lifecycle start <script> [--name <name>]")
return
script = args[0]
name = None
if "--name" in args:
idx = args.index("--name")
if idx + 1 < len(args):
name = args[idx + 1]
_output(lifecycle_mod.start_process(script, name=name, as_json=as_json), as_json)
def _repl_logs_view(args, as_json):
"""Handle 'logs view' in REPL with --lines parsing."""
if not args:
click.echo("Usage: logs view <name> [--lines N]")
return
name = args[0]
lines = 20
if "--lines" in args:
idx = args.index("--lines")
if idx + 1 < len(args):
try:
lines = int(args[idx + 1])
except ValueError:
pass
_output(logs.view_logs(name, lines=lines, as_json=as_json), as_json)
if __name__ == "__main__":
main()
@@ -0,0 +1,40 @@
---
name: pm2
version: 1.0.0
backend: pm2 (subprocess)
state: stateless
entry_point: cli-anything-pm2
groups:
- process
- lifecycle
- logs
- system
---
# PM2 Skills
Skills for the PM2 CLI-Anything harness. Add agent skills here as `.py` files.
## Available Command Skills
| Skill | Group | Command |
|-----------------|-----------|--------------------------------------------|
| List processes | process | `cli-anything-pm2 --json process list` |
| Describe process| process | `cli-anything-pm2 --json process describe <name>` |
| Get metrics | process | `cli-anything-pm2 --json process metrics` |
| Start process | lifecycle | `cli-anything-pm2 lifecycle start <script> --name <name>` |
| Stop process | lifecycle | `cli-anything-pm2 lifecycle stop <name>` |
| Restart process | lifecycle | `cli-anything-pm2 lifecycle restart <name>`|
| Delete process | lifecycle | `cli-anything-pm2 lifecycle delete <name>` |
| View logs | logs | `cli-anything-pm2 logs view <name> --lines 50` |
| Flush logs | logs | `cli-anything-pm2 logs flush [name]` |
| Save process list| system | `cli-anything-pm2 system save` |
| Startup script | system | `cli-anything-pm2 system startup` |
| PM2 version | system | `cli-anything-pm2 --json system version` |
## Planned Skills
- **auto-restart**: Monitor processes and auto-restart on crash
- **health-check**: Periodic health checks on all PM2 processes
- **log-rotate**: Automated log rotation and cleanup
- **deploy**: Rolling restart with zero-downtime deployment
@@ -0,0 +1,85 @@
# 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
# Activate the CLI-Anything venv
source /Users/whitenoise-oc/projects/CLI-Anything/.venv/bin/activate
cd /Users/whitenoise-oc/projects/cli-anything-pm2/agent-harness
# 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,241 @@
"""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
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.
"""
# Ensure common paths are in PATH for shutil.which
extra_paths = ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin"]
env_path = os.environ.get("PATH", "")
for p in extra_paths:
if p not in env_path:
env_path = f"{p}:{env_path}"
os.environ["PATH"] = env_path
pm2_path = shutil.which("pm2")
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()
extra = "/opt/homebrew/bin:/usr/local/bin"
if extra not in env.get("PATH", ""):
env["PATH"] = f"{extra}:{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
}
+21
View File
@@ -0,0 +1,21 @@
"""Setup for cli-anything-pm2 — CLI harness for PM2 process management."""
from setuptools import setup, find_packages
setup(
name="cli-anything-pm2",
version="1.0.0",
description="CLI-Anything harness for PM2 process management",
author="Velocity Team",
packages=find_packages(),
python_requires=">=3.10",
install_requires=[
"click>=8.0",
"prompt-toolkit>=3.0",
],
entry_points={
"console_scripts": [
"cli-anything-pm2=cli_anything.pm2.pm2_cli:main",
],
},
)
+45 -3
View File
@@ -1,8 +1,8 @@
{
"meta": {
"repo": "https://github.com/HKUDS/CLI-Anything",
"description": "CLI-Hub Agent-native stateful CLI interfaces for softwares, codebases, and Web Services",
"updated": "2026-03-18"
"description": "CLI-Hub \u2014 Agent-native stateful CLI interfaces for softwares, codebases, and Web Services",
"updated": "2026-03-23"
},
"clis": [
{
@@ -268,6 +268,48 @@
"category": "ai",
"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 \u2014 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 \u2014 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"
}
]
}
}
+128
View File
@@ -0,0 +1,128 @@
# SeaClip-Lite - Standard Operating Procedure
## Overview
SeaClip-Lite is a lightweight project management board built on FastAPI + SQLite.
It provides issue tracking, a 6-agent CI/CD pipeline, GitHub issue scheduling,
and an activity feed. This CLI harness provides programmatic control over
SeaClip-Lite via its HTTP API and direct SQLite reads.
## Architecture
| Layer | Technology | Purpose |
|-------|-----------|---------|
| API server | FastAPI | Issue CRUD, pipeline control, scheduler sync |
| Database | SQLite | Persistent storage for issues, agents, schedules, activity |
| CLI transport | HTTP (`requests`) | JSON API calls for issues, pipeline, health |
| CLI transport | SQLite (read-only) | Direct DB reads for agents, schedules, activity |
The CLI is a thin Click application that delegates to `SeaClipBackend`, which
routes each call to either the FastAPI JSON endpoints or the SQLite database.
Some FastAPI endpoints return HTMX partials instead of JSON, so those are
served by direct SQLite queries instead.
## State Model
**Stateless.** The CLI holds no local state between invocations. Every command
reads from the API or database at call time. There is no project file, session,
or undo/redo stack.
## Backend
- **FastAPI** at `http://127.0.0.1:5200`
- **SQLite** at `/Users/whitenoise-oc/shrirama/seaclip-lite/seaclip.db`
## 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
source /Users/whitenoise-oc/projects/CLI-Anything/.venv/bin/activate
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,78 @@
# 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
rootdir: /Users/whitenoise-oc/projects/cli-anything-seaclip/agent-harness
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,144 @@
"""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"
DEFAULT_DB_PATH = "/Users/whitenoise-oc/shrirama/seaclip-lite/seaclip.db"
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") or DEFAULT_DB_PATH
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."""
conn = sqlite3.connect(self.db_path)
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,),
)
+23
View File
@@ -0,0 +1,23 @@
"""Setup for cli-anything-seaclip — CLI harness for SeaClip-Lite."""
from setuptools import setup, find_packages
setup(
name="cli-anything-seaclip",
version="1.0.0",
description="CLI-Anything harness for SeaClip-Lite project management",
author="Vinayak",
author_email="vinayak@whitenoiseacademy.com",
python_requires=">=3.10",
packages=find_packages(),
install_requires=[
"click",
"prompt-toolkit",
"requests",
],
entry_points={
"console_scripts": [
"cli-anything-seaclip=cli_anything.seaclip.seaclip_cli:main",
],
},
)