- 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
352 lines
11 KiB
Python
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
|