chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:55:23 +08:00
commit bc7eac8151
1701 changed files with 376767 additions and 0 deletions
+101
View File
@@ -0,0 +1,101 @@
# Agent Harness SOP: PM2 Process Management
## Purpose
This harness provides a CLI-Anything interface for PM2 process management.
It wraps the PM2 CLI via subprocess calls and exposes process lifecycle, logs,
and system commands through a unified Click CLI with interactive REPL mode.
## Backend
- **Engine**: PM2 (Node.js process manager) via subprocess
- **Binary**: `pm2` resolved via `shutil.which()` with fallback paths
- **Protocol**: Subprocess execution with stdout/stderr capture
- **Data format**: PM2 outputs JSON via `pm2 jlist`; other commands return plain text
## Architecture
```
cli-anything-pm2 (Click CLI)
|
+-- pm2_cli.py # Click groups + REPL dispatcher
|
+-- core/
| +-- processes.py # list, describe, metrics
| +-- lifecycle.py # start, stop, restart, delete
| +-- logs.py # view, flush
| +-- system.py # save, startup, version
|
+-- utils/
| +-- pm2_backend.py # subprocess wrapper (_run_pm2, pm2_jlist, etc.)
| +-- repl_skin.py # cli-anything branded REPL UI
|
+-- tests/
| +-- test_core.py # Unit tests (mocked subprocess)
| +-- test_full_e2e.py # E2E tests (real pm2 binary)
| +-- TEST.md # Test plan and results
|
+-- skills/
+-- SKILL.md # Agent skill definitions
```
## Command Groups
| Group | Commands | Backend Calls |
|-------------|------------------------------|-----------------------------------|
| `process` | list, describe, metrics | `pm2 jlist` (JSON parse) |
| `lifecycle` | start, stop, restart, delete | `pm2 start/stop/restart/delete` |
| `logs` | view, flush | `pm2 logs --nostream`, `pm2 flush`|
| `system` | save, startup, version | `pm2 save/startup/--version` |
## State Model
**Stateless.** Every command queries PM2 fresh via subprocess. There is no
in-memory session state, no project files, and no undo/redo. The PM2 daemon
itself maintains process state; this harness is a read/write proxy.
## Output Modes
All commands support two output modes controlled by the `--json` flag:
- **Human mode** (default): Formatted tables, status messages with color
- **JSON mode** (`--json`): Machine-readable JSON for agent consumption
## Interaction Modes
1. **Subcommand CLI**: `cli-anything-pm2 [--json] <group> <command> [args]`
2. **Interactive REPL**: `cli-anything-pm2` (no subcommand) launches the REPL
## Key Design Decisions
- PM2 binary path is cached after first lookup (`_PM2_BIN` module-level)
- `pm2 jlist` is used instead of `pm2 describe` for JSON reliability
- Subprocess environment includes `/opt/homebrew/bin` for macOS compatibility
- Timeout defaults to 30 seconds per subprocess call
- JSON parsing has fallback extraction for non-JSON preamble in stdout
## Error Handling
| Scenario | Behavior |
|-----------------------|---------------------------------------------|
| pm2 not installed | `RuntimeError` with install instructions |
| pm2 binary vanishes | `FileNotFoundError` caught, error dict returned |
| Command timeout | `TimeoutExpired` caught, error dict returned |
| Invalid JSON output | Fallback regex extraction, then `data: None` |
| Process not found | Returns `None` or exit code 1 |
## Testing
- **Unit tests**: `test_core.py` -- all subprocess calls mocked, no pm2 required
- **E2E tests**: `test_full_e2e.py` -- calls real pm2 binary, requires pm2 installed
```bash
# Run all tests
python -m pytest cli_anything/pm2/tests/ -v
# Run only unit tests (no pm2 needed)
python -m pytest cli_anything/pm2/tests/test_core.py -v
# Run only E2E tests (pm2 must be installed and running)
python -m pytest cli_anything/pm2/tests/test_full_e2e.py -v
```
@@ -0,0 +1,41 @@
# cli-anything PM2
CLI-Anything harness for PM2 process management. All PM2 commands are executed via subprocess -- no PM2 API dependency required.
## Quick Start
```bash
# Install
cd agent-harness && pip install -e .
# REPL mode
cli-anything-pm2
# Direct commands
cli-anything-pm2 process list
cli-anything-pm2 --json process list
cli-anything-pm2 lifecycle restart seaclip-dev
cli-anything-pm2 logs view voice-agent --lines 50
cli-anything-pm2 system version
```
## Command Groups
- **process**: list, describe, metrics
- **lifecycle**: start, stop, restart, delete
- **logs**: view, flush
- **system**: save, startup, version
## Architecture
```
pm2_cli.py Click CLI + REPL entry point
core/
processes.py list, describe, metrics
lifecycle.py start, stop, restart, delete
logs.py view, flush
system.py save, startup, version
utils/
pm2_backend.py subprocess wrapper for pm2 commands
repl_skin.py cli-anything REPL skin
```
@@ -0,0 +1,3 @@
"""cli-anything PM2 — CLI harness for PM2 process management."""
__version__ = "1.0.0"
@@ -0,0 +1,5 @@
"""Allow running as python -m cli_anything.pm2."""
from .pm2_cli import main
main()
@@ -0,0 +1,118 @@
"""Lifecycle commands — start, stop, restart, delete PM2 processes."""
import json
from typing import Any
from ..utils.pm2_backend import pm2_action, pm2_start as backend_start
def restart_process(name: str, as_json: bool = False) -> dict[str, Any]:
"""Restart a PM2 process.
Args:
name: Process name or ID.
as_json: If True, return raw result dict.
Returns:
Result dict with success status and message.
"""
result = pm2_action("restart", name)
if as_json:
return {
"action": "restart",
"process": name,
"success": result["success"],
"stdout": result["stdout"],
"stderr": result["stderr"],
}
return {
"success": result["success"],
"message": f"Restarted '{name}'" if result["success"]
else f"Failed to restart '{name}': {result['stderr'].strip()}",
}
def stop_process(name: str, as_json: bool = False) -> dict[str, Any]:
"""Stop a PM2 process.
Args:
name: Process name or ID.
as_json: If True, return raw result dict.
Returns:
Result dict with success status and message.
"""
result = pm2_action("stop", name)
if as_json:
return {
"action": "stop",
"process": name,
"success": result["success"],
"stdout": result["stdout"],
"stderr": result["stderr"],
}
return {
"success": result["success"],
"message": f"Stopped '{name}'" if result["success"]
else f"Failed to stop '{name}': {result['stderr'].strip()}",
}
def delete_process(name: str, as_json: bool = False) -> dict[str, Any]:
"""Delete a PM2 process.
Args:
name: Process name or ID.
as_json: If True, return raw result dict.
Returns:
Result dict with success status and message.
"""
result = pm2_action("delete", name)
if as_json:
return {
"action": "delete",
"process": name,
"success": result["success"],
"stdout": result["stdout"],
"stderr": result["stderr"],
}
return {
"success": result["success"],
"message": f"Deleted '{name}'" if result["success"]
else f"Failed to delete '{name}': {result['stderr'].strip()}",
}
def start_process(
script: str,
name: str | None = None,
as_json: bool = False,
) -> dict[str, Any]:
"""Start a new PM2 process.
Args:
script: Path to script or ecosystem file.
name: Optional process name.
as_json: If True, return raw result dict.
Returns:
Result dict with success status and message.
"""
result = backend_start(script, name=name)
display_name = name or script
if as_json:
return {
"action": "start",
"script": script,
"name": name,
"success": result["success"],
"stdout": result["stdout"],
"stderr": result["stderr"],
}
return {
"success": result["success"],
"message": f"Started '{display_name}'" if result["success"]
else f"Failed to start '{display_name}': {result['stderr'].strip()}",
}
@@ -0,0 +1,71 @@
"""Log commands — view and flush PM2 process logs."""
from typing import Any
from ..utils.pm2_backend import pm2_logs as backend_logs, pm2_flush as backend_flush
def view_logs(name: str, lines: int = 20, as_json: bool = False) -> dict[str, Any]:
"""View recent logs for a PM2 process.
Args:
name: Process name or ID.
lines: Number of log lines to retrieve.
as_json: If True, return structured dict.
Returns:
Dict with log content and metadata.
"""
result = backend_logs(name, lines=lines)
if as_json:
return {
"process": name,
"lines_requested": lines,
"success": result["success"],
"stdout": result["stdout"],
"stderr": result["stderr"],
}
if result["success"]:
output = result["stdout"].strip() or result["stderr"].strip()
return {
"success": True,
"message": f"Logs for '{name}' (last {lines} lines)",
"content": output if output else "(no log output)",
}
else:
return {
"success": False,
"message": f"Failed to get logs for '{name}': {result['stderr'].strip()}",
"content": "",
}
def flush_logs(name: str | None = None, as_json: bool = False) -> dict[str, Any]:
"""Flush logs for a process or all processes.
Args:
name: Process name or ID. If None, flushes all logs.
as_json: If True, return structured dict.
Returns:
Dict with success status and message.
"""
result = backend_flush(name)
target = f"'{name}'" if name else "all processes"
if as_json:
return {
"action": "flush",
"process": name or "all",
"success": result["success"],
"stdout": result["stdout"],
"stderr": result["stderr"],
}
return {
"success": result["success"],
"message": f"Flushed logs for {target}" if result["success"]
else f"Failed to flush logs for {target}: {result['stderr'].strip()}",
}
@@ -0,0 +1,160 @@
"""Process commands — list, describe, and metrics for PM2 processes."""
import json
from typing import Any
from ..utils.pm2_backend import pm2_jlist, pm2_describe
def list_processes(as_json: bool = False) -> str | list[dict[str, Any]]:
"""List all PM2 processes.
Args:
as_json: If True, return raw list of dicts. Otherwise, formatted string.
Returns:
Formatted table string or list of process dicts.
"""
processes = pm2_jlist()
if as_json:
return processes
if not processes:
return "No PM2 processes running."
rows = []
for p in processes:
pm2_env = p.get("pm2_env", {})
monit = p.get("monit", {})
rows.append({
"id": p.get("pm_id", "?"),
"name": p.get("name", "unknown"),
"status": pm2_env.get("status", "unknown"),
"cpu": f"{monit.get('cpu', 0)}%",
"memory": _format_bytes(monit.get("memory", 0)),
"restarts": pm2_env.get("restart_time", 0),
"uptime": _format_uptime(pm2_env.get("pm_uptime", 0)),
})
return rows
def describe_process(name: str, as_json: bool = False) -> str | dict[str, Any] | None:
"""Get detailed info for a specific process.
Args:
name: Process name or ID.
as_json: If True, return raw dict.
Returns:
Formatted info string, raw dict, or None if not found.
"""
info = pm2_describe(name)
if info is None:
return None
if as_json:
return info
pm2_env = info.get("pm2_env", {})
monit = info.get("monit", {})
details = {
"Name": info.get("name", "unknown"),
"ID": str(info.get("pm_id", "?")),
"Status": pm2_env.get("status", "unknown"),
"Script": pm2_env.get("pm_exec_path", "N/A"),
"CWD": pm2_env.get("pm_cwd", "N/A"),
"Interpreter": pm2_env.get("exec_interpreter", "N/A"),
"CPU": f"{monit.get('cpu', 0)}%",
"Memory": _format_bytes(monit.get("memory", 0)),
"Restarts": str(pm2_env.get("restart_time", 0)),
"Uptime": _format_uptime(pm2_env.get("pm_uptime", 0)),
"PID": str(info.get("pid", "N/A")),
"Exec Mode": pm2_env.get("exec_mode", "N/A"),
"Node Version": pm2_env.get("node_version", "N/A"),
}
return details
def get_metrics(as_json: bool = False) -> str | list[dict[str, Any]]:
"""Get CPU/memory metrics for all processes.
Args:
as_json: If True, return raw list of metric dicts.
Returns:
Formatted metrics string or list of metric dicts.
"""
processes = pm2_jlist()
if as_json:
metrics = []
for p in processes:
monit = p.get("monit", {})
pm2_env = p.get("pm2_env", {})
metrics.append({
"name": p.get("name", "unknown"),
"pm_id": p.get("pm_id", "?"),
"status": pm2_env.get("status", "unknown"),
"cpu": monit.get("cpu", 0),
"memory": monit.get("memory", 0),
"memory_human": _format_bytes(monit.get("memory", 0)),
})
return metrics
if not processes:
return "No PM2 processes running."
rows = []
for p in processes:
monit = p.get("monit", {})
pm2_env = p.get("pm2_env", {})
rows.append({
"id": p.get("pm_id", "?"),
"name": p.get("name", "unknown"),
"status": pm2_env.get("status", "unknown"),
"cpu": f"{monit.get('cpu', 0)}%",
"memory": _format_bytes(monit.get("memory", 0)),
})
return rows
def _format_bytes(b: int) -> str:
"""Format bytes into human-readable string."""
if b == 0:
return "0 B"
units = ["B", "KB", "MB", "GB"]
i = 0
val = float(b)
while val >= 1024 and i < len(units) - 1:
val /= 1024
i += 1
return f"{val:.1f} {units[i]}"
def _format_uptime(pm_uptime: int) -> str:
"""Format PM2 uptime timestamp to human-readable duration."""
if pm_uptime == 0:
return "N/A"
import time
now_ms = int(time.time() * 1000)
diff_s = max(0, (now_ms - pm_uptime)) // 1000
if diff_s < 60:
return f"{diff_s}s"
elif diff_s < 3600:
return f"{diff_s // 60}m {diff_s % 60}s"
elif diff_s < 86400:
h = diff_s // 3600
m = (diff_s % 3600) // 60
return f"{h}h {m}m"
else:
d = diff_s // 86400
h = (diff_s % 86400) // 3600
return f"{d}d {h}h"
@@ -0,0 +1,80 @@
"""System commands — save, startup, version for PM2."""
from typing import Any
from ..utils.pm2_backend import pm2_save as backend_save
from ..utils.pm2_backend import pm2_startup as backend_startup
from ..utils.pm2_backend import pm2_version as backend_version
def save(as_json: bool = False) -> dict[str, Any]:
"""Save the current PM2 process list.
Args:
as_json: If True, return structured dict.
Returns:
Dict with success status and message.
"""
result = backend_save()
if as_json:
return {
"action": "save",
"success": result["success"],
"stdout": result["stdout"],
"stderr": result["stderr"],
}
return {
"success": result["success"],
"message": "PM2 process list saved" if result["success"]
else f"Failed to save: {result['stderr'].strip()}",
}
def startup(as_json: bool = False) -> dict[str, Any]:
"""Generate PM2 startup script.
Args:
as_json: If True, return structured dict.
Returns:
Dict with success status, message, and any instructions.
"""
result = backend_startup()
if as_json:
return {
"action": "startup",
"success": result["success"],
"stdout": result["stdout"],
"stderr": result["stderr"],
}
output = result["stdout"].strip()
return {
"success": result["success"],
"message": "Startup script generated" if result["success"]
else f"Startup command output:\n{output}",
"instructions": output,
}
def version(as_json: bool = False) -> dict[str, Any] | str:
"""Get PM2 version.
Args:
as_json: If True, return structured dict.
Returns:
Version string or dict.
"""
ver = backend_version()
if as_json:
return {
"version": ver,
}
return ver
@@ -0,0 +1,371 @@
"""PM2 CLI — Click-based CLI with REPL mode for PM2 process management.
Entry point: cli-anything-pm2
"""
import json
import sys
import click
from .core import processes, lifecycle, logs, system
# ── Helpers ──────────────────────────────────────────────────────────────
def _output(data, as_json: bool):
"""Print data as JSON or formatted text."""
if as_json:
if isinstance(data, str):
click.echo(json.dumps({"result": data}, indent=2))
else:
click.echo(json.dumps(data, indent=2, default=str))
else:
if isinstance(data, str):
click.echo(data)
elif isinstance(data, dict):
if "message" in data:
prefix = "OK" if data.get("success", True) else "ERROR"
click.echo(f"[{prefix}] {data['message']}")
if data.get("content"):
click.echo(data["content"])
if data.get("instructions"):
click.echo(data["instructions"])
else:
for k, v in data.items():
click.echo(f" {k}: {v}")
elif isinstance(data, list):
if data and isinstance(data[0], dict):
# Print as table
if not data:
return
keys = list(data[0].keys())
# Header
header = " ".join(f"{k:<15}" for k in keys)
click.echo(header)
click.echo("-" * len(header))
for row in data:
line = " ".join(f"{str(row.get(k, '')):<15}" for k in keys)
click.echo(line)
else:
for item in data:
click.echo(str(item))
# ── Main Group ───────────────────────────────────────────────────────────
@click.group(invoke_without_command=True)
@click.option("--json", "as_json", is_flag=True, default=False,
help="Output in JSON format.")
@click.pass_context
def main(ctx, as_json):
"""CLI-Anything PM2 — Process management harness for PM2."""
ctx.ensure_object(dict)
ctx.obj["json"] = as_json
if ctx.invoked_subcommand is None:
# Launch REPL mode
_run_repl()
# ── Process Group ────────────────────────────────────────────────────────
@main.group()
@click.pass_context
def process(ctx):
"""Process info commands: list, describe, metrics."""
pass
@process.command("list")
@click.pass_context
def process_list(ctx):
"""List all PM2 processes."""
as_json = ctx.obj["json"]
data = processes.list_processes(as_json=as_json)
_output(data, as_json)
@process.command("describe")
@click.argument("name")
@click.pass_context
def process_describe(ctx, name):
"""Show detailed info for a PM2 process."""
as_json = ctx.obj["json"]
data = processes.describe_process(name, as_json=as_json)
if data is None:
click.echo(f"Process '{name}' not found.", err=True)
sys.exit(1)
_output(data, as_json)
@process.command("metrics")
@click.pass_context
def process_metrics(ctx):
"""Show CPU/memory metrics for all processes."""
as_json = ctx.obj["json"]
data = processes.get_metrics(as_json=as_json)
_output(data, as_json)
# ── Lifecycle Group ──────────────────────────────────────────────────────
@main.group()
@click.pass_context
def lifecycle(ctx):
"""Lifecycle commands: start, stop, restart, delete."""
pass
@lifecycle.command("restart")
@click.argument("name")
@click.pass_context
def lifecycle_restart(ctx, name):
"""Restart a PM2 process."""
as_json = ctx.obj["json"]
data = lifecycle_mod.restart_process(name, as_json=as_json)
_output(data, as_json)
@lifecycle.command("stop")
@click.argument("name")
@click.pass_context
def lifecycle_stop(ctx, name):
"""Stop a PM2 process."""
as_json = ctx.obj["json"]
data = lifecycle_mod.stop_process(name, as_json=as_json)
_output(data, as_json)
@lifecycle.command("start")
@click.argument("script")
@click.option("--name", default=None, help="Process name.")
@click.pass_context
def lifecycle_start(ctx, script, name):
"""Start a new PM2 process."""
as_json = ctx.obj["json"]
data = lifecycle_mod.start_process(script, name=name, as_json=as_json)
_output(data, as_json)
@lifecycle.command("delete")
@click.argument("name")
@click.pass_context
def lifecycle_delete(ctx, name):
"""Delete a PM2 process."""
as_json = ctx.obj["json"]
data = lifecycle_mod.delete_process(name, as_json=as_json)
_output(data, as_json)
# Alias to avoid name collision with the click group
lifecycle_mod = lifecycle_module = None
def _init_lifecycle_mod():
"""Lazy-init the lifecycle module reference."""
global lifecycle_mod
if lifecycle_mod is None:
from .core import lifecycle as _lc
lifecycle_mod = _lc
# Patch lifecycle commands to use the module
_init_lifecycle_mod()
# ── Logs Group ───────────────────────────────────────────────────────────
@main.group("logs")
@click.pass_context
def logs_group(ctx):
"""Log commands: view, flush."""
pass
@logs_group.command("view")
@click.argument("name")
@click.option("--lines", default=20, help="Number of log lines.")
@click.pass_context
def logs_view(ctx, name, lines):
"""View recent logs for a PM2 process."""
as_json = ctx.obj["json"]
data = logs.view_logs(name, lines=lines, as_json=as_json)
_output(data, as_json)
@logs_group.command("flush")
@click.argument("name", required=False, default=None)
@click.pass_context
def logs_flush(ctx, name):
"""Flush logs for a process (or all if no name given)."""
as_json = ctx.obj["json"]
data = logs.flush_logs(name=name, as_json=as_json)
_output(data, as_json)
# ── System Group ─────────────────────────────────────────────────────────
@main.group("system")
@click.pass_context
def system_group(ctx):
"""System commands: save, startup, version."""
pass
@system_group.command("save")
@click.pass_context
def system_save(ctx):
"""Save current PM2 process list."""
as_json = ctx.obj["json"]
data = system.save(as_json=as_json)
_output(data, as_json)
@system_group.command("startup")
@click.pass_context
def system_startup(ctx):
"""Generate PM2 startup script."""
as_json = ctx.obj["json"]
data = system.startup(as_json=as_json)
_output(data, as_json)
@system_group.command("version")
@click.pass_context
def system_version(ctx):
"""Show PM2 version."""
as_json = ctx.obj["json"]
data = system.version(as_json=as_json)
_output(data, as_json)
# ── REPL Mode ────────────────────────────────────────────────────────────
def _run_repl():
"""Launch the interactive REPL."""
from .utils.repl_skin import ReplSkin
skin = ReplSkin("pm2", version="1.0.0")
skin.print_banner()
session = skin.create_prompt_session()
# REPL command mapping
repl_commands = {
"process list": lambda args, j: _output(processes.list_processes(as_json=j), j),
"process describe": lambda args, j: _output(
processes.describe_process(args[0], as_json=j) if args else "Usage: process describe <name>", j
),
"process metrics": lambda args, j: _output(processes.get_metrics(as_json=j), j),
"lifecycle restart": lambda args, j: _output(
lifecycle_mod.restart_process(args[0], as_json=j) if args else "Usage: lifecycle restart <name>", j
),
"lifecycle stop": lambda args, j: _output(
lifecycle_mod.stop_process(args[0], as_json=j) if args else "Usage: lifecycle stop <name>", j
),
"lifecycle start": lambda args, j: _repl_start(args, j),
"lifecycle delete": lambda args, j: _output(
lifecycle_mod.delete_process(args[0], as_json=j) if args else "Usage: lifecycle delete <name>", j
),
"logs view": lambda args, j: _repl_logs_view(args, j),
"logs flush": lambda args, j: _output(logs.flush_logs(name=args[0] if args else None, as_json=j), j),
"system save": lambda args, j: _output(system.save(as_json=j), j),
"system startup": lambda args, j: _output(system.startup(as_json=j), j),
"system version": lambda args, j: _output(system.version(as_json=j), j),
}
help_commands = {
"process list": "List all PM2 processes",
"process describe N": "Detailed info for process N",
"process metrics": "CPU/memory metrics for all processes",
"lifecycle start S": "Start script S [--name N]",
"lifecycle stop N": "Stop process N",
"lifecycle restart N":"Restart process N",
"lifecycle delete N": "Delete process N",
"logs view N": "View logs for process N [--lines 50]",
"logs flush [N]": "Flush logs (optionally for process N)",
"system save": "Save PM2 process list",
"system startup": "Generate startup script",
"system version": "Show PM2 version",
"help": "Show this help",
"quit / exit": "Exit the REPL",
}
while True:
try:
user_input = skin.get_input(session)
except (EOFError, KeyboardInterrupt):
skin.print_goodbye()
break
if not user_input:
continue
raw = user_input.strip()
if raw in ("quit", "exit", "q"):
skin.print_goodbye()
break
if raw == "help":
skin.help(help_commands)
continue
# Check for --json flag in input
as_json = False
if "--json" in raw:
as_json = True
raw = raw.replace("--json", "").strip()
# Match command
matched = False
for cmd_key, handler in repl_commands.items():
if raw.startswith(cmd_key):
remainder = raw[len(cmd_key):].strip()
args = remainder.split() if remainder else []
try:
handler(args, as_json)
except Exception as e:
skin.error(str(e))
matched = True
break
if not matched:
skin.warning(f"Unknown command: {raw}")
skin.hint("Type 'help' for available commands.")
def _repl_start(args, as_json):
"""Handle 'lifecycle start' in REPL with --name parsing."""
if not args:
click.echo("Usage: lifecycle start <script> [--name <name>]")
return
script = args[0]
name = None
if "--name" in args:
idx = args.index("--name")
if idx + 1 < len(args):
name = args[idx + 1]
_output(lifecycle_mod.start_process(script, name=name, as_json=as_json), as_json)
def _repl_logs_view(args, as_json):
"""Handle 'logs view' in REPL with --lines parsing."""
if not args:
click.echo("Usage: logs view <name> [--lines N]")
return
name = args[0]
lines = 20
if "--lines" in args:
idx = args.index("--lines")
if idx + 1 < len(args):
try:
lines = int(args[idx + 1])
except ValueError:
pass
_output(logs.view_logs(name, lines=lines, as_json=as_json), as_json)
if __name__ == "__main__":
main()
@@ -0,0 +1,111 @@
---
name: >-
cli-anything-pm2
description: >-
Command-line interface for PM2 - A stateless CLI for Node.js process management via the PM2 CLI. List, start, stop, restart processes, view logs, and manage system configuration.
---
# cli-anything-pm2
A stateless command-line interface for PM2 process management.
Communicates via the PM2 CLI subprocess. No local state or session.
## Installation
```bash
pip install -e .
```
**Prerequisites:**
- Python 3.10+
- PM2 installed globally (`npm install -g pm2`)
## Usage
### Basic Commands
```bash
# Show help
cli-anything-pm2 --help
# Start interactive REPL mode
cli-anything-pm2
# Run with JSON output (for agent consumption)
cli-anything-pm2 --json process list
cli-anything-pm2 --json system version
```
### REPL Mode
When invoked without a subcommand, the CLI enters an interactive REPL session:
```bash
cli-anything-pm2
# Enter commands interactively with tab-completion and history
```
## Command Groups
### process
Process inspection commands.
| Command | Description |
|---------|-------------|
| `list` | List all PM2 processes |
| `describe <name>` | Get detailed info for a process |
| `metrics` | Get metrics for all processes |
### lifecycle
Process lifecycle commands.
| Command | Description |
|---------|-------------|
| `start <script> --name <name>` | Start a new process |
| `stop <name>` | Stop a process |
| `restart <name>` | Restart a process |
| `delete <name>` | Delete a process |
### logs
Log management commands.
| Command | Description |
|---------|-------------|
| `view <name> --lines 50` | View recent logs |
| `flush [name]` | Flush logs |
### system
System-level commands.
| Command | Description |
|---------|-------------|
| `save` | Save current process list |
| `startup` | Generate startup script |
| `version` | Get PM2 version |
## Output Formats
All commands support dual output modes:
- **Human-readable** (default): Tables, colors, formatted text
- **Machine-readable** (`--json` flag): Structured JSON for agent consumption
```bash
# Human output
cli-anything-pm2 process list
# JSON output for agents
cli-anything-pm2 --json process list
```
## For AI Agents
When using this CLI programmatically:
1. **Always use `--json` flag** for parseable output
2. **Check return codes** - 0 for success, non-zero for errors
3. **Parse stderr** for error messages on failure
## Version
1.0.0
@@ -0,0 +1,84 @@
# PM2 CLI Tests
Test suites for the PM2 CLI-Anything harness.
## Test Files
| File | Type | Count | Dependencies |
|-------------------|--------|-------|----------------------|
| `test_core.py` | Unit | 28 | None (mocked subprocess) |
| `test_full_e2e.py`| E2E | 9 | pm2 installed, CLI on PATH |
## Running Tests
```bash
cd agent-harness
pip install -e .
# All tests
python -m pytest cli_anything/pm2/tests/ -v
# Unit tests only (no pm2 needed)
python -m pytest cli_anything/pm2/tests/test_core.py -v
# E2E tests only (requires pm2)
python -m pytest cli_anything/pm2/tests/test_full_e2e.py -v
```
## Test Plan
### Unit Tests (test_core.py)
- [x] `_find_pm2()` locates binary via shutil.which
- [x] `_find_pm2()` raises RuntimeError when pm2 missing
- [x] `run_pm2()` returns success dict on exit code 0
- [x] `run_pm2()` returns failure dict on exit code 1
- [x] `run_pm2()` parses JSON stdout with capture_json=True
- [x] `run_pm2()` extracts JSON from stdout with non-JSON preamble
- [x] `run_pm2()` handles subprocess timeout
- [x] `run_pm2()` handles FileNotFoundError for missing binary
- [x] `list_processes()` JSON mode returns raw list
- [x] `list_processes()` human mode returns formatted rows
- [x] `list_processes()` returns message when no processes
- [x] `describe_process()` returns details for known process
- [x] `describe_process()` returns None for unknown process
- [x] `get_metrics()` JSON mode returns metric dicts
- [x] `restart_process()` success message
- [x] `stop_process()` failure message
- [x] `start_process()` with name in JSON mode
- [x] `view_logs()` success with content
- [x] `flush_logs()` all processes
- [x] `version()` JSON mode
- [x] `save()` success message
- [x] `_output()` JSON string wrapping
- [x] `_output()` JSON dict passthrough
- [x] `_output()` human string echo
- [x] `_output()` human dict with [OK] prefix
- [x] `_output()` human dict with [ERROR] prefix
- [x] `_format_bytes()` zero bytes
- [x] `_format_bytes()` megabytes
### E2E Tests (test_full_e2e.py)
- [x] `--json process list` returns valid JSON array
- [x] `process list` exits with code 0
- [x] `--json process describe <name>` returns valid JSON dict
- [x] `process describe __nonexistent__` exits non-zero
- [x] `--json system version` returns JSON with version key
- [x] `system version` returns version string with digits
- [x] `--help` exits with code 0
- [x] `process --help` shows list and describe subcommands
- [x] `--json process metrics` returns JSON list
## Last Run Results
```
Platform: darwin (macOS)
Python: 3.14.3
pytest: 9.0.2
Date: 2026-03-23
37 passed in 1.53s
- test_core.py: 28 passed
- test_full_e2e.py: 9 passed
```
@@ -0,0 +1,356 @@
"""Unit tests for the PM2 CLI-Anything harness.
All subprocess calls are mocked -- no pm2 binary required.
Covers: pm2_backend, core/processes, core/lifecycle, core/logs, core/system,
pm2_cli output formatting, and error handling.
"""
import json
import subprocess
from unittest import mock
from unittest.mock import MagicMock, patch
import pytest
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_run_result(stdout="", stderr="", returncode=0):
"""Build a mock subprocess.CompletedProcess."""
r = MagicMock(spec=subprocess.CompletedProcess)
r.stdout = stdout
r.stderr = stderr
r.returncode = returncode
return r
FAKE_JLIST = json.dumps([
{
"pm_id": 0,
"name": "seaclip-dev",
"pid": 12345,
"monit": {"cpu": 2.5, "memory": 52428800},
"pm2_env": {
"status": "online",
"restart_time": 3,
"pm_uptime": 1700000000000,
"pm_exec_path": "/app/index.js",
"pm_cwd": "/app",
"exec_interpreter": "node",
"exec_mode": "fork_mode",
"node_version": "20.11.0",
},
},
{
"pm_id": 1,
"name": "hub-dashboard",
"pid": 12346,
"monit": {"cpu": 0.1, "memory": 10485760},
"pm2_env": {
"status": "stopped",
"restart_time": 0,
"pm_uptime": 0,
"pm_exec_path": "/dash/server.js",
"pm_cwd": "/dash",
"exec_interpreter": "node",
"exec_mode": "fork_mode",
"node_version": "20.11.0",
},
},
])
# ===========================================================================
# 1. pm2_backend._find_pm2
# ===========================================================================
class TestFindPm2:
"""Tests for pm2 binary discovery."""
@patch("shutil.which", return_value="/opt/homebrew/bin/pm2")
def test_find_pm2_found(self, mock_which):
from cli_anything.pm2.utils.pm2_backend import _find_pm2
assert _find_pm2() == "/opt/homebrew/bin/pm2"
@patch("shutil.which", return_value=None)
def test_find_pm2_not_found_raises(self, mock_which):
from cli_anything.pm2.utils.pm2_backend import _find_pm2
with pytest.raises(RuntimeError, match="pm2 not found"):
_find_pm2()
# ===========================================================================
# 2. pm2_backend.run_pm2
# ===========================================================================
class TestRunPm2:
"""Tests for the core run_pm2 subprocess wrapper."""
@patch("cli_anything.pm2.utils.pm2_backend._get_pm2", return_value="/usr/bin/pm2")
@patch("subprocess.run")
def test_run_pm2_success(self, mock_run, mock_get):
mock_run.return_value = _make_run_result(stdout="OK\n", returncode=0)
from cli_anything.pm2.utils.pm2_backend import run_pm2
result = run_pm2("save")
assert result["success"] is True
assert result["returncode"] == 0
assert "OK" in result["stdout"]
@patch("cli_anything.pm2.utils.pm2_backend._get_pm2", return_value="/usr/bin/pm2")
@patch("subprocess.run")
def test_run_pm2_failure(self, mock_run, mock_get):
mock_run.return_value = _make_run_result(stderr="error", returncode=1)
from cli_anything.pm2.utils.pm2_backend import run_pm2
result = run_pm2("restart", "ghost")
assert result["success"] is False
assert result["returncode"] == 1
@patch("cli_anything.pm2.utils.pm2_backend._get_pm2", return_value="/usr/bin/pm2")
@patch("subprocess.run")
def test_run_pm2_json_parsing(self, mock_run, mock_get):
mock_run.return_value = _make_run_result(stdout=FAKE_JLIST, returncode=0)
from cli_anything.pm2.utils.pm2_backend import run_pm2
result = run_pm2("jlist", capture_json=True)
assert result["success"] is True
assert isinstance(result["data"], list)
assert len(result["data"]) == 2
assert result["data"][0]["name"] == "seaclip-dev"
@patch("cli_anything.pm2.utils.pm2_backend._get_pm2", return_value="/usr/bin/pm2")
@patch("subprocess.run")
def test_run_pm2_json_with_preamble(self, mock_run, mock_get):
"""JSON extraction works even with non-JSON text before the array."""
preamble = "PM2 info line\n" + FAKE_JLIST
mock_run.return_value = _make_run_result(stdout=preamble, returncode=0)
from cli_anything.pm2.utils.pm2_backend import run_pm2
result = run_pm2("jlist", capture_json=True)
assert result["data"] is not None
assert isinstance(result["data"], list)
@patch("cli_anything.pm2.utils.pm2_backend._get_pm2", return_value="/usr/bin/pm2")
@patch("subprocess.run", side_effect=subprocess.TimeoutExpired(cmd="pm2", timeout=30))
def test_run_pm2_timeout(self, mock_run, mock_get):
from cli_anything.pm2.utils.pm2_backend import run_pm2
result = run_pm2("jlist", timeout=30)
assert result["success"] is False
assert "timed out" in result["stderr"]
@patch("cli_anything.pm2.utils.pm2_backend._get_pm2", return_value="/nonexistent/pm2")
@patch("subprocess.run", side_effect=FileNotFoundError())
def test_run_pm2_binary_missing(self, mock_run, mock_get):
from cli_anything.pm2.utils.pm2_backend import run_pm2
result = run_pm2("jlist")
assert result["success"] is False
assert "not found" in result["stderr"]
# ===========================================================================
# 3. core/processes.py
# ===========================================================================
class TestProcesses:
"""Tests for process listing, describe, and metrics."""
@patch("cli_anything.pm2.core.processes.pm2_jlist")
def test_list_processes_json(self, mock_jlist):
mock_jlist.return_value = json.loads(FAKE_JLIST)
from cli_anything.pm2.core.processes import list_processes
result = list_processes(as_json=True)
assert isinstance(result, list)
assert len(result) == 2
@patch("cli_anything.pm2.core.processes.pm2_jlist")
def test_list_processes_human(self, mock_jlist):
mock_jlist.return_value = json.loads(FAKE_JLIST)
from cli_anything.pm2.core.processes import list_processes
result = list_processes(as_json=False)
assert isinstance(result, list)
assert result[0]["name"] == "seaclip-dev"
assert result[0]["status"] == "online"
@patch("cli_anything.pm2.core.processes.pm2_jlist")
def test_list_processes_empty(self, mock_jlist):
mock_jlist.return_value = []
from cli_anything.pm2.core.processes import list_processes
result = list_processes(as_json=False)
assert result == "No PM2 processes running."
@patch("cli_anything.pm2.core.processes.pm2_describe")
def test_describe_process_found(self, mock_desc):
mock_desc.return_value = json.loads(FAKE_JLIST)[0]
from cli_anything.pm2.core.processes import describe_process
result = describe_process("seaclip-dev", as_json=False)
assert isinstance(result, dict)
assert result["Name"] == "seaclip-dev"
@patch("cli_anything.pm2.core.processes.pm2_describe")
def test_describe_process_not_found(self, mock_desc):
mock_desc.return_value = None
from cli_anything.pm2.core.processes import describe_process
result = describe_process("ghost", as_json=False)
assert result is None
@patch("cli_anything.pm2.core.processes.pm2_jlist")
def test_get_metrics_json(self, mock_jlist):
mock_jlist.return_value = json.loads(FAKE_JLIST)
from cli_anything.pm2.core.processes import get_metrics
result = get_metrics(as_json=True)
assert isinstance(result, list)
assert result[0]["cpu"] == 2.5
# ===========================================================================
# 4. core/lifecycle.py
# ===========================================================================
class TestLifecycle:
"""Tests for lifecycle commands."""
@patch("cli_anything.pm2.core.lifecycle.pm2_action")
def test_restart_success(self, mock_action):
mock_action.return_value = {
"success": True, "returncode": 0,
"stdout": "restarted", "stderr": "",
}
from cli_anything.pm2.core.lifecycle import restart_process
result = restart_process("seaclip-dev", as_json=False)
assert result["success"] is True
assert "Restarted" in result["message"]
@patch("cli_anything.pm2.core.lifecycle.pm2_action")
def test_stop_failure(self, mock_action):
mock_action.return_value = {
"success": False, "returncode": 1,
"stdout": "", "stderr": "process not found",
}
from cli_anything.pm2.core.lifecycle import stop_process
result = stop_process("ghost", as_json=False)
assert result["success"] is False
assert "Failed" in result["message"]
@patch("cli_anything.pm2.core.lifecycle.backend_start")
def test_start_with_name(self, mock_start):
mock_start.return_value = {
"success": True, "returncode": 0,
"stdout": "started", "stderr": "",
}
from cli_anything.pm2.core.lifecycle import start_process
result = start_process("/app/index.js", name="my-app", as_json=True)
assert result["success"] is True
assert result["name"] == "my-app"
# ===========================================================================
# 5. core/logs.py
# ===========================================================================
class TestLogs:
"""Tests for log commands."""
@patch("cli_anything.pm2.core.logs.backend_logs")
def test_view_logs_success(self, mock_logs):
mock_logs.return_value = {
"success": True, "returncode": 0,
"stdout": "line1\nline2\n", "stderr": "",
}
from cli_anything.pm2.core.logs import view_logs
result = view_logs("seaclip-dev", lines=20, as_json=False)
assert result["success"] is True
assert "line1" in result["content"]
@patch("cli_anything.pm2.core.logs.backend_flush")
def test_flush_all(self, mock_flush):
mock_flush.return_value = {
"success": True, "returncode": 0,
"stdout": "flushed", "stderr": "",
}
from cli_anything.pm2.core.logs import flush_logs
result = flush_logs(name=None, as_json=False)
assert result["success"] is True
assert "all processes" in result["message"]
# ===========================================================================
# 6. core/system.py
# ===========================================================================
class TestSystem:
"""Tests for system commands."""
@patch("cli_anything.pm2.core.system.backend_version")
def test_version_json(self, mock_ver):
mock_ver.return_value = "5.3.0"
from cli_anything.pm2.core.system import version
result = version(as_json=True)
assert result["version"] == "5.3.0"
@patch("cli_anything.pm2.core.system.backend_save")
def test_save_success(self, mock_save):
mock_save.return_value = {
"success": True, "returncode": 0,
"stdout": "saved", "stderr": "",
}
from cli_anything.pm2.core.system import save
result = save(as_json=False)
assert result["success"] is True
assert "saved" in result["message"].lower()
# ===========================================================================
# 7. Output formatting (_output helper)
# ===========================================================================
class TestOutputFormatting:
"""Tests for the _output helper in pm2_cli."""
def test_output_json_string(self, capsys):
from cli_anything.pm2.pm2_cli import _output
_output("hello", as_json=True)
captured = capsys.readouterr()
parsed = json.loads(captured.out)
assert parsed["result"] == "hello"
def test_output_json_dict(self, capsys):
from cli_anything.pm2.pm2_cli import _output
_output({"key": "val"}, as_json=True)
captured = capsys.readouterr()
parsed = json.loads(captured.out)
assert parsed["key"] == "val"
def test_output_human_string(self, capsys):
from cli_anything.pm2.pm2_cli import _output
_output("hello world", as_json=False)
captured = capsys.readouterr()
assert "hello world" in captured.out
def test_output_human_dict_with_message(self, capsys):
from cli_anything.pm2.pm2_cli import _output
_output({"success": True, "message": "Done"}, as_json=False)
captured = capsys.readouterr()
assert "[OK] Done" in captured.out
def test_output_human_error_message(self, capsys):
from cli_anything.pm2.pm2_cli import _output
_output({"success": False, "message": "Oops"}, as_json=False)
captured = capsys.readouterr()
assert "[ERROR] Oops" in captured.out
# ===========================================================================
# 8. Utility: _format_bytes
# ===========================================================================
class TestFormatBytes:
"""Tests for the byte formatting utility."""
def test_zero_bytes(self):
from cli_anything.pm2.core.processes import _format_bytes
assert _format_bytes(0) == "0 B"
def test_megabytes(self):
from cli_anything.pm2.core.processes import _format_bytes
result = _format_bytes(52428800) # 50 MB
assert "MB" in result
assert "50.0" in result
@@ -0,0 +1,157 @@
"""End-to-end tests for the PM2 CLI-Anything harness.
These tests call the REAL pm2 binary and the installed cli-anything-pm2
CLI. They require:
- pm2 installed globally (npm install -g pm2)
- cli-anything-pm2 installed (pip install -e .)
- PM2 daemon running with at least one process
Skip gracefully if pm2 is not available.
"""
import json
import os
import shutil
import subprocess
import sys
import pytest
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _resolve_cli() -> str:
"""Resolve the cli-anything-pm2 binary path.
Checks: shutil.which, then common venv/bin locations.
"""
binary = shutil.which("cli-anything-pm2")
if binary:
return binary
# Try the venv bin dir that matches the running Python
venv_bin = os.path.join(os.path.dirname(sys.executable), "cli-anything-pm2")
if os.path.isfile(venv_bin):
return venv_bin
pytest.skip("cli-anything-pm2 binary not found on PATH")
def _has_pm2() -> bool:
"""Check whether pm2 is installed."""
return shutil.which("pm2") is not None
def _run_cli(*args: str, timeout: int = 30) -> subprocess.CompletedProcess:
"""Run cli-anything-pm2 with the given arguments."""
cli = _resolve_cli()
cmd = [cli, *args]
return subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout,
)
# Skip entire module if pm2 is not installed
pytestmark = pytest.mark.skipif(
not _has_pm2(),
reason="pm2 is not installed -- skipping E2E tests",
)
# ===========================================================================
# E2E Tests
# ===========================================================================
class TestProcessListE2E:
"""E2E tests for the process list command."""
def test_process_list_json_returns_valid_json(self):
"""cli-anything-pm2 --json process list outputs parseable JSON."""
result = _run_cli("--json", "process", "list")
assert result.returncode == 0, f"stderr: {result.stderr}"
data = json.loads(result.stdout)
assert isinstance(data, list)
def test_process_list_human_returns_zero(self):
"""cli-anything-pm2 process list exits 0."""
result = _run_cli("process", "list")
assert result.returncode == 0
class TestProcessDescribeE2E:
"""E2E tests for process describe."""
def test_describe_existing_process_json(self):
"""Describe a known process and get valid JSON."""
# First get list to find an actual process name
list_result = _run_cli("--json", "process", "list")
if list_result.returncode != 0:
pytest.skip("Could not list processes")
processes = json.loads(list_result.stdout)
if not processes:
pytest.skip("No PM2 processes running")
# Pick first process
name = processes[0].get("name") or str(processes[0].get("pm_id", 0))
result = _run_cli("--json", "process", "describe", name)
assert result.returncode == 0
data = json.loads(result.stdout)
assert isinstance(data, dict)
def test_describe_nonexistent_process(self):
"""Describe a process that does not exist exits with code 1."""
result = _run_cli("--json", "process", "describe", "__nonexistent_process_xyz__")
assert result.returncode != 0
class TestSystemE2E:
"""E2E tests for system commands."""
def test_system_version_json(self):
"""cli-anything-pm2 --json system version returns valid JSON with version key."""
result = _run_cli("--json", "system", "version")
assert result.returncode == 0, f"stderr: {result.stderr}"
data = json.loads(result.stdout)
assert "version" in data
# PM2 version is a semver string like "5.3.0"
assert len(data["version"]) > 0
def test_system_version_human(self):
"""cli-anything-pm2 system version returns a version string."""
result = _run_cli("system", "version")
assert result.returncode == 0
# Should contain at least a digit (version number)
assert any(c.isdigit() for c in result.stdout)
class TestHelpE2E:
"""E2E tests for help output."""
def test_help_flag_exits_zero(self):
"""cli-anything-pm2 --help exits with code 0."""
result = _run_cli("--help")
assert result.returncode == 0
assert "CLI-Anything PM2" in result.stdout or "Usage" in result.stdout
def test_process_help(self):
"""cli-anything-pm2 process --help shows subcommands."""
result = _run_cli("process", "--help")
assert result.returncode == 0
assert "list" in result.stdout
assert "describe" in result.stdout
class TestProcessMetricsE2E:
"""E2E test for process metrics."""
def test_metrics_json_returns_list(self):
"""cli-anything-pm2 --json process metrics returns a JSON list."""
result = _run_cli("--json", "process", "metrics")
assert result.returncode == 0
data = json.loads(result.stdout)
assert isinstance(data, list)
@@ -0,0 +1,243 @@
"""PM2 Backend — subprocess wrapper for all PM2 CLI commands.
All PM2 interactions go through this module. It finds the pm2 binary,
runs commands via subprocess.run(), and returns structured results.
"""
import json
import os
import shutil
import subprocess
from typing import Any
# Common directories where pm2 may be installed (Homebrew, global npm, system).
_EXTRA_PATH_DIRS = ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin"]
def _augmented_path(base_path: str | None = None) -> str:
"""Return PATH string with _EXTRA_PATH_DIRS prepended if missing."""
path = base_path if base_path is not None else os.environ.get("PATH", "")
for p in _EXTRA_PATH_DIRS:
if p not in path:
path = f"{p}:{path}"
return path
def _find_pm2() -> str:
"""Locate the pm2 binary on the system.
Checks common Homebrew and global npm paths in addition to PATH.
Returns:
Absolute path to the pm2 binary.
Raises:
RuntimeError: If pm2 is not found.
"""
pm2_path = shutil.which("pm2", path=_augmented_path())
if pm2_path is None:
raise RuntimeError(
"pm2 not found on this system. "
"Install it with: npm install -g pm2"
)
return pm2_path
# Cache the pm2 path at module level
_PM2_BIN: str | None = None
def _get_pm2() -> str:
"""Get cached pm2 binary path."""
global _PM2_BIN
if _PM2_BIN is None:
_PM2_BIN = _find_pm2()
return _PM2_BIN
def _build_env() -> dict[str, str]:
"""Build environment dict with proper PATH for subprocess."""
env = os.environ.copy()
env["PATH"] = _augmented_path(env.get("PATH", ""))
return env
def run_pm2(
*args: str,
capture_json: bool = False,
timeout: int = 30,
) -> dict[str, Any]:
"""Run a pm2 command and return the result.
Args:
*args: Arguments to pass to pm2 (e.g., "jlist", "restart", "myapp").
capture_json: If True, attempt to parse stdout as JSON.
timeout: Command timeout in seconds.
Returns:
Dict with keys:
- success (bool): Whether command exited with code 0.
- returncode (int): Process return code.
- stdout (str): Raw stdout.
- stderr (str): Raw stderr.
- data (Any): Parsed JSON data if capture_json=True, else None.
"""
pm2 = _get_pm2()
cmd = [pm2, *args]
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout,
env=_build_env(),
)
except subprocess.TimeoutExpired:
return {
"success": False,
"returncode": -1,
"stdout": "",
"stderr": f"Command timed out after {timeout}s: {' '.join(cmd)}",
"data": None,
}
except FileNotFoundError:
return {
"success": False,
"returncode": -1,
"stdout": "",
"stderr": f"pm2 binary not found at: {pm2}",
"data": None,
}
parsed_data = None
if capture_json and result.returncode == 0:
try:
parsed_data = json.loads(result.stdout)
except (json.JSONDecodeError, ValueError):
# stdout may contain non-JSON preamble; try to extract JSON array
stdout = result.stdout.strip()
# Look for JSON array or object
for start_char, end_char in [("[", "]"), ("{", "}")]:
idx_start = stdout.find(start_char)
idx_end = stdout.rfind(end_char)
if idx_start != -1 and idx_end > idx_start:
try:
parsed_data = json.loads(stdout[idx_start:idx_end + 1])
break
except (json.JSONDecodeError, ValueError):
continue
return {
"success": result.returncode == 0,
"returncode": result.returncode,
"stdout": result.stdout,
"stderr": result.stderr,
"data": parsed_data,
}
def pm2_jlist() -> list[dict[str, Any]]:
"""Get JSON list of all PM2 processes.
Returns:
List of process info dicts, or empty list on failure.
"""
result = run_pm2("jlist", capture_json=True)
if result["success"] and isinstance(result["data"], list):
return result["data"]
return []
def pm2_describe(name: str) -> dict[str, Any] | None:
"""Get detailed info for a specific process.
Uses pm2 jlist and filters by name/id, since pm2 describe
does not produce JSON output.
Args:
name: Process name or ID.
Returns:
Process description dict, or None on failure.
"""
processes = pm2_jlist()
for p in processes:
if p.get("name") == name or str(p.get("pm_id")) == str(name):
return p
return None
def pm2_action(action: str, name: str) -> dict[str, Any]:
"""Run a lifecycle action (restart, stop, delete) on a process.
Args:
action: One of "restart", "stop", "delete".
name: Process name or ID.
Returns:
Result dict from run_pm2.
"""
return run_pm2(action, str(name))
def pm2_start(script: str, name: str | None = None) -> dict[str, Any]:
"""Start a new PM2 process.
Args:
script: Path to script or ecosystem file.
name: Optional process name.
Returns:
Result dict from run_pm2.
"""
args = ["start", script]
if name:
args.extend(["--name", name])
return run_pm2(*args)
def pm2_logs(name: str, lines: int = 20) -> dict[str, Any]:
"""Get recent logs for a process.
Args:
name: Process name or ID.
lines: Number of log lines to retrieve.
Returns:
Result dict from run_pm2.
"""
return run_pm2("logs", str(name), "--lines", str(lines), "--nostream")
def pm2_flush(name: str | None = None) -> dict[str, Any]:
"""Flush logs for a process or all processes.
Args:
name: Process name or ID. If None, flushes all.
Returns:
Result dict from run_pm2.
"""
args = ["flush"]
if name:
args.append(str(name))
return run_pm2(*args)
def pm2_save() -> dict[str, Any]:
"""Save the current PM2 process list."""
return run_pm2("save")
def pm2_startup() -> dict[str, Any]:
"""Generate PM2 startup script."""
return run_pm2("startup")
def pm2_version() -> str:
"""Get PM2 version string."""
result = run_pm2("--version")
if result["success"]:
return result["stdout"].strip()
return "unknown"
@@ -0,0 +1,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
}
+44
View File
@@ -0,0 +1,44 @@
"""Setup for cli-anything-pm2 — CLI harness for PM2 process management."""
from setuptools import setup, find_namespace_packages
setup(
name="cli-anything-pm2",
version="1.0.0",
author="cli-anything contributors",
author_email="",
description="CLI-Anything harness for PM2 process management",
url="https://github.com/HKUDS/CLI-Anything",
packages=find_namespace_packages(include=["cli_anything.*"]),
classifiers=[
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"Topic :: Software Development :: Libraries :: Python Modules",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
],
python_requires=">=3.10",
install_requires=[
"click>=8.0.0",
"prompt-toolkit>=3.0.0",
],
extras_require={
"dev": [
"pytest>=7.0.0",
"pytest-cov>=4.0.0",
],
},
entry_points={
"console_scripts": [
"cli-anything-pm2=cli_anything.pm2.pm2_cli:main",
],
},
package_data={
"cli_anything.pm2": ["skills/*.md"],
},
include_package_data=True,
zip_safe=False,
)