Files
fno2010-cm-cli-rest/cm_cli/executor.py
T
Jensen Zhang 4f241f7d32 Fix cm-cli auto-detection and add development guidelines
- Update CMCLIExecutor to detect cm-cli as executable (not just .py script)
- Use shutil.which() to find cm-cli in PATH (supports uv/pip installations)
- Fall back to .py script detection for legacy installations
- Add comprehensive .gitignore for Python, IDE, and ComfyUI artifacts
- Expand AGENTS.md with build commands, code style, and architecture patterns
- Remove trailing whitespace across all source files
2026-03-16 02:33:46 +08:00

352 lines
11 KiB
Python

"""
cm-cli executor - Wraps ComfyUI-Manager CLI commands with async subprocess execution.
This module provides a Python interface to cm-cli (ComfyUI-Manager CLI) commands,
handling subprocess execution, output parsing, and job tracking for long-running operations.
"""
import asyncio
import json
import os
import subprocess
import uuid
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from pathlib import Path
from typing import Any, Dict, List, Optional
class CMCLIError(Exception):
"""Base exception for cm-cli execution errors."""
def __init__(self, message: str, command: Optional[List[str]] = None, exit_code: Optional[int] = None):
self.message = message
self.command = command
self.exit_code = exit_code
super().__init__(self.message)
class JobStatus(str, Enum):
"""Status of an async cm-cli job."""
PENDING = "pending"
RUNNING = "running"
COMPLETED = "completed"
FAILED = "failed"
@dataclass
class CMCLIJob:
"""Represents an async cm-cli job with tracking information."""
job_id: str
command: List[str]
status: JobStatus = JobStatus.PENDING
exit_code: Optional[int] = None
stdout: str = ""
stderr: str = ""
created_at: datetime = field(default_factory=datetime.now)
started_at: Optional[datetime] = None
completed_at: Optional[datetime] = None
error: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
"""Convert job to dictionary for JSON serialization."""
return {
"job_id": self.job_id,
"command": " ".join(self.command),
"status": self.status.value,
"exit_code": self.exit_code,
"stdout": self.stdout,
"stderr": self.stderr,
"created_at": self.created_at.isoformat(),
"started_at": self.started_at.isoformat() if self.started_at else None,
"completed_at": self.completed_at.isoformat() if self.completed_at else None,
"error": self.error,
}
class CMCLIExecutor:
"""
Executor for cm-cli commands with async support and job tracking.
Usage:
executor = CMCLIExecutor(cm_cli_path="/path/to/cm-cli")
# Synchronous execution
result = await executor.execute(["show", "installed"])
# Asynchronous job (for long-running operations)
job = await executor.execute_async(["install", "ComfyUI-Impact-Pack"])
"""
def __init__(
self,
cm_cli_path: Optional[str] = None,
python_path: str = "python",
working_dir: Optional[str] = None,
):
"""
Initialize the cm-cli executor.
Args:
cm_cli_path: Path to cm-cli executable. If None, auto-detects from PATH or ComfyUI-Manager.
python_path: Path to Python interpreter (used only if cm-cli is a .py script).
working_dir: Working directory for subprocess execution (usually ComfyUI root).
"""
self.python_path = python_path
self.working_dir = Path(working_dir) if working_dir else Path.cwd()
self._active_jobs: Dict[str, CMCLIJob] = {}
# Auto-detect cm-cli path if not provided
if cm_cli_path:
self.cm_cli_path = Path(cm_cli_path)
else:
self.cm_cli_path = self._auto_detect_cm_cli()
def _auto_detect_cm_cli(self) -> Path:
"""
Auto-detect cm-cli executable location.
Searches in order:
1. System PATH (cm-cli installed as console script by uv/pip)
2. ComfyUI-Manager custom_nodes directory (cm-cli.py script)
3. Current working directory
Returns:
Path to cm-cli executable or script.
"""
import shutil
# First, try to find cm-cli in PATH (most common for uv/pip installs)
cm_cli_in_path = shutil.which("cm-cli")
if cm_cli_in_path:
return Path(cm_cli_in_path)
# Fallback: check if it's cm_cli (underscore variant)
cm_cli_underscore = shutil.which("cm_cli")
if cm_cli_underscore:
return Path(cm_cli_underscore)
# Last resort: check common ComfyUI-Manager locations for the script
possible_paths = [
self.working_dir / "custom_nodes" / "ComfyUI-Manager" / "cm-cli.py",
self.working_dir / "custom_nodes" / "ComfyUI-Manager" / "cm_cli.py",
self.working_dir / "cm-cli.py",
]
for path in possible_paths:
if path.exists():
return path
# If not found, return 'cm-cli' and let the subprocess fail with a clear error
return Path("cm-cli")
async def execute(
self,
args: List[str],
timeout: Optional[int] = None,
capture_output: bool = True,
) -> Dict[str, Any]:
"""
Execute a cm-cli command synchronously.
Args:
args: Command arguments (e.g., ["show", "installed"]).
timeout: Timeout in seconds.
capture_output: Whether to capture stdout/stderr.
Returns:
Dictionary with 'success', 'stdout', 'stderr', 'exit_code'.
Raises:
CMCLIError: If command execution fails.
"""
# Build command: use cm-cli directly as executable
# If it's a .py file, prepend python interpreter
if self.cm_cli_path.suffix == ".py":
command = [self.python_path, str(self.cm_cli_path)] + args
else:
command = [str(self.cm_cli_path)] + args
try:
process = await asyncio.create_subprocess_exec(
*command,
stdout=asyncio.subprocess.PIPE if capture_output else None,
stderr=asyncio.subprocess.PIPE if capture_output else None,
cwd=self.working_dir,
)
if capture_output:
stdout, stderr = await asyncio.wait_for(
process.communicate(),
timeout=timeout,
)
stdout_str = stdout.decode("utf-8", errors="replace") if stdout else ""
stderr_str = stderr.decode("utf-8", errors="replace") if stderr else ""
else:
await asyncio.wait_for(process.wait(), timeout=timeout)
stdout_str = ""
stderr_str = ""
success = process.returncode == 0
return {
"success": success,
"stdout": stdout_str,
"stderr": stderr_str,
"exit_code": process.returncode,
"command": " ".join(command),
}
except asyncio.TimeoutError as e:
raise CMCLIError(
f"Command timed out after {timeout} seconds",
command=command,
) from e
except FileNotFoundError as e:
raise CMCLIError(
f"cm-cli not found at {self.cm_cli_path}. "
"Ensure ComfyUI-Manager is installed and cm-cli is in PATH, "
"or set cm_cli_path in configuration.",
command=command,
) from e
except Exception as e:
raise CMCLIError(
f"Failed to execute command: {str(e)}",
command=command,
) from e
async def execute_async(
self,
args: List[str],
timeout: Optional[int] = None,
) -> CMCLIJob:
"""
Execute a cm-cli command asynchronously with job tracking.
Use this for long-running operations like install, update, etc.
Args:
args: Command arguments.
timeout: Timeout in seconds.
Returns:
CMCLIJob object for tracking execution.
"""
job_id = str(uuid.uuid4())[:8]
# Build command: use cm-cli directly as executable
# If it's a .py file, prepend python interpreter
if self.cm_cli_path.suffix == ".py":
command = [self.python_path, str(self.cm_cli_path)] + args
else:
command = [str(self.cm_cli_path)] + args
job = CMCLIJob(
job_id=job_id,
command=command,
status=JobStatus.RUNNING,
started_at=datetime.now(),
)
self._active_jobs[job_id] = job
try:
process = await asyncio.create_subprocess_exec(
*command,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=self.working_dir,
)
stdout, stderr = await asyncio.wait_for(
process.communicate(),
timeout=timeout,
)
job.stdout = stdout.decode("utf-8", errors="replace")
job.stderr = stderr.decode("utf-8", errors="replace")
job.exit_code = process.returncode
job.status = JobStatus.COMPLETED if process.returncode == 0 else JobStatus.FAILED
if process.returncode != 0:
job.error = job.stderr.strip() or f"Exit code: {process.returncode}"
except asyncio.TimeoutError:
job.status = JobStatus.FAILED
job.error = f"Command timed out after {timeout} seconds"
if process:
try:
process.kill()
except:
pass
except Exception as e:
job.status = JobStatus.FAILED
job.error = str(e)
finally:
job.completed_at = datetime.now()
return job
def get_job(self, job_id: str) -> Optional[CMCLIJob]:
"""Get job by ID."""
return self._active_jobs.get(job_id)
def list_jobs(self) -> List[CMCLIJob]:
"""List all active jobs."""
return list(self._active_jobs.values())
def cleanup_job(self, job_id: str) -> bool:
"""Remove job from tracking. Returns True if job existed."""
if job_id in self._active_jobs:
del self._active_jobs[job_id]
return True
return False
async def parse_installed_nodes(self, output: str) -> List[Dict[str, str]]:
"""
Parse output from 'cm-cli.py show installed' command.
Expected format:
Node Name [repo_url]
- version: 1.0.0
Returns:
List of dictionaries with 'name', 'repo', 'version' keys.
"""
nodes = []
current_node = None
for line in output.strip().split("\n"):
line = line.strip()
if not line:
continue
# Node name line (contains [repo])
if "[" in line and "]" in line:
if current_node:
nodes.append(current_node)
name_part = line.split("[")[0].strip()
repo_part = line.split("[")[1].split("]")[0] if "[" in line else ""
current_node = {
"name": name_part,
"repo": repo_part,
"version": "unknown",
}
# Version line
elif line.startswith("- version:"):
if current_node:
current_node["version"] = line.split(":")[1].strip()
if current_node:
nodes.append(current_node)
return nodes