chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
# Agent Harness SOP: PM2 Process Management
|
||||
|
||||
## Purpose
|
||||
|
||||
This harness provides a CLI-Anything interface for PM2 process management.
|
||||
It wraps the PM2 CLI via subprocess calls and exposes process lifecycle, logs,
|
||||
and system commands through a unified Click CLI with interactive REPL mode.
|
||||
|
||||
## Backend
|
||||
|
||||
- **Engine**: PM2 (Node.js process manager) via subprocess
|
||||
- **Binary**: `pm2` resolved via `shutil.which()` with fallback paths
|
||||
- **Protocol**: Subprocess execution with stdout/stderr capture
|
||||
- **Data format**: PM2 outputs JSON via `pm2 jlist`; other commands return plain text
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
cli-anything-pm2 (Click CLI)
|
||||
|
|
||||
+-- pm2_cli.py # Click groups + REPL dispatcher
|
||||
|
|
||||
+-- core/
|
||||
| +-- processes.py # list, describe, metrics
|
||||
| +-- lifecycle.py # start, stop, restart, delete
|
||||
| +-- logs.py # view, flush
|
||||
| +-- system.py # save, startup, version
|
||||
|
|
||||
+-- utils/
|
||||
| +-- pm2_backend.py # subprocess wrapper (_run_pm2, pm2_jlist, etc.)
|
||||
| +-- repl_skin.py # cli-anything branded REPL UI
|
||||
|
|
||||
+-- tests/
|
||||
| +-- test_core.py # Unit tests (mocked subprocess)
|
||||
| +-- test_full_e2e.py # E2E tests (real pm2 binary)
|
||||
| +-- TEST.md # Test plan and results
|
||||
|
|
||||
+-- skills/
|
||||
+-- SKILL.md # Agent skill definitions
|
||||
```
|
||||
|
||||
## Command Groups
|
||||
|
||||
| Group | Commands | Backend Calls |
|
||||
|-------------|------------------------------|-----------------------------------|
|
||||
| `process` | list, describe, metrics | `pm2 jlist` (JSON parse) |
|
||||
| `lifecycle` | start, stop, restart, delete | `pm2 start/stop/restart/delete` |
|
||||
| `logs` | view, flush | `pm2 logs --nostream`, `pm2 flush`|
|
||||
| `system` | save, startup, version | `pm2 save/startup/--version` |
|
||||
|
||||
## State Model
|
||||
|
||||
**Stateless.** Every command queries PM2 fresh via subprocess. There is no
|
||||
in-memory session state, no project files, and no undo/redo. The PM2 daemon
|
||||
itself maintains process state; this harness is a read/write proxy.
|
||||
|
||||
## Output Modes
|
||||
|
||||
All commands support two output modes controlled by the `--json` flag:
|
||||
|
||||
- **Human mode** (default): Formatted tables, status messages with color
|
||||
- **JSON mode** (`--json`): Machine-readable JSON for agent consumption
|
||||
|
||||
## Interaction Modes
|
||||
|
||||
1. **Subcommand CLI**: `cli-anything-pm2 [--json] <group> <command> [args]`
|
||||
2. **Interactive REPL**: `cli-anything-pm2` (no subcommand) launches the REPL
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
- PM2 binary path is cached after first lookup (`_PM2_BIN` module-level)
|
||||
- `pm2 jlist` is used instead of `pm2 describe` for JSON reliability
|
||||
- Subprocess environment includes `/opt/homebrew/bin` for macOS compatibility
|
||||
- Timeout defaults to 30 seconds per subprocess call
|
||||
- JSON parsing has fallback extraction for non-JSON preamble in stdout
|
||||
|
||||
## Error Handling
|
||||
|
||||
| Scenario | Behavior |
|
||||
|-----------------------|---------------------------------------------|
|
||||
| pm2 not installed | `RuntimeError` with install instructions |
|
||||
| pm2 binary vanishes | `FileNotFoundError` caught, error dict returned |
|
||||
| Command timeout | `TimeoutExpired` caught, error dict returned |
|
||||
| Invalid JSON output | Fallback regex extraction, then `data: None` |
|
||||
| Process not found | Returns `None` or exit code 1 |
|
||||
|
||||
## Testing
|
||||
|
||||
- **Unit tests**: `test_core.py` -- all subprocess calls mocked, no pm2 required
|
||||
- **E2E tests**: `test_full_e2e.py` -- calls real pm2 binary, requires pm2 installed
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
python -m pytest cli_anything/pm2/tests/ -v
|
||||
|
||||
# Run only unit tests (no pm2 needed)
|
||||
python -m pytest cli_anything/pm2/tests/test_core.py -v
|
||||
|
||||
# Run only E2E tests (pm2 must be installed and running)
|
||||
python -m pytest cli_anything/pm2/tests/test_full_e2e.py -v
|
||||
```
|
||||
@@ -0,0 +1,41 @@
|
||||
# cli-anything PM2
|
||||
|
||||
CLI-Anything harness for PM2 process management. All PM2 commands are executed via subprocess -- no PM2 API dependency required.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Install
|
||||
cd agent-harness && pip install -e .
|
||||
|
||||
# REPL mode
|
||||
cli-anything-pm2
|
||||
|
||||
# Direct commands
|
||||
cli-anything-pm2 process list
|
||||
cli-anything-pm2 --json process list
|
||||
cli-anything-pm2 lifecycle restart seaclip-dev
|
||||
cli-anything-pm2 logs view voice-agent --lines 50
|
||||
cli-anything-pm2 system version
|
||||
```
|
||||
|
||||
## Command Groups
|
||||
|
||||
- **process**: list, describe, metrics
|
||||
- **lifecycle**: start, stop, restart, delete
|
||||
- **logs**: view, flush
|
||||
- **system**: save, startup, version
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
pm2_cli.py Click CLI + REPL entry point
|
||||
core/
|
||||
processes.py list, describe, metrics
|
||||
lifecycle.py start, stop, restart, delete
|
||||
logs.py view, flush
|
||||
system.py save, startup, version
|
||||
utils/
|
||||
pm2_backend.py subprocess wrapper for pm2 commands
|
||||
repl_skin.py cli-anything REPL skin
|
||||
```
|
||||
@@ -0,0 +1,3 @@
|
||||
"""cli-anything PM2 — CLI harness for PM2 process management."""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Allow running as python -m cli_anything.pm2."""
|
||||
|
||||
from .pm2_cli import main
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Lifecycle commands — start, stop, restart, delete PM2 processes."""
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from ..utils.pm2_backend import pm2_action, pm2_start as backend_start
|
||||
|
||||
|
||||
def restart_process(name: str, as_json: bool = False) -> dict[str, Any]:
|
||||
"""Restart a PM2 process.
|
||||
|
||||
Args:
|
||||
name: Process name or ID.
|
||||
as_json: If True, return raw result dict.
|
||||
|
||||
Returns:
|
||||
Result dict with success status and message.
|
||||
"""
|
||||
result = pm2_action("restart", name)
|
||||
if as_json:
|
||||
return {
|
||||
"action": "restart",
|
||||
"process": name,
|
||||
"success": result["success"],
|
||||
"stdout": result["stdout"],
|
||||
"stderr": result["stderr"],
|
||||
}
|
||||
return {
|
||||
"success": result["success"],
|
||||
"message": f"Restarted '{name}'" if result["success"]
|
||||
else f"Failed to restart '{name}': {result['stderr'].strip()}",
|
||||
}
|
||||
|
||||
|
||||
def stop_process(name: str, as_json: bool = False) -> dict[str, Any]:
|
||||
"""Stop a PM2 process.
|
||||
|
||||
Args:
|
||||
name: Process name or ID.
|
||||
as_json: If True, return raw result dict.
|
||||
|
||||
Returns:
|
||||
Result dict with success status and message.
|
||||
"""
|
||||
result = pm2_action("stop", name)
|
||||
if as_json:
|
||||
return {
|
||||
"action": "stop",
|
||||
"process": name,
|
||||
"success": result["success"],
|
||||
"stdout": result["stdout"],
|
||||
"stderr": result["stderr"],
|
||||
}
|
||||
return {
|
||||
"success": result["success"],
|
||||
"message": f"Stopped '{name}'" if result["success"]
|
||||
else f"Failed to stop '{name}': {result['stderr'].strip()}",
|
||||
}
|
||||
|
||||
|
||||
def delete_process(name: str, as_json: bool = False) -> dict[str, Any]:
|
||||
"""Delete a PM2 process.
|
||||
|
||||
Args:
|
||||
name: Process name or ID.
|
||||
as_json: If True, return raw result dict.
|
||||
|
||||
Returns:
|
||||
Result dict with success status and message.
|
||||
"""
|
||||
result = pm2_action("delete", name)
|
||||
if as_json:
|
||||
return {
|
||||
"action": "delete",
|
||||
"process": name,
|
||||
"success": result["success"],
|
||||
"stdout": result["stdout"],
|
||||
"stderr": result["stderr"],
|
||||
}
|
||||
return {
|
||||
"success": result["success"],
|
||||
"message": f"Deleted '{name}'" if result["success"]
|
||||
else f"Failed to delete '{name}': {result['stderr'].strip()}",
|
||||
}
|
||||
|
||||
|
||||
def start_process(
|
||||
script: str,
|
||||
name: str | None = None,
|
||||
as_json: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Start a new PM2 process.
|
||||
|
||||
Args:
|
||||
script: Path to script or ecosystem file.
|
||||
name: Optional process name.
|
||||
as_json: If True, return raw result dict.
|
||||
|
||||
Returns:
|
||||
Result dict with success status and message.
|
||||
"""
|
||||
result = backend_start(script, name=name)
|
||||
display_name = name or script
|
||||
|
||||
if as_json:
|
||||
return {
|
||||
"action": "start",
|
||||
"script": script,
|
||||
"name": name,
|
||||
"success": result["success"],
|
||||
"stdout": result["stdout"],
|
||||
"stderr": result["stderr"],
|
||||
}
|
||||
return {
|
||||
"success": result["success"],
|
||||
"message": f"Started '{display_name}'" if result["success"]
|
||||
else f"Failed to start '{display_name}': {result['stderr'].strip()}",
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Log commands — view and flush PM2 process logs."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from ..utils.pm2_backend import pm2_logs as backend_logs, pm2_flush as backend_flush
|
||||
|
||||
|
||||
def view_logs(name: str, lines: int = 20, as_json: bool = False) -> dict[str, Any]:
|
||||
"""View recent logs for a PM2 process.
|
||||
|
||||
Args:
|
||||
name: Process name or ID.
|
||||
lines: Number of log lines to retrieve.
|
||||
as_json: If True, return structured dict.
|
||||
|
||||
Returns:
|
||||
Dict with log content and metadata.
|
||||
"""
|
||||
result = backend_logs(name, lines=lines)
|
||||
|
||||
if as_json:
|
||||
return {
|
||||
"process": name,
|
||||
"lines_requested": lines,
|
||||
"success": result["success"],
|
||||
"stdout": result["stdout"],
|
||||
"stderr": result["stderr"],
|
||||
}
|
||||
|
||||
if result["success"]:
|
||||
output = result["stdout"].strip() or result["stderr"].strip()
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Logs for '{name}' (last {lines} lines)",
|
||||
"content": output if output else "(no log output)",
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"Failed to get logs for '{name}': {result['stderr'].strip()}",
|
||||
"content": "",
|
||||
}
|
||||
|
||||
|
||||
def flush_logs(name: str | None = None, as_json: bool = False) -> dict[str, Any]:
|
||||
"""Flush logs for a process or all processes.
|
||||
|
||||
Args:
|
||||
name: Process name or ID. If None, flushes all logs.
|
||||
as_json: If True, return structured dict.
|
||||
|
||||
Returns:
|
||||
Dict with success status and message.
|
||||
"""
|
||||
result = backend_flush(name)
|
||||
target = f"'{name}'" if name else "all processes"
|
||||
|
||||
if as_json:
|
||||
return {
|
||||
"action": "flush",
|
||||
"process": name or "all",
|
||||
"success": result["success"],
|
||||
"stdout": result["stdout"],
|
||||
"stderr": result["stderr"],
|
||||
}
|
||||
|
||||
return {
|
||||
"success": result["success"],
|
||||
"message": f"Flushed logs for {target}" if result["success"]
|
||||
else f"Failed to flush logs for {target}: {result['stderr'].strip()}",
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Process commands — list, describe, and metrics for PM2 processes."""
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from ..utils.pm2_backend import pm2_jlist, pm2_describe
|
||||
|
||||
|
||||
def list_processes(as_json: bool = False) -> str | list[dict[str, Any]]:
|
||||
"""List all PM2 processes.
|
||||
|
||||
Args:
|
||||
as_json: If True, return raw list of dicts. Otherwise, formatted string.
|
||||
|
||||
Returns:
|
||||
Formatted table string or list of process dicts.
|
||||
"""
|
||||
processes = pm2_jlist()
|
||||
|
||||
if as_json:
|
||||
return processes
|
||||
|
||||
if not processes:
|
||||
return "No PM2 processes running."
|
||||
|
||||
rows = []
|
||||
for p in processes:
|
||||
pm2_env = p.get("pm2_env", {})
|
||||
monit = p.get("monit", {})
|
||||
rows.append({
|
||||
"id": p.get("pm_id", "?"),
|
||||
"name": p.get("name", "unknown"),
|
||||
"status": pm2_env.get("status", "unknown"),
|
||||
"cpu": f"{monit.get('cpu', 0)}%",
|
||||
"memory": _format_bytes(monit.get("memory", 0)),
|
||||
"restarts": pm2_env.get("restart_time", 0),
|
||||
"uptime": _format_uptime(pm2_env.get("pm_uptime", 0)),
|
||||
})
|
||||
|
||||
return rows
|
||||
|
||||
|
||||
def describe_process(name: str, as_json: bool = False) -> str | dict[str, Any] | None:
|
||||
"""Get detailed info for a specific process.
|
||||
|
||||
Args:
|
||||
name: Process name or ID.
|
||||
as_json: If True, return raw dict.
|
||||
|
||||
Returns:
|
||||
Formatted info string, raw dict, or None if not found.
|
||||
"""
|
||||
info = pm2_describe(name)
|
||||
|
||||
if info is None:
|
||||
return None
|
||||
|
||||
if as_json:
|
||||
return info
|
||||
|
||||
pm2_env = info.get("pm2_env", {})
|
||||
monit = info.get("monit", {})
|
||||
|
||||
details = {
|
||||
"Name": info.get("name", "unknown"),
|
||||
"ID": str(info.get("pm_id", "?")),
|
||||
"Status": pm2_env.get("status", "unknown"),
|
||||
"Script": pm2_env.get("pm_exec_path", "N/A"),
|
||||
"CWD": pm2_env.get("pm_cwd", "N/A"),
|
||||
"Interpreter": pm2_env.get("exec_interpreter", "N/A"),
|
||||
"CPU": f"{monit.get('cpu', 0)}%",
|
||||
"Memory": _format_bytes(monit.get("memory", 0)),
|
||||
"Restarts": str(pm2_env.get("restart_time", 0)),
|
||||
"Uptime": _format_uptime(pm2_env.get("pm_uptime", 0)),
|
||||
"PID": str(info.get("pid", "N/A")),
|
||||
"Exec Mode": pm2_env.get("exec_mode", "N/A"),
|
||||
"Node Version": pm2_env.get("node_version", "N/A"),
|
||||
}
|
||||
|
||||
return details
|
||||
|
||||
|
||||
def get_metrics(as_json: bool = False) -> str | list[dict[str, Any]]:
|
||||
"""Get CPU/memory metrics for all processes.
|
||||
|
||||
Args:
|
||||
as_json: If True, return raw list of metric dicts.
|
||||
|
||||
Returns:
|
||||
Formatted metrics string or list of metric dicts.
|
||||
"""
|
||||
processes = pm2_jlist()
|
||||
|
||||
if as_json:
|
||||
metrics = []
|
||||
for p in processes:
|
||||
monit = p.get("monit", {})
|
||||
pm2_env = p.get("pm2_env", {})
|
||||
metrics.append({
|
||||
"name": p.get("name", "unknown"),
|
||||
"pm_id": p.get("pm_id", "?"),
|
||||
"status": pm2_env.get("status", "unknown"),
|
||||
"cpu": monit.get("cpu", 0),
|
||||
"memory": monit.get("memory", 0),
|
||||
"memory_human": _format_bytes(monit.get("memory", 0)),
|
||||
})
|
||||
return metrics
|
||||
|
||||
if not processes:
|
||||
return "No PM2 processes running."
|
||||
|
||||
rows = []
|
||||
for p in processes:
|
||||
monit = p.get("monit", {})
|
||||
pm2_env = p.get("pm2_env", {})
|
||||
rows.append({
|
||||
"id": p.get("pm_id", "?"),
|
||||
"name": p.get("name", "unknown"),
|
||||
"status": pm2_env.get("status", "unknown"),
|
||||
"cpu": f"{monit.get('cpu', 0)}%",
|
||||
"memory": _format_bytes(monit.get("memory", 0)),
|
||||
})
|
||||
|
||||
return rows
|
||||
|
||||
|
||||
def _format_bytes(b: int) -> str:
|
||||
"""Format bytes into human-readable string."""
|
||||
if b == 0:
|
||||
return "0 B"
|
||||
units = ["B", "KB", "MB", "GB"]
|
||||
i = 0
|
||||
val = float(b)
|
||||
while val >= 1024 and i < len(units) - 1:
|
||||
val /= 1024
|
||||
i += 1
|
||||
return f"{val:.1f} {units[i]}"
|
||||
|
||||
|
||||
def _format_uptime(pm_uptime: int) -> str:
|
||||
"""Format PM2 uptime timestamp to human-readable duration."""
|
||||
if pm_uptime == 0:
|
||||
return "N/A"
|
||||
|
||||
import time
|
||||
now_ms = int(time.time() * 1000)
|
||||
diff_s = max(0, (now_ms - pm_uptime)) // 1000
|
||||
|
||||
if diff_s < 60:
|
||||
return f"{diff_s}s"
|
||||
elif diff_s < 3600:
|
||||
return f"{diff_s // 60}m {diff_s % 60}s"
|
||||
elif diff_s < 86400:
|
||||
h = diff_s // 3600
|
||||
m = (diff_s % 3600) // 60
|
||||
return f"{h}h {m}m"
|
||||
else:
|
||||
d = diff_s // 86400
|
||||
h = (diff_s % 86400) // 3600
|
||||
return f"{d}d {h}h"
|
||||
@@ -0,0 +1,80 @@
|
||||
"""System commands — save, startup, version for PM2."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from ..utils.pm2_backend import pm2_save as backend_save
|
||||
from ..utils.pm2_backend import pm2_startup as backend_startup
|
||||
from ..utils.pm2_backend import pm2_version as backend_version
|
||||
|
||||
|
||||
def save(as_json: bool = False) -> dict[str, Any]:
|
||||
"""Save the current PM2 process list.
|
||||
|
||||
Args:
|
||||
as_json: If True, return structured dict.
|
||||
|
||||
Returns:
|
||||
Dict with success status and message.
|
||||
"""
|
||||
result = backend_save()
|
||||
|
||||
if as_json:
|
||||
return {
|
||||
"action": "save",
|
||||
"success": result["success"],
|
||||
"stdout": result["stdout"],
|
||||
"stderr": result["stderr"],
|
||||
}
|
||||
|
||||
return {
|
||||
"success": result["success"],
|
||||
"message": "PM2 process list saved" if result["success"]
|
||||
else f"Failed to save: {result['stderr'].strip()}",
|
||||
}
|
||||
|
||||
|
||||
def startup(as_json: bool = False) -> dict[str, Any]:
|
||||
"""Generate PM2 startup script.
|
||||
|
||||
Args:
|
||||
as_json: If True, return structured dict.
|
||||
|
||||
Returns:
|
||||
Dict with success status, message, and any instructions.
|
||||
"""
|
||||
result = backend_startup()
|
||||
|
||||
if as_json:
|
||||
return {
|
||||
"action": "startup",
|
||||
"success": result["success"],
|
||||
"stdout": result["stdout"],
|
||||
"stderr": result["stderr"],
|
||||
}
|
||||
|
||||
output = result["stdout"].strip()
|
||||
return {
|
||||
"success": result["success"],
|
||||
"message": "Startup script generated" if result["success"]
|
||||
else f"Startup command output:\n{output}",
|
||||
"instructions": output,
|
||||
}
|
||||
|
||||
|
||||
def version(as_json: bool = False) -> dict[str, Any] | str:
|
||||
"""Get PM2 version.
|
||||
|
||||
Args:
|
||||
as_json: If True, return structured dict.
|
||||
|
||||
Returns:
|
||||
Version string or dict.
|
||||
"""
|
||||
ver = backend_version()
|
||||
|
||||
if as_json:
|
||||
return {
|
||||
"version": ver,
|
||||
}
|
||||
|
||||
return ver
|
||||
@@ -0,0 +1,371 @@
|
||||
"""PM2 CLI — Click-based CLI with REPL mode for PM2 process management.
|
||||
|
||||
Entry point: cli-anything-pm2
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
|
||||
import click
|
||||
|
||||
from .core import processes, lifecycle, logs, system
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
def _output(data, as_json: bool):
|
||||
"""Print data as JSON or formatted text."""
|
||||
if as_json:
|
||||
if isinstance(data, str):
|
||||
click.echo(json.dumps({"result": data}, indent=2))
|
||||
else:
|
||||
click.echo(json.dumps(data, indent=2, default=str))
|
||||
else:
|
||||
if isinstance(data, str):
|
||||
click.echo(data)
|
||||
elif isinstance(data, dict):
|
||||
if "message" in data:
|
||||
prefix = "OK" if data.get("success", True) else "ERROR"
|
||||
click.echo(f"[{prefix}] {data['message']}")
|
||||
if data.get("content"):
|
||||
click.echo(data["content"])
|
||||
if data.get("instructions"):
|
||||
click.echo(data["instructions"])
|
||||
else:
|
||||
for k, v in data.items():
|
||||
click.echo(f" {k}: {v}")
|
||||
elif isinstance(data, list):
|
||||
if data and isinstance(data[0], dict):
|
||||
# Print as table
|
||||
if not data:
|
||||
return
|
||||
keys = list(data[0].keys())
|
||||
# Header
|
||||
header = " ".join(f"{k:<15}" for k in keys)
|
||||
click.echo(header)
|
||||
click.echo("-" * len(header))
|
||||
for row in data:
|
||||
line = " ".join(f"{str(row.get(k, '')):<15}" for k in keys)
|
||||
click.echo(line)
|
||||
else:
|
||||
for item in data:
|
||||
click.echo(str(item))
|
||||
|
||||
|
||||
# ── Main Group ───────────────────────────────────────────────────────────
|
||||
|
||||
@click.group(invoke_without_command=True)
|
||||
@click.option("--json", "as_json", is_flag=True, default=False,
|
||||
help="Output in JSON format.")
|
||||
@click.pass_context
|
||||
def main(ctx, as_json):
|
||||
"""CLI-Anything PM2 — Process management harness for PM2."""
|
||||
ctx.ensure_object(dict)
|
||||
ctx.obj["json"] = as_json
|
||||
|
||||
if ctx.invoked_subcommand is None:
|
||||
# Launch REPL mode
|
||||
_run_repl()
|
||||
|
||||
|
||||
# ── Process Group ────────────────────────────────────────────────────────
|
||||
|
||||
@main.group()
|
||||
@click.pass_context
|
||||
def process(ctx):
|
||||
"""Process info commands: list, describe, metrics."""
|
||||
pass
|
||||
|
||||
|
||||
@process.command("list")
|
||||
@click.pass_context
|
||||
def process_list(ctx):
|
||||
"""List all PM2 processes."""
|
||||
as_json = ctx.obj["json"]
|
||||
data = processes.list_processes(as_json=as_json)
|
||||
_output(data, as_json)
|
||||
|
||||
|
||||
@process.command("describe")
|
||||
@click.argument("name")
|
||||
@click.pass_context
|
||||
def process_describe(ctx, name):
|
||||
"""Show detailed info for a PM2 process."""
|
||||
as_json = ctx.obj["json"]
|
||||
data = processes.describe_process(name, as_json=as_json)
|
||||
if data is None:
|
||||
click.echo(f"Process '{name}' not found.", err=True)
|
||||
sys.exit(1)
|
||||
_output(data, as_json)
|
||||
|
||||
|
||||
@process.command("metrics")
|
||||
@click.pass_context
|
||||
def process_metrics(ctx):
|
||||
"""Show CPU/memory metrics for all processes."""
|
||||
as_json = ctx.obj["json"]
|
||||
data = processes.get_metrics(as_json=as_json)
|
||||
_output(data, as_json)
|
||||
|
||||
|
||||
# ── Lifecycle Group ──────────────────────────────────────────────────────
|
||||
|
||||
@main.group()
|
||||
@click.pass_context
|
||||
def lifecycle(ctx):
|
||||
"""Lifecycle commands: start, stop, restart, delete."""
|
||||
pass
|
||||
|
||||
|
||||
@lifecycle.command("restart")
|
||||
@click.argument("name")
|
||||
@click.pass_context
|
||||
def lifecycle_restart(ctx, name):
|
||||
"""Restart a PM2 process."""
|
||||
as_json = ctx.obj["json"]
|
||||
data = lifecycle_mod.restart_process(name, as_json=as_json)
|
||||
_output(data, as_json)
|
||||
|
||||
|
||||
@lifecycle.command("stop")
|
||||
@click.argument("name")
|
||||
@click.pass_context
|
||||
def lifecycle_stop(ctx, name):
|
||||
"""Stop a PM2 process."""
|
||||
as_json = ctx.obj["json"]
|
||||
data = lifecycle_mod.stop_process(name, as_json=as_json)
|
||||
_output(data, as_json)
|
||||
|
||||
|
||||
@lifecycle.command("start")
|
||||
@click.argument("script")
|
||||
@click.option("--name", default=None, help="Process name.")
|
||||
@click.pass_context
|
||||
def lifecycle_start(ctx, script, name):
|
||||
"""Start a new PM2 process."""
|
||||
as_json = ctx.obj["json"]
|
||||
data = lifecycle_mod.start_process(script, name=name, as_json=as_json)
|
||||
_output(data, as_json)
|
||||
|
||||
|
||||
@lifecycle.command("delete")
|
||||
@click.argument("name")
|
||||
@click.pass_context
|
||||
def lifecycle_delete(ctx, name):
|
||||
"""Delete a PM2 process."""
|
||||
as_json = ctx.obj["json"]
|
||||
data = lifecycle_mod.delete_process(name, as_json=as_json)
|
||||
_output(data, as_json)
|
||||
|
||||
|
||||
# Alias to avoid name collision with the click group
|
||||
lifecycle_mod = lifecycle_module = None
|
||||
|
||||
|
||||
def _init_lifecycle_mod():
|
||||
"""Lazy-init the lifecycle module reference."""
|
||||
global lifecycle_mod
|
||||
if lifecycle_mod is None:
|
||||
from .core import lifecycle as _lc
|
||||
lifecycle_mod = _lc
|
||||
|
||||
|
||||
# Patch lifecycle commands to use the module
|
||||
_init_lifecycle_mod()
|
||||
|
||||
|
||||
# ── Logs Group ───────────────────────────────────────────────────────────
|
||||
|
||||
@main.group("logs")
|
||||
@click.pass_context
|
||||
def logs_group(ctx):
|
||||
"""Log commands: view, flush."""
|
||||
pass
|
||||
|
||||
|
||||
@logs_group.command("view")
|
||||
@click.argument("name")
|
||||
@click.option("--lines", default=20, help="Number of log lines.")
|
||||
@click.pass_context
|
||||
def logs_view(ctx, name, lines):
|
||||
"""View recent logs for a PM2 process."""
|
||||
as_json = ctx.obj["json"]
|
||||
data = logs.view_logs(name, lines=lines, as_json=as_json)
|
||||
_output(data, as_json)
|
||||
|
||||
|
||||
@logs_group.command("flush")
|
||||
@click.argument("name", required=False, default=None)
|
||||
@click.pass_context
|
||||
def logs_flush(ctx, name):
|
||||
"""Flush logs for a process (or all if no name given)."""
|
||||
as_json = ctx.obj["json"]
|
||||
data = logs.flush_logs(name=name, as_json=as_json)
|
||||
_output(data, as_json)
|
||||
|
||||
|
||||
# ── System Group ─────────────────────────────────────────────────────────
|
||||
|
||||
@main.group("system")
|
||||
@click.pass_context
|
||||
def system_group(ctx):
|
||||
"""System commands: save, startup, version."""
|
||||
pass
|
||||
|
||||
|
||||
@system_group.command("save")
|
||||
@click.pass_context
|
||||
def system_save(ctx):
|
||||
"""Save current PM2 process list."""
|
||||
as_json = ctx.obj["json"]
|
||||
data = system.save(as_json=as_json)
|
||||
_output(data, as_json)
|
||||
|
||||
|
||||
@system_group.command("startup")
|
||||
@click.pass_context
|
||||
def system_startup(ctx):
|
||||
"""Generate PM2 startup script."""
|
||||
as_json = ctx.obj["json"]
|
||||
data = system.startup(as_json=as_json)
|
||||
_output(data, as_json)
|
||||
|
||||
|
||||
@system_group.command("version")
|
||||
@click.pass_context
|
||||
def system_version(ctx):
|
||||
"""Show PM2 version."""
|
||||
as_json = ctx.obj["json"]
|
||||
data = system.version(as_json=as_json)
|
||||
_output(data, as_json)
|
||||
|
||||
|
||||
# ── REPL Mode ────────────────────────────────────────────────────────────
|
||||
|
||||
def _run_repl():
|
||||
"""Launch the interactive REPL."""
|
||||
from .utils.repl_skin import ReplSkin
|
||||
|
||||
skin = ReplSkin("pm2", version="1.0.0")
|
||||
skin.print_banner()
|
||||
|
||||
session = skin.create_prompt_session()
|
||||
|
||||
# REPL command mapping
|
||||
repl_commands = {
|
||||
"process list": lambda args, j: _output(processes.list_processes(as_json=j), j),
|
||||
"process describe": lambda args, j: _output(
|
||||
processes.describe_process(args[0], as_json=j) if args else "Usage: process describe <name>", j
|
||||
),
|
||||
"process metrics": lambda args, j: _output(processes.get_metrics(as_json=j), j),
|
||||
"lifecycle restart": lambda args, j: _output(
|
||||
lifecycle_mod.restart_process(args[0], as_json=j) if args else "Usage: lifecycle restart <name>", j
|
||||
),
|
||||
"lifecycle stop": lambda args, j: _output(
|
||||
lifecycle_mod.stop_process(args[0], as_json=j) if args else "Usage: lifecycle stop <name>", j
|
||||
),
|
||||
"lifecycle start": lambda args, j: _repl_start(args, j),
|
||||
"lifecycle delete": lambda args, j: _output(
|
||||
lifecycle_mod.delete_process(args[0], as_json=j) if args else "Usage: lifecycle delete <name>", j
|
||||
),
|
||||
"logs view": lambda args, j: _repl_logs_view(args, j),
|
||||
"logs flush": lambda args, j: _output(logs.flush_logs(name=args[0] if args else None, as_json=j), j),
|
||||
"system save": lambda args, j: _output(system.save(as_json=j), j),
|
||||
"system startup": lambda args, j: _output(system.startup(as_json=j), j),
|
||||
"system version": lambda args, j: _output(system.version(as_json=j), j),
|
||||
}
|
||||
|
||||
help_commands = {
|
||||
"process list": "List all PM2 processes",
|
||||
"process describe N": "Detailed info for process N",
|
||||
"process metrics": "CPU/memory metrics for all processes",
|
||||
"lifecycle start S": "Start script S [--name N]",
|
||||
"lifecycle stop N": "Stop process N",
|
||||
"lifecycle restart N":"Restart process N",
|
||||
"lifecycle delete N": "Delete process N",
|
||||
"logs view N": "View logs for process N [--lines 50]",
|
||||
"logs flush [N]": "Flush logs (optionally for process N)",
|
||||
"system save": "Save PM2 process list",
|
||||
"system startup": "Generate startup script",
|
||||
"system version": "Show PM2 version",
|
||||
"help": "Show this help",
|
||||
"quit / exit": "Exit the REPL",
|
||||
}
|
||||
|
||||
while True:
|
||||
try:
|
||||
user_input = skin.get_input(session)
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
skin.print_goodbye()
|
||||
break
|
||||
|
||||
if not user_input:
|
||||
continue
|
||||
|
||||
raw = user_input.strip()
|
||||
|
||||
if raw in ("quit", "exit", "q"):
|
||||
skin.print_goodbye()
|
||||
break
|
||||
|
||||
if raw == "help":
|
||||
skin.help(help_commands)
|
||||
continue
|
||||
|
||||
# Check for --json flag in input
|
||||
as_json = False
|
||||
if "--json" in raw:
|
||||
as_json = True
|
||||
raw = raw.replace("--json", "").strip()
|
||||
|
||||
# Match command
|
||||
matched = False
|
||||
for cmd_key, handler in repl_commands.items():
|
||||
if raw.startswith(cmd_key):
|
||||
remainder = raw[len(cmd_key):].strip()
|
||||
args = remainder.split() if remainder else []
|
||||
try:
|
||||
handler(args, as_json)
|
||||
except Exception as e:
|
||||
skin.error(str(e))
|
||||
matched = True
|
||||
break
|
||||
|
||||
if not matched:
|
||||
skin.warning(f"Unknown command: {raw}")
|
||||
skin.hint("Type 'help' for available commands.")
|
||||
|
||||
|
||||
def _repl_start(args, as_json):
|
||||
"""Handle 'lifecycle start' in REPL with --name parsing."""
|
||||
if not args:
|
||||
click.echo("Usage: lifecycle start <script> [--name <name>]")
|
||||
return
|
||||
script = args[0]
|
||||
name = None
|
||||
if "--name" in args:
|
||||
idx = args.index("--name")
|
||||
if idx + 1 < len(args):
|
||||
name = args[idx + 1]
|
||||
_output(lifecycle_mod.start_process(script, name=name, as_json=as_json), as_json)
|
||||
|
||||
|
||||
def _repl_logs_view(args, as_json):
|
||||
"""Handle 'logs view' in REPL with --lines parsing."""
|
||||
if not args:
|
||||
click.echo("Usage: logs view <name> [--lines N]")
|
||||
return
|
||||
name = args[0]
|
||||
lines = 20
|
||||
if "--lines" in args:
|
||||
idx = args.index("--lines")
|
||||
if idx + 1 < len(args):
|
||||
try:
|
||||
lines = int(args[idx + 1])
|
||||
except ValueError:
|
||||
pass
|
||||
_output(logs.view_logs(name, lines=lines, as_json=as_json), as_json)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,111 @@
|
||||
---
|
||||
name: >-
|
||||
cli-anything-pm2
|
||||
description: >-
|
||||
Command-line interface for PM2 - A stateless CLI for Node.js process management via the PM2 CLI. List, start, stop, restart processes, view logs, and manage system configuration.
|
||||
---
|
||||
|
||||
# cli-anything-pm2
|
||||
|
||||
A stateless command-line interface for PM2 process management.
|
||||
Communicates via the PM2 CLI subprocess. No local state or session.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
**Prerequisites:**
|
||||
- Python 3.10+
|
||||
- PM2 installed globally (`npm install -g pm2`)
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Commands
|
||||
|
||||
```bash
|
||||
# Show help
|
||||
cli-anything-pm2 --help
|
||||
|
||||
# Start interactive REPL mode
|
||||
cli-anything-pm2
|
||||
|
||||
# Run with JSON output (for agent consumption)
|
||||
cli-anything-pm2 --json process list
|
||||
cli-anything-pm2 --json system version
|
||||
```
|
||||
|
||||
### REPL Mode
|
||||
|
||||
When invoked without a subcommand, the CLI enters an interactive REPL session:
|
||||
|
||||
```bash
|
||||
cli-anything-pm2
|
||||
# Enter commands interactively with tab-completion and history
|
||||
```
|
||||
|
||||
## Command Groups
|
||||
|
||||
### process
|
||||
Process inspection commands.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `list` | List all PM2 processes |
|
||||
| `describe <name>` | Get detailed info for a process |
|
||||
| `metrics` | Get metrics for all processes |
|
||||
|
||||
### lifecycle
|
||||
Process lifecycle commands.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `start <script> --name <name>` | Start a new process |
|
||||
| `stop <name>` | Stop a process |
|
||||
| `restart <name>` | Restart a process |
|
||||
| `delete <name>` | Delete a process |
|
||||
|
||||
### logs
|
||||
Log management commands.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `view <name> --lines 50` | View recent logs |
|
||||
| `flush [name]` | Flush logs |
|
||||
|
||||
### system
|
||||
System-level commands.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `save` | Save current process list |
|
||||
| `startup` | Generate startup script |
|
||||
| `version` | Get PM2 version |
|
||||
|
||||
## Output Formats
|
||||
|
||||
All commands support dual output modes:
|
||||
|
||||
- **Human-readable** (default): Tables, colors, formatted text
|
||||
- **Machine-readable** (`--json` flag): Structured JSON for agent consumption
|
||||
|
||||
```bash
|
||||
# Human output
|
||||
cli-anything-pm2 process list
|
||||
|
||||
# JSON output for agents
|
||||
cli-anything-pm2 --json process list
|
||||
```
|
||||
|
||||
## For AI Agents
|
||||
|
||||
When using this CLI programmatically:
|
||||
|
||||
1. **Always use `--json` flag** for parseable output
|
||||
2. **Check return codes** - 0 for success, non-zero for errors
|
||||
3. **Parse stderr** for error messages on failure
|
||||
|
||||
## Version
|
||||
|
||||
1.0.0
|
||||
@@ -0,0 +1,84 @@
|
||||
# PM2 CLI Tests
|
||||
|
||||
Test suites for the PM2 CLI-Anything harness.
|
||||
|
||||
## Test Files
|
||||
|
||||
| File | Type | Count | Dependencies |
|
||||
|-------------------|--------|-------|----------------------|
|
||||
| `test_core.py` | Unit | 28 | None (mocked subprocess) |
|
||||
| `test_full_e2e.py`| E2E | 9 | pm2 installed, CLI on PATH |
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
cd agent-harness
|
||||
pip install -e .
|
||||
|
||||
# All tests
|
||||
python -m pytest cli_anything/pm2/tests/ -v
|
||||
|
||||
# Unit tests only (no pm2 needed)
|
||||
python -m pytest cli_anything/pm2/tests/test_core.py -v
|
||||
|
||||
# E2E tests only (requires pm2)
|
||||
python -m pytest cli_anything/pm2/tests/test_full_e2e.py -v
|
||||
```
|
||||
|
||||
## Test Plan
|
||||
|
||||
### Unit Tests (test_core.py)
|
||||
|
||||
- [x] `_find_pm2()` locates binary via shutil.which
|
||||
- [x] `_find_pm2()` raises RuntimeError when pm2 missing
|
||||
- [x] `run_pm2()` returns success dict on exit code 0
|
||||
- [x] `run_pm2()` returns failure dict on exit code 1
|
||||
- [x] `run_pm2()` parses JSON stdout with capture_json=True
|
||||
- [x] `run_pm2()` extracts JSON from stdout with non-JSON preamble
|
||||
- [x] `run_pm2()` handles subprocess timeout
|
||||
- [x] `run_pm2()` handles FileNotFoundError for missing binary
|
||||
- [x] `list_processes()` JSON mode returns raw list
|
||||
- [x] `list_processes()` human mode returns formatted rows
|
||||
- [x] `list_processes()` returns message when no processes
|
||||
- [x] `describe_process()` returns details for known process
|
||||
- [x] `describe_process()` returns None for unknown process
|
||||
- [x] `get_metrics()` JSON mode returns metric dicts
|
||||
- [x] `restart_process()` success message
|
||||
- [x] `stop_process()` failure message
|
||||
- [x] `start_process()` with name in JSON mode
|
||||
- [x] `view_logs()` success with content
|
||||
- [x] `flush_logs()` all processes
|
||||
- [x] `version()` JSON mode
|
||||
- [x] `save()` success message
|
||||
- [x] `_output()` JSON string wrapping
|
||||
- [x] `_output()` JSON dict passthrough
|
||||
- [x] `_output()` human string echo
|
||||
- [x] `_output()` human dict with [OK] prefix
|
||||
- [x] `_output()` human dict with [ERROR] prefix
|
||||
- [x] `_format_bytes()` zero bytes
|
||||
- [x] `_format_bytes()` megabytes
|
||||
|
||||
### E2E Tests (test_full_e2e.py)
|
||||
|
||||
- [x] `--json process list` returns valid JSON array
|
||||
- [x] `process list` exits with code 0
|
||||
- [x] `--json process describe <name>` returns valid JSON dict
|
||||
- [x] `process describe __nonexistent__` exits non-zero
|
||||
- [x] `--json system version` returns JSON with version key
|
||||
- [x] `system version` returns version string with digits
|
||||
- [x] `--help` exits with code 0
|
||||
- [x] `process --help` shows list and describe subcommands
|
||||
- [x] `--json process metrics` returns JSON list
|
||||
|
||||
## Last Run Results
|
||||
|
||||
```
|
||||
Platform: darwin (macOS)
|
||||
Python: 3.14.3
|
||||
pytest: 9.0.2
|
||||
Date: 2026-03-23
|
||||
|
||||
37 passed in 1.53s
|
||||
- test_core.py: 28 passed
|
||||
- test_full_e2e.py: 9 passed
|
||||
```
|
||||
@@ -0,0 +1,356 @@
|
||||
"""Unit tests for the PM2 CLI-Anything harness.
|
||||
|
||||
All subprocess calls are mocked -- no pm2 binary required.
|
||||
Covers: pm2_backend, core/processes, core/lifecycle, core/logs, core/system,
|
||||
pm2_cli output formatting, and error handling.
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from unittest import mock
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_run_result(stdout="", stderr="", returncode=0):
|
||||
"""Build a mock subprocess.CompletedProcess."""
|
||||
r = MagicMock(spec=subprocess.CompletedProcess)
|
||||
r.stdout = stdout
|
||||
r.stderr = stderr
|
||||
r.returncode = returncode
|
||||
return r
|
||||
|
||||
|
||||
FAKE_JLIST = json.dumps([
|
||||
{
|
||||
"pm_id": 0,
|
||||
"name": "seaclip-dev",
|
||||
"pid": 12345,
|
||||
"monit": {"cpu": 2.5, "memory": 52428800},
|
||||
"pm2_env": {
|
||||
"status": "online",
|
||||
"restart_time": 3,
|
||||
"pm_uptime": 1700000000000,
|
||||
"pm_exec_path": "/app/index.js",
|
||||
"pm_cwd": "/app",
|
||||
"exec_interpreter": "node",
|
||||
"exec_mode": "fork_mode",
|
||||
"node_version": "20.11.0",
|
||||
},
|
||||
},
|
||||
{
|
||||
"pm_id": 1,
|
||||
"name": "hub-dashboard",
|
||||
"pid": 12346,
|
||||
"monit": {"cpu": 0.1, "memory": 10485760},
|
||||
"pm2_env": {
|
||||
"status": "stopped",
|
||||
"restart_time": 0,
|
||||
"pm_uptime": 0,
|
||||
"pm_exec_path": "/dash/server.js",
|
||||
"pm_cwd": "/dash",
|
||||
"exec_interpreter": "node",
|
||||
"exec_mode": "fork_mode",
|
||||
"node_version": "20.11.0",
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 1. pm2_backend._find_pm2
|
||||
# ===========================================================================
|
||||
|
||||
class TestFindPm2:
|
||||
"""Tests for pm2 binary discovery."""
|
||||
|
||||
@patch("shutil.which", return_value="/opt/homebrew/bin/pm2")
|
||||
def test_find_pm2_found(self, mock_which):
|
||||
from cli_anything.pm2.utils.pm2_backend import _find_pm2
|
||||
assert _find_pm2() == "/opt/homebrew/bin/pm2"
|
||||
|
||||
@patch("shutil.which", return_value=None)
|
||||
def test_find_pm2_not_found_raises(self, mock_which):
|
||||
from cli_anything.pm2.utils.pm2_backend import _find_pm2
|
||||
with pytest.raises(RuntimeError, match="pm2 not found"):
|
||||
_find_pm2()
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 2. pm2_backend.run_pm2
|
||||
# ===========================================================================
|
||||
|
||||
class TestRunPm2:
|
||||
"""Tests for the core run_pm2 subprocess wrapper."""
|
||||
|
||||
@patch("cli_anything.pm2.utils.pm2_backend._get_pm2", return_value="/usr/bin/pm2")
|
||||
@patch("subprocess.run")
|
||||
def test_run_pm2_success(self, mock_run, mock_get):
|
||||
mock_run.return_value = _make_run_result(stdout="OK\n", returncode=0)
|
||||
from cli_anything.pm2.utils.pm2_backend import run_pm2
|
||||
result = run_pm2("save")
|
||||
assert result["success"] is True
|
||||
assert result["returncode"] == 0
|
||||
assert "OK" in result["stdout"]
|
||||
|
||||
@patch("cli_anything.pm2.utils.pm2_backend._get_pm2", return_value="/usr/bin/pm2")
|
||||
@patch("subprocess.run")
|
||||
def test_run_pm2_failure(self, mock_run, mock_get):
|
||||
mock_run.return_value = _make_run_result(stderr="error", returncode=1)
|
||||
from cli_anything.pm2.utils.pm2_backend import run_pm2
|
||||
result = run_pm2("restart", "ghost")
|
||||
assert result["success"] is False
|
||||
assert result["returncode"] == 1
|
||||
|
||||
@patch("cli_anything.pm2.utils.pm2_backend._get_pm2", return_value="/usr/bin/pm2")
|
||||
@patch("subprocess.run")
|
||||
def test_run_pm2_json_parsing(self, mock_run, mock_get):
|
||||
mock_run.return_value = _make_run_result(stdout=FAKE_JLIST, returncode=0)
|
||||
from cli_anything.pm2.utils.pm2_backend import run_pm2
|
||||
result = run_pm2("jlist", capture_json=True)
|
||||
assert result["success"] is True
|
||||
assert isinstance(result["data"], list)
|
||||
assert len(result["data"]) == 2
|
||||
assert result["data"][0]["name"] == "seaclip-dev"
|
||||
|
||||
@patch("cli_anything.pm2.utils.pm2_backend._get_pm2", return_value="/usr/bin/pm2")
|
||||
@patch("subprocess.run")
|
||||
def test_run_pm2_json_with_preamble(self, mock_run, mock_get):
|
||||
"""JSON extraction works even with non-JSON text before the array."""
|
||||
preamble = "PM2 info line\n" + FAKE_JLIST
|
||||
mock_run.return_value = _make_run_result(stdout=preamble, returncode=0)
|
||||
from cli_anything.pm2.utils.pm2_backend import run_pm2
|
||||
result = run_pm2("jlist", capture_json=True)
|
||||
assert result["data"] is not None
|
||||
assert isinstance(result["data"], list)
|
||||
|
||||
@patch("cli_anything.pm2.utils.pm2_backend._get_pm2", return_value="/usr/bin/pm2")
|
||||
@patch("subprocess.run", side_effect=subprocess.TimeoutExpired(cmd="pm2", timeout=30))
|
||||
def test_run_pm2_timeout(self, mock_run, mock_get):
|
||||
from cli_anything.pm2.utils.pm2_backend import run_pm2
|
||||
result = run_pm2("jlist", timeout=30)
|
||||
assert result["success"] is False
|
||||
assert "timed out" in result["stderr"]
|
||||
|
||||
@patch("cli_anything.pm2.utils.pm2_backend._get_pm2", return_value="/nonexistent/pm2")
|
||||
@patch("subprocess.run", side_effect=FileNotFoundError())
|
||||
def test_run_pm2_binary_missing(self, mock_run, mock_get):
|
||||
from cli_anything.pm2.utils.pm2_backend import run_pm2
|
||||
result = run_pm2("jlist")
|
||||
assert result["success"] is False
|
||||
assert "not found" in result["stderr"]
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 3. core/processes.py
|
||||
# ===========================================================================
|
||||
|
||||
class TestProcesses:
|
||||
"""Tests for process listing, describe, and metrics."""
|
||||
|
||||
@patch("cli_anything.pm2.core.processes.pm2_jlist")
|
||||
def test_list_processes_json(self, mock_jlist):
|
||||
mock_jlist.return_value = json.loads(FAKE_JLIST)
|
||||
from cli_anything.pm2.core.processes import list_processes
|
||||
result = list_processes(as_json=True)
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 2
|
||||
|
||||
@patch("cli_anything.pm2.core.processes.pm2_jlist")
|
||||
def test_list_processes_human(self, mock_jlist):
|
||||
mock_jlist.return_value = json.loads(FAKE_JLIST)
|
||||
from cli_anything.pm2.core.processes import list_processes
|
||||
result = list_processes(as_json=False)
|
||||
assert isinstance(result, list)
|
||||
assert result[0]["name"] == "seaclip-dev"
|
||||
assert result[0]["status"] == "online"
|
||||
|
||||
@patch("cli_anything.pm2.core.processes.pm2_jlist")
|
||||
def test_list_processes_empty(self, mock_jlist):
|
||||
mock_jlist.return_value = []
|
||||
from cli_anything.pm2.core.processes import list_processes
|
||||
result = list_processes(as_json=False)
|
||||
assert result == "No PM2 processes running."
|
||||
|
||||
@patch("cli_anything.pm2.core.processes.pm2_describe")
|
||||
def test_describe_process_found(self, mock_desc):
|
||||
mock_desc.return_value = json.loads(FAKE_JLIST)[0]
|
||||
from cli_anything.pm2.core.processes import describe_process
|
||||
result = describe_process("seaclip-dev", as_json=False)
|
||||
assert isinstance(result, dict)
|
||||
assert result["Name"] == "seaclip-dev"
|
||||
|
||||
@patch("cli_anything.pm2.core.processes.pm2_describe")
|
||||
def test_describe_process_not_found(self, mock_desc):
|
||||
mock_desc.return_value = None
|
||||
from cli_anything.pm2.core.processes import describe_process
|
||||
result = describe_process("ghost", as_json=False)
|
||||
assert result is None
|
||||
|
||||
@patch("cli_anything.pm2.core.processes.pm2_jlist")
|
||||
def test_get_metrics_json(self, mock_jlist):
|
||||
mock_jlist.return_value = json.loads(FAKE_JLIST)
|
||||
from cli_anything.pm2.core.processes import get_metrics
|
||||
result = get_metrics(as_json=True)
|
||||
assert isinstance(result, list)
|
||||
assert result[0]["cpu"] == 2.5
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 4. core/lifecycle.py
|
||||
# ===========================================================================
|
||||
|
||||
class TestLifecycle:
|
||||
"""Tests for lifecycle commands."""
|
||||
|
||||
@patch("cli_anything.pm2.core.lifecycle.pm2_action")
|
||||
def test_restart_success(self, mock_action):
|
||||
mock_action.return_value = {
|
||||
"success": True, "returncode": 0,
|
||||
"stdout": "restarted", "stderr": "",
|
||||
}
|
||||
from cli_anything.pm2.core.lifecycle import restart_process
|
||||
result = restart_process("seaclip-dev", as_json=False)
|
||||
assert result["success"] is True
|
||||
assert "Restarted" in result["message"]
|
||||
|
||||
@patch("cli_anything.pm2.core.lifecycle.pm2_action")
|
||||
def test_stop_failure(self, mock_action):
|
||||
mock_action.return_value = {
|
||||
"success": False, "returncode": 1,
|
||||
"stdout": "", "stderr": "process not found",
|
||||
}
|
||||
from cli_anything.pm2.core.lifecycle import stop_process
|
||||
result = stop_process("ghost", as_json=False)
|
||||
assert result["success"] is False
|
||||
assert "Failed" in result["message"]
|
||||
|
||||
@patch("cli_anything.pm2.core.lifecycle.backend_start")
|
||||
def test_start_with_name(self, mock_start):
|
||||
mock_start.return_value = {
|
||||
"success": True, "returncode": 0,
|
||||
"stdout": "started", "stderr": "",
|
||||
}
|
||||
from cli_anything.pm2.core.lifecycle import start_process
|
||||
result = start_process("/app/index.js", name="my-app", as_json=True)
|
||||
assert result["success"] is True
|
||||
assert result["name"] == "my-app"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 5. core/logs.py
|
||||
# ===========================================================================
|
||||
|
||||
class TestLogs:
|
||||
"""Tests for log commands."""
|
||||
|
||||
@patch("cli_anything.pm2.core.logs.backend_logs")
|
||||
def test_view_logs_success(self, mock_logs):
|
||||
mock_logs.return_value = {
|
||||
"success": True, "returncode": 0,
|
||||
"stdout": "line1\nline2\n", "stderr": "",
|
||||
}
|
||||
from cli_anything.pm2.core.logs import view_logs
|
||||
result = view_logs("seaclip-dev", lines=20, as_json=False)
|
||||
assert result["success"] is True
|
||||
assert "line1" in result["content"]
|
||||
|
||||
@patch("cli_anything.pm2.core.logs.backend_flush")
|
||||
def test_flush_all(self, mock_flush):
|
||||
mock_flush.return_value = {
|
||||
"success": True, "returncode": 0,
|
||||
"stdout": "flushed", "stderr": "",
|
||||
}
|
||||
from cli_anything.pm2.core.logs import flush_logs
|
||||
result = flush_logs(name=None, as_json=False)
|
||||
assert result["success"] is True
|
||||
assert "all processes" in result["message"]
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 6. core/system.py
|
||||
# ===========================================================================
|
||||
|
||||
class TestSystem:
|
||||
"""Tests for system commands."""
|
||||
|
||||
@patch("cli_anything.pm2.core.system.backend_version")
|
||||
def test_version_json(self, mock_ver):
|
||||
mock_ver.return_value = "5.3.0"
|
||||
from cli_anything.pm2.core.system import version
|
||||
result = version(as_json=True)
|
||||
assert result["version"] == "5.3.0"
|
||||
|
||||
@patch("cli_anything.pm2.core.system.backend_save")
|
||||
def test_save_success(self, mock_save):
|
||||
mock_save.return_value = {
|
||||
"success": True, "returncode": 0,
|
||||
"stdout": "saved", "stderr": "",
|
||||
}
|
||||
from cli_anything.pm2.core.system import save
|
||||
result = save(as_json=False)
|
||||
assert result["success"] is True
|
||||
assert "saved" in result["message"].lower()
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 7. Output formatting (_output helper)
|
||||
# ===========================================================================
|
||||
|
||||
class TestOutputFormatting:
|
||||
"""Tests for the _output helper in pm2_cli."""
|
||||
|
||||
def test_output_json_string(self, capsys):
|
||||
from cli_anything.pm2.pm2_cli import _output
|
||||
_output("hello", as_json=True)
|
||||
captured = capsys.readouterr()
|
||||
parsed = json.loads(captured.out)
|
||||
assert parsed["result"] == "hello"
|
||||
|
||||
def test_output_json_dict(self, capsys):
|
||||
from cli_anything.pm2.pm2_cli import _output
|
||||
_output({"key": "val"}, as_json=True)
|
||||
captured = capsys.readouterr()
|
||||
parsed = json.loads(captured.out)
|
||||
assert parsed["key"] == "val"
|
||||
|
||||
def test_output_human_string(self, capsys):
|
||||
from cli_anything.pm2.pm2_cli import _output
|
||||
_output("hello world", as_json=False)
|
||||
captured = capsys.readouterr()
|
||||
assert "hello world" in captured.out
|
||||
|
||||
def test_output_human_dict_with_message(self, capsys):
|
||||
from cli_anything.pm2.pm2_cli import _output
|
||||
_output({"success": True, "message": "Done"}, as_json=False)
|
||||
captured = capsys.readouterr()
|
||||
assert "[OK] Done" in captured.out
|
||||
|
||||
def test_output_human_error_message(self, capsys):
|
||||
from cli_anything.pm2.pm2_cli import _output
|
||||
_output({"success": False, "message": "Oops"}, as_json=False)
|
||||
captured = capsys.readouterr()
|
||||
assert "[ERROR] Oops" in captured.out
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 8. Utility: _format_bytes
|
||||
# ===========================================================================
|
||||
|
||||
class TestFormatBytes:
|
||||
"""Tests for the byte formatting utility."""
|
||||
|
||||
def test_zero_bytes(self):
|
||||
from cli_anything.pm2.core.processes import _format_bytes
|
||||
assert _format_bytes(0) == "0 B"
|
||||
|
||||
def test_megabytes(self):
|
||||
from cli_anything.pm2.core.processes import _format_bytes
|
||||
result = _format_bytes(52428800) # 50 MB
|
||||
assert "MB" in result
|
||||
assert "50.0" in result
|
||||
@@ -0,0 +1,157 @@
|
||||
"""End-to-end tests for the PM2 CLI-Anything harness.
|
||||
|
||||
These tests call the REAL pm2 binary and the installed cli-anything-pm2
|
||||
CLI. They require:
|
||||
- pm2 installed globally (npm install -g pm2)
|
||||
- cli-anything-pm2 installed (pip install -e .)
|
||||
- PM2 daemon running with at least one process
|
||||
|
||||
Skip gracefully if pm2 is not available.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _resolve_cli() -> str:
|
||||
"""Resolve the cli-anything-pm2 binary path.
|
||||
|
||||
Checks: shutil.which, then common venv/bin locations.
|
||||
"""
|
||||
binary = shutil.which("cli-anything-pm2")
|
||||
if binary:
|
||||
return binary
|
||||
|
||||
# Try the venv bin dir that matches the running Python
|
||||
venv_bin = os.path.join(os.path.dirname(sys.executable), "cli-anything-pm2")
|
||||
if os.path.isfile(venv_bin):
|
||||
return venv_bin
|
||||
|
||||
pytest.skip("cli-anything-pm2 binary not found on PATH")
|
||||
|
||||
|
||||
def _has_pm2() -> bool:
|
||||
"""Check whether pm2 is installed."""
|
||||
return shutil.which("pm2") is not None
|
||||
|
||||
|
||||
def _run_cli(*args: str, timeout: int = 30) -> subprocess.CompletedProcess:
|
||||
"""Run cli-anything-pm2 with the given arguments."""
|
||||
cli = _resolve_cli()
|
||||
cmd = [cli, *args]
|
||||
return subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
|
||||
# Skip entire module if pm2 is not installed
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not _has_pm2(),
|
||||
reason="pm2 is not installed -- skipping E2E tests",
|
||||
)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# E2E Tests
|
||||
# ===========================================================================
|
||||
|
||||
class TestProcessListE2E:
|
||||
"""E2E tests for the process list command."""
|
||||
|
||||
def test_process_list_json_returns_valid_json(self):
|
||||
"""cli-anything-pm2 --json process list outputs parseable JSON."""
|
||||
result = _run_cli("--json", "process", "list")
|
||||
assert result.returncode == 0, f"stderr: {result.stderr}"
|
||||
data = json.loads(result.stdout)
|
||||
assert isinstance(data, list)
|
||||
|
||||
def test_process_list_human_returns_zero(self):
|
||||
"""cli-anything-pm2 process list exits 0."""
|
||||
result = _run_cli("process", "list")
|
||||
assert result.returncode == 0
|
||||
|
||||
|
||||
class TestProcessDescribeE2E:
|
||||
"""E2E tests for process describe."""
|
||||
|
||||
def test_describe_existing_process_json(self):
|
||||
"""Describe a known process and get valid JSON."""
|
||||
# First get list to find an actual process name
|
||||
list_result = _run_cli("--json", "process", "list")
|
||||
if list_result.returncode != 0:
|
||||
pytest.skip("Could not list processes")
|
||||
processes = json.loads(list_result.stdout)
|
||||
if not processes:
|
||||
pytest.skip("No PM2 processes running")
|
||||
|
||||
# Pick first process
|
||||
name = processes[0].get("name") or str(processes[0].get("pm_id", 0))
|
||||
result = _run_cli("--json", "process", "describe", name)
|
||||
assert result.returncode == 0
|
||||
data = json.loads(result.stdout)
|
||||
assert isinstance(data, dict)
|
||||
|
||||
def test_describe_nonexistent_process(self):
|
||||
"""Describe a process that does not exist exits with code 1."""
|
||||
result = _run_cli("--json", "process", "describe", "__nonexistent_process_xyz__")
|
||||
assert result.returncode != 0
|
||||
|
||||
|
||||
class TestSystemE2E:
|
||||
"""E2E tests for system commands."""
|
||||
|
||||
def test_system_version_json(self):
|
||||
"""cli-anything-pm2 --json system version returns valid JSON with version key."""
|
||||
result = _run_cli("--json", "system", "version")
|
||||
assert result.returncode == 0, f"stderr: {result.stderr}"
|
||||
data = json.loads(result.stdout)
|
||||
assert "version" in data
|
||||
# PM2 version is a semver string like "5.3.0"
|
||||
assert len(data["version"]) > 0
|
||||
|
||||
def test_system_version_human(self):
|
||||
"""cli-anything-pm2 system version returns a version string."""
|
||||
result = _run_cli("system", "version")
|
||||
assert result.returncode == 0
|
||||
# Should contain at least a digit (version number)
|
||||
assert any(c.isdigit() for c in result.stdout)
|
||||
|
||||
|
||||
class TestHelpE2E:
|
||||
"""E2E tests for help output."""
|
||||
|
||||
def test_help_flag_exits_zero(self):
|
||||
"""cli-anything-pm2 --help exits with code 0."""
|
||||
result = _run_cli("--help")
|
||||
assert result.returncode == 0
|
||||
assert "CLI-Anything PM2" in result.stdout or "Usage" in result.stdout
|
||||
|
||||
def test_process_help(self):
|
||||
"""cli-anything-pm2 process --help shows subcommands."""
|
||||
result = _run_cli("process", "--help")
|
||||
assert result.returncode == 0
|
||||
assert "list" in result.stdout
|
||||
assert "describe" in result.stdout
|
||||
|
||||
|
||||
class TestProcessMetricsE2E:
|
||||
"""E2E test for process metrics."""
|
||||
|
||||
def test_metrics_json_returns_list(self):
|
||||
"""cli-anything-pm2 --json process metrics returns a JSON list."""
|
||||
result = _run_cli("--json", "process", "metrics")
|
||||
assert result.returncode == 0
|
||||
data = json.loads(result.stdout)
|
||||
assert isinstance(data, list)
|
||||
@@ -0,0 +1,243 @@
|
||||
"""PM2 Backend — subprocess wrapper for all PM2 CLI commands.
|
||||
|
||||
All PM2 interactions go through this module. It finds the pm2 binary,
|
||||
runs commands via subprocess.run(), and returns structured results.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from typing import Any
|
||||
|
||||
# Common directories where pm2 may be installed (Homebrew, global npm, system).
|
||||
_EXTRA_PATH_DIRS = ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin"]
|
||||
|
||||
|
||||
def _augmented_path(base_path: str | None = None) -> str:
|
||||
"""Return PATH string with _EXTRA_PATH_DIRS prepended if missing."""
|
||||
path = base_path if base_path is not None else os.environ.get("PATH", "")
|
||||
for p in _EXTRA_PATH_DIRS:
|
||||
if p not in path:
|
||||
path = f"{p}:{path}"
|
||||
return path
|
||||
|
||||
|
||||
def _find_pm2() -> str:
|
||||
"""Locate the pm2 binary on the system.
|
||||
|
||||
Checks common Homebrew and global npm paths in addition to PATH.
|
||||
|
||||
Returns:
|
||||
Absolute path to the pm2 binary.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If pm2 is not found.
|
||||
"""
|
||||
pm2_path = shutil.which("pm2", path=_augmented_path())
|
||||
if pm2_path is None:
|
||||
raise RuntimeError(
|
||||
"pm2 not found on this system. "
|
||||
"Install it with: npm install -g pm2"
|
||||
)
|
||||
return pm2_path
|
||||
|
||||
|
||||
# Cache the pm2 path at module level
|
||||
_PM2_BIN: str | None = None
|
||||
|
||||
|
||||
def _get_pm2() -> str:
|
||||
"""Get cached pm2 binary path."""
|
||||
global _PM2_BIN
|
||||
if _PM2_BIN is None:
|
||||
_PM2_BIN = _find_pm2()
|
||||
return _PM2_BIN
|
||||
|
||||
|
||||
def _build_env() -> dict[str, str]:
|
||||
"""Build environment dict with proper PATH for subprocess."""
|
||||
env = os.environ.copy()
|
||||
env["PATH"] = _augmented_path(env.get("PATH", ""))
|
||||
return env
|
||||
|
||||
|
||||
def run_pm2(
|
||||
*args: str,
|
||||
capture_json: bool = False,
|
||||
timeout: int = 30,
|
||||
) -> dict[str, Any]:
|
||||
"""Run a pm2 command and return the result.
|
||||
|
||||
Args:
|
||||
*args: Arguments to pass to pm2 (e.g., "jlist", "restart", "myapp").
|
||||
capture_json: If True, attempt to parse stdout as JSON.
|
||||
timeout: Command timeout in seconds.
|
||||
|
||||
Returns:
|
||||
Dict with keys:
|
||||
- success (bool): Whether command exited with code 0.
|
||||
- returncode (int): Process return code.
|
||||
- stdout (str): Raw stdout.
|
||||
- stderr (str): Raw stderr.
|
||||
- data (Any): Parsed JSON data if capture_json=True, else None.
|
||||
"""
|
||||
pm2 = _get_pm2()
|
||||
cmd = [pm2, *args]
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
env=_build_env(),
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return {
|
||||
"success": False,
|
||||
"returncode": -1,
|
||||
"stdout": "",
|
||||
"stderr": f"Command timed out after {timeout}s: {' '.join(cmd)}",
|
||||
"data": None,
|
||||
}
|
||||
except FileNotFoundError:
|
||||
return {
|
||||
"success": False,
|
||||
"returncode": -1,
|
||||
"stdout": "",
|
||||
"stderr": f"pm2 binary not found at: {pm2}",
|
||||
"data": None,
|
||||
}
|
||||
|
||||
parsed_data = None
|
||||
if capture_json and result.returncode == 0:
|
||||
try:
|
||||
parsed_data = json.loads(result.stdout)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
# stdout may contain non-JSON preamble; try to extract JSON array
|
||||
stdout = result.stdout.strip()
|
||||
# Look for JSON array or object
|
||||
for start_char, end_char in [("[", "]"), ("{", "}")]:
|
||||
idx_start = stdout.find(start_char)
|
||||
idx_end = stdout.rfind(end_char)
|
||||
if idx_start != -1 and idx_end > idx_start:
|
||||
try:
|
||||
parsed_data = json.loads(stdout[idx_start:idx_end + 1])
|
||||
break
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
continue
|
||||
|
||||
return {
|
||||
"success": result.returncode == 0,
|
||||
"returncode": result.returncode,
|
||||
"stdout": result.stdout,
|
||||
"stderr": result.stderr,
|
||||
"data": parsed_data,
|
||||
}
|
||||
|
||||
|
||||
def pm2_jlist() -> list[dict[str, Any]]:
|
||||
"""Get JSON list of all PM2 processes.
|
||||
|
||||
Returns:
|
||||
List of process info dicts, or empty list on failure.
|
||||
"""
|
||||
result = run_pm2("jlist", capture_json=True)
|
||||
if result["success"] and isinstance(result["data"], list):
|
||||
return result["data"]
|
||||
return []
|
||||
|
||||
|
||||
def pm2_describe(name: str) -> dict[str, Any] | None:
|
||||
"""Get detailed info for a specific process.
|
||||
|
||||
Uses pm2 jlist and filters by name/id, since pm2 describe
|
||||
does not produce JSON output.
|
||||
|
||||
Args:
|
||||
name: Process name or ID.
|
||||
|
||||
Returns:
|
||||
Process description dict, or None on failure.
|
||||
"""
|
||||
processes = pm2_jlist()
|
||||
for p in processes:
|
||||
if p.get("name") == name or str(p.get("pm_id")) == str(name):
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def pm2_action(action: str, name: str) -> dict[str, Any]:
|
||||
"""Run a lifecycle action (restart, stop, delete) on a process.
|
||||
|
||||
Args:
|
||||
action: One of "restart", "stop", "delete".
|
||||
name: Process name or ID.
|
||||
|
||||
Returns:
|
||||
Result dict from run_pm2.
|
||||
"""
|
||||
return run_pm2(action, str(name))
|
||||
|
||||
|
||||
def pm2_start(script: str, name: str | None = None) -> dict[str, Any]:
|
||||
"""Start a new PM2 process.
|
||||
|
||||
Args:
|
||||
script: Path to script or ecosystem file.
|
||||
name: Optional process name.
|
||||
|
||||
Returns:
|
||||
Result dict from run_pm2.
|
||||
"""
|
||||
args = ["start", script]
|
||||
if name:
|
||||
args.extend(["--name", name])
|
||||
return run_pm2(*args)
|
||||
|
||||
|
||||
def pm2_logs(name: str, lines: int = 20) -> dict[str, Any]:
|
||||
"""Get recent logs for a process.
|
||||
|
||||
Args:
|
||||
name: Process name or ID.
|
||||
lines: Number of log lines to retrieve.
|
||||
|
||||
Returns:
|
||||
Result dict from run_pm2.
|
||||
"""
|
||||
return run_pm2("logs", str(name), "--lines", str(lines), "--nostream")
|
||||
|
||||
|
||||
def pm2_flush(name: str | None = None) -> dict[str, Any]:
|
||||
"""Flush logs for a process or all processes.
|
||||
|
||||
Args:
|
||||
name: Process name or ID. If None, flushes all.
|
||||
|
||||
Returns:
|
||||
Result dict from run_pm2.
|
||||
"""
|
||||
args = ["flush"]
|
||||
if name:
|
||||
args.append(str(name))
|
||||
return run_pm2(*args)
|
||||
|
||||
|
||||
def pm2_save() -> dict[str, Any]:
|
||||
"""Save the current PM2 process list."""
|
||||
return run_pm2("save")
|
||||
|
||||
|
||||
def pm2_startup() -> dict[str, Any]:
|
||||
"""Generate PM2 startup script."""
|
||||
return run_pm2("startup")
|
||||
|
||||
|
||||
def pm2_version() -> str:
|
||||
"""Get PM2 version string."""
|
||||
result = run_pm2("--version")
|
||||
if result["success"]:
|
||||
return result["stdout"].strip()
|
||||
return "unknown"
|
||||
@@ -0,0 +1,567 @@
|
||||
"""cli-anything REPL Skin — Unified terminal interface for all CLI harnesses.
|
||||
|
||||
Copy this file into your CLI package at:
|
||||
cli_anything/<software>/utils/repl_skin.py
|
||||
|
||||
Usage:
|
||||
from cli_anything.<software>.utils.repl_skin import ReplSkin
|
||||
|
||||
skin = ReplSkin("shotcut", version="1.0.0")
|
||||
skin.print_banner() # auto-detects repo-root or packaged SKILL.md
|
||||
prompt_text = skin.prompt(project_name="my_video.mlt", modified=True)
|
||||
skin.success("Project saved")
|
||||
skin.error("File not found")
|
||||
skin.warning("Unsaved changes")
|
||||
skin.info("Processing 24 clips...")
|
||||
skin.status("Track 1", "3 clips, 00:02:30")
|
||||
skin.table(headers, rows)
|
||||
skin.print_goodbye()
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# ── ANSI color codes (no external deps for core styling) ──────────────
|
||||
|
||||
_RESET = "\033[0m"
|
||||
_BOLD = "\033[1m"
|
||||
_DIM = "\033[2m"
|
||||
_ITALIC = "\033[3m"
|
||||
_UNDERLINE = "\033[4m"
|
||||
|
||||
# Brand colors
|
||||
_CYAN = "\033[38;5;80m" # cli-anything brand cyan
|
||||
_CYAN_BG = "\033[48;5;80m"
|
||||
_WHITE = "\033[97m"
|
||||
_GRAY = "\033[38;5;245m"
|
||||
_DARK_GRAY = "\033[38;5;240m"
|
||||
_LIGHT_GRAY = "\033[38;5;250m"
|
||||
|
||||
# Software accent colors — each software gets a unique accent
|
||||
_ACCENT_COLORS = {
|
||||
"gimp": "\033[38;5;214m", # warm orange
|
||||
"blender": "\033[38;5;208m", # deep orange
|
||||
"inkscape": "\033[38;5;39m", # bright blue
|
||||
"audacity": "\033[38;5;33m", # navy blue
|
||||
"libreoffice": "\033[38;5;40m", # green
|
||||
"obs_studio": "\033[38;5;55m", # purple
|
||||
"kdenlive": "\033[38;5;69m", # slate blue
|
||||
"shotcut": "\033[38;5;35m", # teal green
|
||||
}
|
||||
_DEFAULT_ACCENT = "\033[38;5;75m" # default sky blue
|
||||
|
||||
# Status colors
|
||||
_GREEN = "\033[38;5;78m"
|
||||
_YELLOW = "\033[38;5;220m"
|
||||
_RED = "\033[38;5;196m"
|
||||
_BLUE = "\033[38;5;75m"
|
||||
_MAGENTA = "\033[38;5;176m"
|
||||
|
||||
_SKILL_SOURCE_REPO = os.environ.get("CLI_ANYTHING_SKILL_REPO", "HKUDS/CLI-Anything")
|
||||
|
||||
# ── 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))
|
||||
|
||||
|
||||
def _display_home_path(path: str) -> str:
|
||||
"""Display a path relative to the home directory when possible."""
|
||||
expanded = Path(path).expanduser().resolve()
|
||||
home = Path.home().resolve()
|
||||
try:
|
||||
relative = expanded.relative_to(home)
|
||||
return f"~/{relative.as_posix()}"
|
||||
except ValueError:
|
||||
return str(expanded)
|
||||
|
||||
|
||||
class ReplSkin:
|
||||
"""Unified REPL skin for cli-anything CLIs.
|
||||
|
||||
Provides consistent branding, prompts, and message formatting
|
||||
across all CLI harnesses built with the cli-anything methodology.
|
||||
"""
|
||||
|
||||
def __init__(self, software: str, version: str = "1.0.0",
|
||||
history_file: str | None = None, skill_path: str | None = None):
|
||||
"""Initialize the REPL skin.
|
||||
|
||||
Args:
|
||||
software: Software name (e.g., "gimp", "shotcut", "blender").
|
||||
version: CLI version string.
|
||||
history_file: Path for persistent command history.
|
||||
Defaults to ~/.cli-anything-<software>/history
|
||||
skill_path: Path to the SKILL.md file for agent discovery.
|
||||
Auto-detected from the repo-root skills/ tree when present,
|
||||
otherwise from the package's skills/ directory.
|
||||
Displayed in banner for AI agents to know where to read skill info.
|
||||
"""
|
||||
self.software = software.lower().replace("-", "_")
|
||||
self.display_name = software.replace("_", " ").title()
|
||||
self.version = version
|
||||
software_aliases = {"iterm2_ctl": "iterm2"}
|
||||
self.skill_slug = software_aliases.get(self.software, self.software).replace("_", "-")
|
||||
self.skill_id = f"cli-anything-{self.skill_slug}"
|
||||
self.skill_install_cmd = (
|
||||
f"npx skills add {_SKILL_SOURCE_REPO} --skill {self.skill_id} -g -y"
|
||||
)
|
||||
global_skill_root = Path(
|
||||
os.environ.get("CLI_ANYTHING_GLOBAL_SKILLS_DIR", str(Path.home() / ".agents" / "skills"))
|
||||
).expanduser()
|
||||
self.global_skill_path = str(global_skill_root / self.skill_id / "SKILL.md")
|
||||
|
||||
# Prefer repo-root canonical skills/<skill-id>/SKILL.md when running
|
||||
# inside the CLI-Anything monorepo. Fall back to the packaged
|
||||
# cli_anything/<software>/skills/SKILL.md for installed harnesses.
|
||||
if skill_path is None:
|
||||
package_skill = Path(__file__).resolve().parent.parent / "skills" / "SKILL.md"
|
||||
repo_skill = None
|
||||
for parent in Path(__file__).resolve().parents:
|
||||
candidate = parent / "skills" / self.skill_id / "SKILL.md"
|
||||
if candidate.is_file():
|
||||
repo_skill = candidate
|
||||
break
|
||||
if repo_skill and repo_skill.is_file():
|
||||
skill_path = str(repo_skill)
|
||||
elif package_skill.is_file():
|
||||
skill_path = str(package_skill)
|
||||
self.skill_path = skill_path
|
||||
self.accent = _ACCENT_COLORS.get(self.software, _DEFAULT_ACCENT)
|
||||
|
||||
# History file
|
||||
if history_file is None:
|
||||
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."""
|
||||
import textwrap
|
||||
|
||||
inner = 72
|
||||
|
||||
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}"
|
||||
|
||||
def _meta_lines(label: str, value: str) -> list[str]:
|
||||
"""Wrap a metadata line for the banner box."""
|
||||
icon = self._c(_MAGENTA, "◇")
|
||||
label_text = self._c(_DARK_GRAY, label)
|
||||
prefix = f" {icon} {label_text} "
|
||||
available = max(12, inner - _visible_len(prefix))
|
||||
wrapped = textwrap.wrap(
|
||||
value,
|
||||
width=available,
|
||||
break_long_words=True,
|
||||
break_on_hyphens=False,
|
||||
) or [""]
|
||||
lines = [f"{prefix}{self._c(_LIGHT_GRAY, wrapped[0])}"]
|
||||
continuation_prefix = " " * _visible_len(prefix)
|
||||
for chunk in wrapped[1:]:
|
||||
lines.append(f"{continuation_prefix}{self._c(_LIGHT_GRAY, chunk)}")
|
||||
return lines
|
||||
|
||||
top = self._c(_DARK_GRAY, f"{_TL}{_H_LINE * inner}{_TR}")
|
||||
bot = self._c(_DARK_GRAY, f"{_BL}{_H_LINE * inner}{_BR}")
|
||||
|
||||
# Title: ◆ cli-anything · Shotcut
|
||||
icon = self._c(_CYAN + _BOLD, "◆")
|
||||
brand = self._c(_CYAN + _BOLD, "cli-anything")
|
||||
dot = self._c(_DARK_GRAY, "·")
|
||||
name = self._c(self.accent + _BOLD, self.display_name)
|
||||
title = f" {icon} {brand} {dot} {name}"
|
||||
|
||||
ver = f" {self._c(_DARK_GRAY, f' v{self.version}')}"
|
||||
tip = f" {self._c(_DARK_GRAY, ' Type help for commands, quit to exit')}"
|
||||
empty = ""
|
||||
|
||||
meta_lines: list[str] = []
|
||||
meta_lines.extend(_meta_lines("Install:", self.skill_install_cmd))
|
||||
meta_lines.extend(_meta_lines("Global skill:", _display_home_path(self.global_skill_path)))
|
||||
print(top)
|
||||
print(_box_line(title))
|
||||
print(_box_line(ver))
|
||||
for line in meta_lines:
|
||||
print(_box_line(line))
|
||||
print(_box_line(empty))
|
||||
print(_box_line(tip))
|
||||
print(bot)
|
||||
print()
|
||||
|
||||
# ── Prompt ────────────────────────────────────────────────────────
|
||||
|
||||
def prompt(self, project_name: str = "", modified: bool = False,
|
||||
context: str = "") -> str:
|
||||
"""Build a styled prompt string for prompt_toolkit or input().
|
||||
|
||||
Args:
|
||||
project_name: Current project name (empty if none open).
|
||||
modified: Whether the project has unsaved changes.
|
||||
context: Optional extra context to show in prompt.
|
||||
|
||||
Returns:
|
||||
Formatted prompt string.
|
||||
"""
|
||||
parts = []
|
||||
|
||||
# Icon
|
||||
if self._color:
|
||||
parts.append(f"{_CYAN}◆{_RESET} ")
|
||||
else:
|
||||
parts.append("> ")
|
||||
|
||||
# Software name
|
||||
parts.append(self._c(self.accent + _BOLD, self.software))
|
||||
|
||||
# Project context
|
||||
if project_name or context:
|
||||
ctx = context or project_name
|
||||
mod = "*" if modified else ""
|
||||
parts.append(f" {self._c(_DARK_GRAY, '[')}")
|
||||
parts.append(self._c(_LIGHT_GRAY, f"{ctx}{mod}"))
|
||||
parts.append(self._c(_DARK_GRAY, ']'))
|
||||
|
||||
parts.append(self._c(_GRAY, " ❯ "))
|
||||
|
||||
return "".join(parts)
|
||||
|
||||
def prompt_tokens(self, project_name: str = "", modified: bool = False,
|
||||
context: str = ""):
|
||||
"""Build prompt_toolkit formatted text tokens for the prompt.
|
||||
|
||||
Use with prompt_toolkit's FormattedText for proper ANSI handling.
|
||||
|
||||
Returns:
|
||||
list of (style, text) tuples for prompt_toolkit.
|
||||
"""
|
||||
accent_hex = _ANSI_256_TO_HEX.get(self.accent, "#5fafff")
|
||||
tokens = []
|
||||
|
||||
tokens.append(("class:icon", "◆ "))
|
||||
tokens.append(("class:software", self.software))
|
||||
|
||||
if project_name or context:
|
||||
ctx = context or project_name
|
||||
mod = "*" if modified else ""
|
||||
tokens.append(("class:bracket", " ["))
|
||||
tokens.append(("class:context", f"{ctx}{mod}"))
|
||||
tokens.append(("class:bracket", "]"))
|
||||
|
||||
tokens.append(("class:arrow", " ❯ "))
|
||||
|
||||
return tokens
|
||||
|
||||
def get_prompt_style(self):
|
||||
"""Get a prompt_toolkit Style object matching the skin.
|
||||
|
||||
Returns:
|
||||
prompt_toolkit.styles.Style
|
||||
"""
|
||||
try:
|
||||
from prompt_toolkit.styles import Style
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
accent_hex = _ANSI_256_TO_HEX.get(self.accent, "#5fafff")
|
||||
|
||||
return Style.from_dict({
|
||||
"icon": "#5fdfdf bold", # cyan brand color
|
||||
"software": f"{accent_hex} bold",
|
||||
"bracket": "#585858",
|
||||
"context": "#bcbcbc",
|
||||
"arrow": "#808080",
|
||||
# Completion menu
|
||||
"completion-menu.completion": "bg:#303030 #bcbcbc",
|
||||
"completion-menu.completion.current": f"bg:{accent_hex} #000000",
|
||||
"completion-menu.meta.completion": "bg:#303030 #808080",
|
||||
"completion-menu.meta.completion.current": f"bg:{accent_hex} #000000",
|
||||
# Auto-suggest
|
||||
"auto-suggest": "#585858",
|
||||
# Bottom toolbar
|
||||
"bottom-toolbar": "bg:#1c1c1c #808080",
|
||||
"bottom-toolbar.text": "#808080",
|
||||
})
|
||||
|
||||
# ── Messages ──────────────────────────────────────────────────────
|
||||
|
||||
def success(self, message: str):
|
||||
"""Print a success message with green checkmark."""
|
||||
icon = self._c(_GREEN + _BOLD, "✓")
|
||||
print(f" {icon} {self._c(_GREEN, message)}")
|
||||
|
||||
def error(self, message: str):
|
||||
"""Print an error message with red cross."""
|
||||
icon = self._c(_RED + _BOLD, "✗")
|
||||
print(f" {icon} {self._c(_RED, message)}", file=sys.stderr)
|
||||
|
||||
def warning(self, message: str):
|
||||
"""Print a warning message with yellow triangle."""
|
||||
icon = self._c(_YELLOW + _BOLD, "⚠")
|
||||
print(f" {icon} {self._c(_YELLOW, message)}")
|
||||
|
||||
def info(self, message: str):
|
||||
"""Print an info message with blue dot."""
|
||||
icon = self._c(_BLUE, "●")
|
||||
print(f" {icon} {self._c(_LIGHT_GRAY, message)}")
|
||||
|
||||
def hint(self, message: str):
|
||||
"""Print a subtle hint message."""
|
||||
print(f" {self._c(_DARK_GRAY, message)}")
|
||||
|
||||
def section(self, title: str):
|
||||
"""Print a section header."""
|
||||
print()
|
||||
print(f" {self._c(self.accent + _BOLD, title)}")
|
||||
print(f" {self._c(_DARK_GRAY, _H_LINE * len(title))}")
|
||||
|
||||
# ── Status display ────────────────────────────────────────────────
|
||||
|
||||
def status(self, label: str, value: str):
|
||||
"""Print a key-value status line."""
|
||||
lbl = self._c(_GRAY, f" {label}:")
|
||||
val = self._c(_WHITE, f" {value}")
|
||||
print(f"{lbl}{val}")
|
||||
|
||||
def status_block(self, items: dict[str, str], title: str = ""):
|
||||
"""Print a block of status key-value pairs.
|
||||
|
||||
Args:
|
||||
items: Dict of label -> value pairs.
|
||||
title: Optional title for the block.
|
||||
"""
|
||||
if title:
|
||||
self.section(title)
|
||||
|
||||
max_key = max(len(k) for k in items) if items else 0
|
||||
for label, value in items.items():
|
||||
lbl = self._c(_GRAY, f" {label:<{max_key}}")
|
||||
val = self._c(_WHITE, f" {value}")
|
||||
print(f"{lbl}{val}")
|
||||
|
||||
def progress(self, current: int, total: int, label: str = ""):
|
||||
"""Print a simple progress indicator.
|
||||
|
||||
Args:
|
||||
current: Current step number.
|
||||
total: Total number of steps.
|
||||
label: Optional label for the progress.
|
||||
"""
|
||||
pct = int(current / total * 100) if total > 0 else 0
|
||||
bar_width = 20
|
||||
filled = int(bar_width * current / total) if total > 0 else 0
|
||||
bar = "█" * filled + "░" * (bar_width - filled)
|
||||
text = f" {self._c(_CYAN, bar)} {self._c(_GRAY, f'{pct:3d}%')}"
|
||||
if label:
|
||||
text += f" {self._c(_LIGHT_GRAY, label)}"
|
||||
print(text)
|
||||
|
||||
# ── Table display ─────────────────────────────────────────────────
|
||||
|
||||
def table(self, headers: list[str], rows: list[list[str]],
|
||||
max_col_width: int = 40):
|
||||
"""Print a formatted table with box-drawing characters.
|
||||
|
||||
Args:
|
||||
headers: Column header strings.
|
||||
rows: List of rows, each a list of cell strings.
|
||||
max_col_width: Maximum column width before truncation.
|
||||
"""
|
||||
if not headers:
|
||||
return
|
||||
|
||||
# Calculate column widths
|
||||
col_widths = [min(len(h), max_col_width) for h in headers]
|
||||
for row in rows:
|
||||
for i, cell in enumerate(row):
|
||||
if i < len(col_widths):
|
||||
col_widths[i] = min(
|
||||
max(col_widths[i], len(str(cell))), max_col_width
|
||||
)
|
||||
|
||||
def pad(text: str, width: int) -> str:
|
||||
t = str(text)[:width]
|
||||
return t + " " * (width - len(t))
|
||||
|
||||
# Header
|
||||
header_cells = [
|
||||
self._c(_CYAN + _BOLD, pad(h, col_widths[i]))
|
||||
for i, h in enumerate(headers)
|
||||
]
|
||||
sep = self._c(_DARK_GRAY, f" {_V_LINE} ")
|
||||
header_line = f" {sep.join(header_cells)}"
|
||||
print(header_line)
|
||||
|
||||
# Separator
|
||||
sep_parts = [self._c(_DARK_GRAY, _H_LINE * w) for w in col_widths]
|
||||
sep_line = self._c(_DARK_GRAY, f" {'───'.join([_H_LINE * w for w in col_widths])}")
|
||||
print(sep_line)
|
||||
|
||||
# Rows
|
||||
for row in rows:
|
||||
cells = []
|
||||
for i, cell in enumerate(row):
|
||||
if i < len(col_widths):
|
||||
cells.append(self._c(_LIGHT_GRAY, pad(str(cell), col_widths[i])))
|
||||
row_sep = self._c(_DARK_GRAY, f" {_V_LINE} ")
|
||||
print(f" {row_sep.join(cells)}")
|
||||
|
||||
# ── Help display ──────────────────────────────────────────────────
|
||||
|
||||
def help(self, commands: dict[str, str]):
|
||||
"""Print a formatted help listing.
|
||||
|
||||
Args:
|
||||
commands: Dict of command -> description pairs.
|
||||
"""
|
||||
self.section("Commands")
|
||||
max_cmd = max(len(c) for c in commands) if commands else 0
|
||||
for cmd, desc in commands.items():
|
||||
cmd_styled = self._c(self.accent, f" {cmd:<{max_cmd}}")
|
||||
desc_styled = self._c(_GRAY, f" {desc}")
|
||||
print(f"{cmd_styled}{desc_styled}")
|
||||
print()
|
||||
|
||||
# ── Goodbye ───────────────────────────────────────────────────────
|
||||
|
||||
def print_goodbye(self):
|
||||
"""Print a styled goodbye message."""
|
||||
print(f"\n {_ICON_SMALL} {self._c(_GRAY, 'Goodbye!')}\n")
|
||||
|
||||
# ── Prompt toolkit session factory ────────────────────────────────
|
||||
|
||||
def create_prompt_session(self):
|
||||
"""Create a prompt_toolkit PromptSession with skin styling.
|
||||
|
||||
Returns:
|
||||
A configured PromptSession, or None if prompt_toolkit unavailable.
|
||||
"""
|
||||
try:
|
||||
from prompt_toolkit import PromptSession
|
||||
from prompt_toolkit.history import FileHistory
|
||||
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
|
||||
from prompt_toolkit.formatted_text import FormattedText
|
||||
|
||||
style = self.get_prompt_style()
|
||||
|
||||
session = PromptSession(
|
||||
history=FileHistory(self.history_file),
|
||||
auto_suggest=AutoSuggestFromHistory(),
|
||||
style=style,
|
||||
enable_history_search=True,
|
||||
)
|
||||
return session
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
def get_input(self, pt_session, project_name: str = "",
|
||||
modified: bool = False, context: str = "") -> str:
|
||||
"""Get input from user using prompt_toolkit or fallback.
|
||||
|
||||
Args:
|
||||
pt_session: A prompt_toolkit PromptSession (or None).
|
||||
project_name: Current project name.
|
||||
modified: Whether project has unsaved changes.
|
||||
context: Optional context string.
|
||||
|
||||
Returns:
|
||||
User input string (stripped).
|
||||
"""
|
||||
if pt_session is not None:
|
||||
from prompt_toolkit.formatted_text import FormattedText
|
||||
tokens = self.prompt_tokens(project_name, modified, context)
|
||||
return pt_session.prompt(FormattedText(tokens)).strip()
|
||||
else:
|
||||
raw_prompt = self.prompt(project_name, modified, context)
|
||||
return input(raw_prompt).strip()
|
||||
|
||||
# ── Toolbar builder ───────────────────────────────────────────────
|
||||
|
||||
def bottom_toolbar(self, items: dict[str, str]):
|
||||
"""Create a bottom toolbar callback for prompt_toolkit.
|
||||
|
||||
Args:
|
||||
items: Dict of label -> value pairs to show in toolbar.
|
||||
|
||||
Returns:
|
||||
A callable that returns FormattedText for the toolbar.
|
||||
"""
|
||||
def toolbar():
|
||||
from prompt_toolkit.formatted_text import FormattedText
|
||||
parts = []
|
||||
for i, (k, v) in enumerate(items.items()):
|
||||
if i > 0:
|
||||
parts.append(("class:bottom-toolbar.text", " │ "))
|
||||
parts.append(("class:bottom-toolbar.text", f" {k}: "))
|
||||
parts.append(("class:bottom-toolbar", v))
|
||||
return FormattedText(parts)
|
||||
return toolbar
|
||||
|
||||
|
||||
# ── ANSI 256-color to hex mapping (for prompt_toolkit styles) ─────────
|
||||
|
||||
_ANSI_256_TO_HEX = {
|
||||
"\033[38;5;33m": "#0087ff", # audacity navy blue
|
||||
"\033[38;5;35m": "#00af5f", # shotcut teal
|
||||
"\033[38;5;39m": "#00afff", # inkscape bright blue
|
||||
"\033[38;5;40m": "#00d700", # libreoffice green
|
||||
"\033[38;5;55m": "#5f00af", # obs purple
|
||||
"\033[38;5;69m": "#5f87ff", # kdenlive slate blue
|
||||
"\033[38;5;75m": "#5fafff", # default sky blue
|
||||
"\033[38;5;80m": "#5fd7d7", # brand cyan
|
||||
"\033[38;5;208m": "#ff8700", # blender deep orange
|
||||
"\033[38;5;214m": "#ffaf00", # gimp warm orange
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Setup for cli-anything-pm2 — CLI harness for PM2 process management."""
|
||||
|
||||
from setuptools import setup, find_namespace_packages
|
||||
|
||||
setup(
|
||||
name="cli-anything-pm2",
|
||||
version="1.0.0",
|
||||
author="cli-anything contributors",
|
||||
author_email="",
|
||||
description="CLI-Anything harness for PM2 process management",
|
||||
url="https://github.com/HKUDS/CLI-Anything",
|
||||
packages=find_namespace_packages(include=["cli_anything.*"]),
|
||||
classifiers=[
|
||||
"Development Status :: 4 - Beta",
|
||||
"Intended Audience :: Developers",
|
||||
"Topic :: Software Development :: Libraries :: Python Modules",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
],
|
||||
python_requires=">=3.10",
|
||||
install_requires=[
|
||||
"click>=8.0.0",
|
||||
"prompt-toolkit>=3.0.0",
|
||||
],
|
||||
extras_require={
|
||||
"dev": [
|
||||
"pytest>=7.0.0",
|
||||
"pytest-cov>=4.0.0",
|
||||
],
|
||||
},
|
||||
entry_points={
|
||||
"console_scripts": [
|
||||
"cli-anything-pm2=cli_anything.pm2.pm2_cli:main",
|
||||
],
|
||||
},
|
||||
package_data={
|
||||
"cli_anything.pm2": ["skills/*.md"],
|
||||
},
|
||||
include_package_data=True,
|
||||
zip_safe=False,
|
||||
)
|
||||
Reference in New Issue
Block a user