- 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
506 lines
15 KiB
Markdown
506 lines
15 KiB
Markdown
# comfy-cli Box Table 解析指南
|
||
|
||
> 文档版本:1.0
|
||
> 最后更新:2026-03-17
|
||
> 目标:将 rich box table 输出转换为结构化 JSON/CSV
|
||
|
||
---
|
||
|
||
## 问题背景
|
||
|
||
comfy-cli 使用 `rich.table.Table` 输出格式化的表格,例如:
|
||
|
||
```
|
||
┌──────────────────────────┬─────────────┬─────────┐
|
||
│ Name │ Version │ Status │
|
||
├──────────────────────────┼─────────────┼─────────┤
|
||
│ ComfyUI-Impact-Pack │ v5.12.1 │ enabled │
|
||
│ ComfyUI-Manager │ v2.45.0 │ enabled │
|
||
└──────────────────────────┴─────────────┴─────────┘
|
||
```
|
||
|
||
这种格式对人类友好,但对程序解析不友好。需要专门的解析器。
|
||
|
||
---
|
||
|
||
## 方案对比
|
||
|
||
| 方案 | 优点 | 缺点 | 推荐场景 |
|
||
|------|------|------|----------|
|
||
| **方案 1:修改 comfy-cli 源码** | 最彻底,直接输出 JSON | 需要维护 fork 版本 | 长期方案 |
|
||
| **方案 2:使用 rich 的 Console 捕获** | 无需修改源码 | 需要导入 comfy-cli 内部模块 | Python 集成 |
|
||
| **方案 3:文本解析(推荐)** | 独立,无侵入 | 需要维护解析器 | REST API 集成 |
|
||
| **方案 4:直接文件系统访问** | 最可靠 | 仅适用于 list 类命令 | 模型/节点列表 |
|
||
|
||
---
|
||
|
||
## 方案 1:修改 comfy-cli 源码(最彻底)
|
||
|
||
在 `comfy_cli/ui.py` 中添加 JSON 输出选项:
|
||
|
||
```python
|
||
# comfy_cli/ui.py 修改版
|
||
import json
|
||
|
||
def display_table(
|
||
data: list[tuple],
|
||
column_names: list[str],
|
||
title: str = "",
|
||
output_format: str = "table" # 新增参数
|
||
) -> None:
|
||
if output_format == "json":
|
||
# 输出 JSON
|
||
result = [
|
||
dict(zip(column_names, row))
|
||
for row in data
|
||
]
|
||
print(json.dumps(result, indent=2))
|
||
else:
|
||
# 原有 table 输出
|
||
table = Table(title=title)
|
||
for name in column_names:
|
||
table.add_column(name, overflow="fold")
|
||
for row in data:
|
||
table.add_row(*[str(item) for item in row])
|
||
console.print(table)
|
||
```
|
||
|
||
**使用方式:**
|
||
```bash
|
||
comfy node show installed --format json
|
||
```
|
||
|
||
**优点:**
|
||
- 一劳永逸
|
||
- 官方支持
|
||
|
||
**缺点:**
|
||
- 需要 fork 并维护 comfy-cli
|
||
- 每次更新需要合并上游变更
|
||
|
||
---
|
||
|
||
## 方案 2:使用 rich 的 Console 捕获(Python 集成)
|
||
|
||
在 Python 代码中捕获 rich 的输出:
|
||
|
||
```python
|
||
from io import StringIO
|
||
from rich.console import Console
|
||
from rich.table import Table
|
||
|
||
def capture_table_output(data: list[tuple], column_names: list[str]) -> str:
|
||
"""捕获 rich table 输出为字符串"""
|
||
output = StringIO()
|
||
console = Console(file=output, force_terminal=False)
|
||
|
||
table = Table()
|
||
for name in column_names:
|
||
table.add_column(name)
|
||
for row in data:
|
||
table.add_row(*[str(item) for item in row])
|
||
|
||
console.print(table)
|
||
return output.getvalue()
|
||
|
||
# 然后解析字符串...
|
||
```
|
||
|
||
**优点:**
|
||
- 无需修改 comfy-cli
|
||
- 可以在自己的代码中使用
|
||
|
||
**缺点:**
|
||
- 仍然需要解析 box table
|
||
- 不适用于 CLI 调用场景
|
||
|
||
---
|
||
|
||
## 方案 3:文本解析器(推荐用于 REST API)
|
||
|
||
### 3.1 使用 `rich` 的内置解析
|
||
|
||
```python
|
||
from rich.console import Console
|
||
from rich.table import Table
|
||
import re
|
||
|
||
def parse_rich_table(table_text: str) -> list[dict]:
|
||
"""
|
||
解析 rich box table 文本为字典列表
|
||
|
||
Args:
|
||
table_text: rich table 的原始输出文本
|
||
|
||
Returns:
|
||
字典列表,每个字典代表一行
|
||
"""
|
||
lines = table_text.strip().split('\n')
|
||
|
||
# 过滤掉边框行
|
||
data_lines = []
|
||
for line in lines:
|
||
# 跳过纯边框行(只包含 ─┌┐└┘├┤┬┴┼│ 的行)
|
||
if re.match(r'^[\s┌─┬┐├─┼┤└─┴┘│]+$', line):
|
||
continue
|
||
data_lines.append(line)
|
||
|
||
if len(data_lines) < 2:
|
||
return []
|
||
|
||
# 第一行是表头
|
||
header_line = data_lines[0]
|
||
# 提取列名(去掉 │ 符号)
|
||
headers = [h.strip() for h in header_line.split('│')[1:-1]]
|
||
|
||
# 解析数据行
|
||
result = []
|
||
for line in data_lines[1:]:
|
||
if '│' not in line:
|
||
continue
|
||
cells = [c.strip() for c in line.split('│')[1:-1]]
|
||
if len(cells) == len(headers):
|
||
result.append(dict(zip(headers, cells)))
|
||
|
||
return result
|
||
|
||
# 示例
|
||
table_output = """
|
||
┌──────────────────────────┬─────────────┬─────────┐
|
||
│ Name │ Version │ Status │
|
||
├──────────────────────────┼─────────────┼─────────┤
|
||
│ ComfyUI-Impact-Pack │ v5.12.1 │ enabled │
|
||
│ ComfyUI-Manager │ v2.45.0 │ enabled │
|
||
└──────────────────────────┴─────────────┴─────────┘
|
||
"""
|
||
|
||
result = parse_rich_table(table_output)
|
||
# [
|
||
# {"Name": "ComfyUI-Impact-Pack", "Version": "v5.12.1", "Status": "enabled"},
|
||
# {"Name": "ComfyUI-Manager", "Version": "v2.45.0", "Status": "enabled"}
|
||
# ]
|
||
```
|
||
|
||
### 3.2 使用第三方库 `rich-argparse` + `pandas`
|
||
|
||
```python
|
||
import pandas as pd
|
||
from io import StringIO
|
||
|
||
def parse_table_with_pandas(table_text: str) -> pd.DataFrame:
|
||
"""使用 pandas 解析 box table"""
|
||
# 清理边框字符
|
||
cleaned = table_text
|
||
for char in '┌─┬┐├─┼┤└─┴┘│':
|
||
cleaned = cleaned.replace(char, '│' if char == '│' else ' ')
|
||
|
||
lines = [line for line in cleaned.split('\n') if line.strip() and '│' in line]
|
||
|
||
if len(lines) < 2:
|
||
return pd.DataFrame()
|
||
|
||
# 分割列
|
||
data = []
|
||
for line in lines:
|
||
cells = [c.strip() for c in line.split('│') if c.strip()]
|
||
data.append(cells)
|
||
|
||
headers = data[0]
|
||
rows = data[1:]
|
||
|
||
return pd.DataFrame(rows, columns=headers)
|
||
```
|
||
|
||
### 3.3 使用专用库 `asciitable` / `terminaltables`
|
||
|
||
```bash
|
||
pip install asciitable
|
||
```
|
||
|
||
```python
|
||
import asciitable
|
||
|
||
def parse_with_asciitable(table_text: str) -> list[dict]:
|
||
"""使用 asciitable 解析"""
|
||
try:
|
||
return asciitable.read(table_text)
|
||
except Exception:
|
||
# 降级到手动解析
|
||
return parse_rich_table(table_text)
|
||
```
|
||
|
||
### 3.4 完整的解析器实现(推荐)
|
||
|
||
```python
|
||
# comfy_cli_parser.py
|
||
import re
|
||
import json
|
||
from typing import List, Dict, Any, Optional
|
||
from dataclasses import dataclass
|
||
|
||
@dataclass
|
||
class ParseResult:
|
||
success: bool
|
||
data: Any
|
||
error: Optional[str] = None
|
||
raw_output: str = ""
|
||
|
||
class ComfyCliTableParser:
|
||
"""comfy-cli box table 解析器"""
|
||
|
||
# Box-drawing 字符
|
||
BOX_CHARS = set('┌─┬┐├─┼┤└─┴┘│')
|
||
SEPARATOR_CHARS = set('─┼┤├')
|
||
|
||
def parse(self, output: str, command: str) -> ParseResult:
|
||
"""解析 comfy-cli 输出"""
|
||
try:
|
||
if self._is_table(output):
|
||
return self._parse_table(output)
|
||
elif self._is_list(output):
|
||
return self._parse_list(output)
|
||
elif self._is_key_value(output):
|
||
return self._parse_key_value(output)
|
||
else:
|
||
return ParseResult(
|
||
success=False,
|
||
data={"raw": output},
|
||
error="Unknown output format"
|
||
)
|
||
except Exception as e:
|
||
return ParseResult(
|
||
success=False,
|
||
data=None,
|
||
error=str(e),
|
||
raw_output=output
|
||
)
|
||
|
||
def _is_table(self, text: str) -> bool:
|
||
"""检测是否为 box table"""
|
||
return any(c in text for c in '┌├└│')
|
||
|
||
def _is_list(self, text: str) -> bool:
|
||
"""检测是否为简单列表(每行一个项目)"""
|
||
lines = [l.strip() for l in text.strip().split('\n') if l.strip()]
|
||
return len(lines) > 1 and not self._is_table(text)
|
||
|
||
def _is_key_value(self, text: str) -> bool:
|
||
"""检测是否为键值对格式"""
|
||
return ':' in text and not self._is_table(text)
|
||
|
||
def _parse_table(self, text: str) -> ParseResult:
|
||
"""解析 box table"""
|
||
lines = text.strip().split('\n')
|
||
|
||
# 提取数据行(跳过纯边框行)
|
||
data_lines = []
|
||
for line in lines:
|
||
if '│' in line and not re.match(r'^[\s┌─┬┐├─┼┤└─┴┘]+$', line):
|
||
data_lines.append(line)
|
||
|
||
if len(data_lines) < 2:
|
||
return ParseResult(
|
||
success=True,
|
||
data=[],
|
||
raw_output=text
|
||
)
|
||
|
||
# 解析表头
|
||
header_line = data_lines[0]
|
||
headers = self._extract_cells(header_line)
|
||
|
||
# 解析数据行
|
||
rows = []
|
||
for line in data_lines[1:]:
|
||
cells = self._extract_cells(line)
|
||
if len(cells) == len(headers):
|
||
rows.append(dict(zip(headers, cells)))
|
||
|
||
return ParseResult(
|
||
success=True,
|
||
data=rows,
|
||
raw_output=text
|
||
)
|
||
|
||
def _extract_cells(self, line: str) -> List[str]:
|
||
"""从一行中提取单元格内容"""
|
||
# 分割并清理
|
||
parts = line.split('│')
|
||
cells = []
|
||
for part in parts:
|
||
cell = part.strip()
|
||
# 移除剩余的边框字符
|
||
cell = re.sub(r'^[┌├└┬┼┴]+', '', cell)
|
||
cell = re.sub(r'[┐┤┘┬┼┴]+$', '', cell)
|
||
if cell:
|
||
cells.append(cell)
|
||
return cells
|
||
|
||
def _parse_list(self, text: str) -> ParseResult:
|
||
"""解析简单列表"""
|
||
lines = [l.strip() for l in text.strip().split('\n') if l.strip()]
|
||
return ParseResult(
|
||
success=True,
|
||
data={"items": lines, "count": len(lines)},
|
||
raw_output=text
|
||
)
|
||
|
||
def _parse_key_value(self, text: str) -> ParseResult:
|
||
"""解析键值对"""
|
||
result = {}
|
||
for line in text.strip().split('\n'):
|
||
if ':' in line:
|
||
key, value = line.split(':', 1)
|
||
result[key.strip().lower().replace(' ', '_')] = value.strip()
|
||
return ParseResult(
|
||
success=True,
|
||
data=result,
|
||
raw_output=text
|
||
)
|
||
|
||
def to_json(self, result: ParseResult) -> str:
|
||
"""转换为 JSON 字符串"""
|
||
return json.dumps({
|
||
"success": result.success,
|
||
"data": result.data,
|
||
"error": result.error
|
||
}, indent=2, ensure_ascii=False)
|
||
|
||
|
||
# 使用示例
|
||
if __name__ == "__main__":
|
||
parser = ComfyCliTableParser()
|
||
|
||
# 示例:解析 node show 输出
|
||
node_output = """
|
||
┌──────────────────────────┬─────────────┬─────────┐
|
||
│ Name │ Version │ Status │
|
||
├──────────────────────────┼─────────────┼─────────┤
|
||
│ ComfyUI-Impact-Pack │ v5.12.1 │ enabled │
|
||
│ ComfyUI-Manager │ v2.45.0 │ enabled │
|
||
└──────────────────────────┴─────────────┴─────────┘
|
||
"""
|
||
|
||
result = parser.parse(node_output, "node show")
|
||
print(parser.to_json(result))
|
||
# 输出:
|
||
# {
|
||
# "success": true,
|
||
# "data": [
|
||
# {"Name": "ComfyUI-Impact-Pack", "Version": "v5.12.1", "Status": "enabled"},
|
||
# {"Name": "ComfyUI-Manager", "Version": "v2.45.0", "Status": "enabled"}
|
||
# ]
|
||
# }
|
||
```
|
||
|
||
---
|
||
|
||
## 方案 4:直接文件系统访问(最可靠)
|
||
|
||
对于 `model list` 和 `node simple-show` 这类命令,直接读取文件系统比解析 CLI 输出更可靠:
|
||
|
||
```python
|
||
from pathlib import Path
|
||
from typing import List, Dict
|
||
import json
|
||
|
||
def list_models_direct(workspace: str, relative_path: str) -> Dict:
|
||
"""直接读取文件系统获取模型列表"""
|
||
full_path = Path(workspace) / relative_path
|
||
|
||
if not full_path.exists():
|
||
return {"success": False, "error": "Path not found"}
|
||
|
||
models = []
|
||
for f in full_path.iterdir():
|
||
if f.is_file() and f.suffix in ['.safetensors', '.ckpt', '.pt', '.bin']:
|
||
stat = f.stat()
|
||
models.append({
|
||
"filename": f.name,
|
||
"size": stat.st_size,
|
||
"size_human": _format_size(stat.st_size),
|
||
"modified": stat.st_mtime
|
||
})
|
||
|
||
return {
|
||
"success": True,
|
||
"data": {
|
||
"path": relative_path,
|
||
"models": sorted(models, key=lambda x: x["filename"]),
|
||
"count": len(models)
|
||
}
|
||
}
|
||
|
||
def _format_size(bytes: int) -> str:
|
||
for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
|
||
if bytes < 1024:
|
||
return f"{bytes:.1f} {unit}"
|
||
bytes /= 1024
|
||
return f"{bytes:.1f} TB"
|
||
```
|
||
|
||
---
|
||
|
||
## REST API 集成建议
|
||
|
||
在 REST API 层,推荐**组合方案**:
|
||
|
||
```python
|
||
# handlers/models.py
|
||
from aiohttp import web
|
||
from comfy_cli_parser import ComfyCliTableParser
|
||
import subprocess
|
||
|
||
class ModelHandler:
|
||
def __init__(self, workspace: str):
|
||
self.workspace = workspace
|
||
self.parser = ComfyCliTableParser()
|
||
|
||
async def list_models(self, request: web.Request) -> web.Response:
|
||
"""列出模型"""
|
||
# 方案 4:直接文件系统访问(推荐)
|
||
data = list_models_direct(self.workspace, "models/checkpoints")
|
||
return web.json_response(data)
|
||
|
||
async def show_nodes(self, request: web.Request) -> web.Response:
|
||
"""显示节点列表"""
|
||
# 方案 3:CLI + 解析器
|
||
cmd = ["comfy", "--workspace", self.workspace, "node", "simple-show", "installed"]
|
||
|
||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||
|
||
parse_result = self.parser.parse(result.stdout, "node simple-show")
|
||
|
||
return web.json_response({
|
||
"success": parse_result.success,
|
||
"data": parse_result.data,
|
||
"raw_output": parse_result.raw_output if request.query.get("debug") else None
|
||
})
|
||
```
|
||
|
||
---
|
||
|
||
## 现有工具推荐
|
||
|
||
| 工具 | 用途 | 链接 |
|
||
|------|------|------|
|
||
| `rich` | 生成和解析 table | `pip install rich` |
|
||
| `pandas` | 数据框处理 | `pip install pandas` |
|
||
| `asciitable` | ASCII 表格解析 | `pip install asciitable` |
|
||
| `texttable` | 文本表格生成/解析 | `pip install texttable` |
|
||
|
||
---
|
||
|
||
## 总结
|
||
|
||
| 场景 | 推荐方案 |
|
||
|------|----------|
|
||
| **REST API 集成** | 方案 3(文本解析器)+ 方案 4(直接文件系统) |
|
||
| **Python 脚本集成** | 方案 2(rich Console 捕获) |
|
||
| **长期使用** | 方案 1(修改 comfy-cli 源码,添加 `--format json` 选项) |
|
||
| **简单列表查询** | 方案 4(直接文件系统访问) |
|
||
|
||
---
|
||
|
||
*由 Zero (零号) 生成 | 2026-03-17*
|