Files
Jensen Zhang d7a15a7d5d Add comfy-cli REST API support
- Implement ComfyCliExecutor for async command execution
- Add REST API endpoints for comfy-cli:
  - Configuration management (/comfy-cli/config/*)
  - Model management (/comfy-cli/model/*)
  - Node management (/comfy-cli/node/*)
- Create API handlers: ConfigHandler, ModelHandler, NodeHandler
- Register routes in comfy_cli_routes.py
- Update main __init__.py to support dual CLI (cm-cli + comfy-cli)
- Add comprehensive documentation:
  - COMFY_CLI_API.md: Complete API reference
  - COMFY_CLI_QUICKSTART.md: Quick start guide
  - Update README.md with dual CLI support
  - Update AGENTS.md development guide
- Add comfy-cli reference documentation in docs/references/
- Remove trailing spaces from all Python and Markdown files
- Ensure all code passes syntax validation

Features:
- Async job tracking for long-running operations
- Structured JSON responses
- Comprehensive error handling
- Direct filesystem access for model listing (more reliable)
- Support for CivitAI and HuggingFace API tokens
2026-03-17 21:55:32 +08:00

355 lines
12 KiB
Python

"""
Node management API handler for comfy-cli.
Handles:
- GET /comfy-cli/node/simple-show
- GET /comfy-cli/node/show
- POST /comfy-cli/node/install
- POST /comfy-cli/node/update
- POST /comfy-cli/node/enable
- POST /comfy-cli/node/disable
- POST /comfy-cli/node/uninstall
"""
from aiohttp import web
import logging
from typing import Any, Dict, List
from ...comfy_cli.executor import ComfyCliExecutor, ComfyCLIError, JobStatus
logger = logging.getLogger(__name__)
class NodeHandler:
"""Handler for comfy-cli node management endpoints."""
def __init__(self, executor: ComfyCliExecutor):
self.executor = executor
async def simple_show(self, request: web.Request) -> web.Response:
"""GET /comfy-cli/node/simple-show?mode=installed"""
mode = request.query.get("mode", "installed")
valid_modes = ["installed", "enabled", "disabled", "not-installed", "all"]
if mode not in valid_modes:
return web.json_response(
{
"success": False,
"error": {
"code": "INVALID_PARAMETER",
"message": f"mode must be one of: {', '.join(valid_modes)}",
},
},
status=400,
)
try:
result = await self.executor.execute(
["node", "simple-show", mode], timeout=60
)
if not result["success"]:
return web.json_response(
{
"success": False,
"error": {
"code": "COMMAND_FAILED",
"message": result["stderr"] or "Failed to list nodes",
},
},
status=500,
)
node_names = self.executor.parse_list_output(result["stdout"])
return web.json_response(
{
"success": True,
"data": {
"mode": mode,
"nodes": node_names,
"total_count": len(node_names),
},
"raw_output": result["stdout"],
}
)
except ComfyCLIError as e:
logger.error(f"simple_show failed: {e}")
return web.json_response(
{"success": False, "error": {"code": "COMFY_CLI_ERROR", "message": e.message}},
status=500,
)
except Exception as e:
logger.exception(f"simple_show failed: {e}")
return web.json_response(
{"success": False, "error": {"code": "INTERNAL_ERROR", "message": str(e)}},
status=500,
)
async def show(self, request: web.Request) -> web.Response:
"""GET /comfy-cli/node/show?mode=installed"""
mode = request.query.get("mode", "installed")
valid_modes = ["installed", "enabled", "disabled", "all"]
if mode not in valid_modes:
return web.json_response(
{
"success": False,
"error": {
"code": "INVALID_PARAMETER",
"message": f"mode must be one of: {', '.join(valid_modes)}",
},
},
status=400,
)
try:
result = await self.executor.execute(["node", "show", mode], timeout=60)
if not result["success"]:
return web.json_response(
{
"success": False,
"error": {
"code": "COMMAND_FAILED",
"message": result["stderr"] or "Failed to show nodes",
},
},
status=500,
)
nodes = self._parse_node_show_output(result["stdout"])
return web.json_response(
{
"success": True,
"data": {"mode": mode, "nodes": nodes, "total_count": len(nodes)},
"raw_output": result["stdout"],
}
)
except Exception as e:
logger.exception(f"show failed: {e}")
return web.json_response(
{"success": False, "error": {"code": "INTERNAL_ERROR", "message": str(e)}},
status=500,
)
def _parse_node_show_output(self, raw_output: str) -> List[Dict[str, Any]]:
"""Parse detailed node show output."""
nodes = []
current_node = {}
for line in raw_output.strip().split("\n"):
line = line.strip()
if not line:
if current_node:
nodes.append(current_node)
current_node = {}
continue
if line.startswith("Node:"):
if current_node:
nodes.append(current_node)
current_node = {"name": line.replace("Node:", "").strip()}
elif ":" in line and current_node:
key, value = line.split(":", 1)
key = key.strip().lower().replace(" ", "_")
value = value.strip()
if key == "requirements":
if value == "-":
current_node[key] = []
else:
current_node[key] = [r.strip() for r in value.split(",")]
else:
current_node[key] = value
if current_node:
nodes.append(current_node)
return nodes
async def install_node(self, request: web.Request) -> web.Response:
"""POST /comfy-cli/node/install (async)"""
try:
data = await request.json()
nodes = data.get("nodes", [])
if not nodes:
return web.json_response(
{
"success": False,
"error": {"code": "MISSING_PARAMETER", "message": "nodes is required"},
},
status=400,
)
args = ["node", "install"] + nodes
if data.get("fast_deps"):
args.append("--fast-deps")
if data.get("no_deps"):
args.append("--no-deps")
if data.get("channel"):
args.extend(["--channel", data["channel"]])
job = await self.executor.execute_async(args, timeout=300)
return web.json_response(
{
"success": True,
"job_id": job.job_id,
"status": job.status.value,
"message": f"Installing {len(nodes)} node(s)",
"data": {"nodes": nodes},
},
status=202,
)
except Exception as e:
logger.exception(f"install_node failed: {e}")
return web.json_response(
{"success": False, "error": {"code": "INTERNAL_ERROR", "message": str(e)}},
status=500,
)
async def update_node(self, request: web.Request) -> web.Response:
"""POST /comfy-cli/node/update (async)"""
try:
data = await request.json()
target = data.get("target", "all")
args = ["node", "update", target]
if data.get("channel"):
args.extend(["--channel", data["channel"]])
if data.get("mode"):
args.extend(["--mode", data["mode"]])
job = await self.executor.execute_async(args, timeout=300)
return web.json_response(
{
"success": True,
"job_id": job.job_id,
"status": job.status.value,
"message": "Updating nodes",
"data": {"target": target},
},
status=202,
)
except Exception as e:
logger.exception(f"update_node failed: {e}")
return web.json_response(
{"success": False, "error": {"code": "INTERNAL_ERROR", "message": str(e)}},
status=500,
)
async def enable_node(self, request: web.Request) -> web.Response:
"""POST /comfy-cli/node/enable"""
try:
data = await request.json()
node = data.get("node")
if not node:
return web.json_response(
{"success": False, "error": {"code": "MISSING_PARAMETER", "message": "node is required"}},
status=400,
)
result = await self.executor.execute(["node", "enable", node], timeout=60)
if not result["success"]:
return web.json_response(
{
"success": False,
"error": {"code": "COMMAND_FAILED", "message": result["stderr"] or "Failed to enable node"},
},
status=500,
)
return web.json_response(
{"success": True, "data": {"node": node, "action": "enabled"}, "raw_output": result["stdout"]}
)
except Exception as e:
logger.exception(f"enable_node failed: {e}")
return web.json_response(
{"success": False, "error": {"code": "INTERNAL_ERROR", "message": str(e)}},
status=500,
)
async def disable_node(self, request: web.Request) -> web.Response:
"""POST /comfy-cli/node/disable"""
try:
data = await request.json()
node = data.get("node")
if not node:
return web.json_response(
{"success": False, "error": {"code": "MISSING_PARAMETER", "message": "node is required"}},
status=400,
)
result = await self.executor.execute(["node", "disable", node], timeout=60)
if not result["success"]:
return web.json_response(
{
"success": False,
"error": {"code": "COMMAND_FAILED", "message": result["stderr"] or "Failed to disable node"},
},
status=500,
)
return web.json_response(
{"success": True, "data": {"node": node, "action": "disabled"}, "raw_output": result["stdout"]}
)
except Exception as e:
logger.exception(f"disable_node failed: {e}")
return web.json_response(
{"success": False, "error": {"code": "INTERNAL_ERROR", "message": str(e)}},
status=500,
)
async def uninstall_node(self, request: web.Request) -> web.Response:
"""POST /comfy-cli/node/uninstall"""
try:
data = await request.json()
node = data.get("node")
if not node:
return web.json_response(
{"success": False, "error": {"code": "MISSING_PARAMETER", "message": "node is required"}},
status=400,
)
args = ["node", "uninstall", node]
if data.get("confirm"):
args.append("--confirm")
result = await self.executor.execute(args, timeout=60)
if not result["success"]:
return web.json_response(
{
"success": False,
"error": {"code": "COMMAND_FAILED", "message": result["stderr"] or "Failed to uninstall node"},
},
status=500,
)
return web.json_response(
{"success": True, "data": {"node": node, "action": "uninstalled"}, "raw_output": result["stdout"]}
)
except Exception as e:
logger.exception(f"uninstall_node failed: {e}")
return web.json_response(
{"success": False, "error": {"code": "INTERNAL_ERROR", "message": str(e)}},
status=500,
)