🔄 卡若AI 同步 2026-03-12 23:10 | 更新:水桥平台对接、卡木、总索引与入口、运营中枢工作台 | 排除 >20MB: 11 个
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"access_token": "u-e9uHxerhZ7VrnwkCFC9Yvalh3Ix1ghWXpgGaZMk0260Y",
|
"access_token": "u-e1gGA5qAp2yXEyJOa2k9Zalh3KxxghONV0GaJwk0274U",
|
||||||
"refresh_token": "ur-dIaKx4ssV4SU.karG6lt9ulh1C11ghopOgGaYx00261E",
|
"refresh_token": "ur-cuP.iNyXxflV6qpKn0c2Lnlh3AxxghqPpwGaIQ4022gJ",
|
||||||
"name": "飞书用户",
|
"name": "飞书用户",
|
||||||
"auth_time": "2026-03-12T20:33:34.954705"
|
"auth_time": "2026-03-12T22:44:05.347461"
|
||||||
}
|
}
|
||||||
@@ -326,18 +326,29 @@ def _get_text_content(block: dict) -> str:
|
|||||||
return (tr.get("content") or "")
|
return (tr.get("content") or "")
|
||||||
|
|
||||||
|
|
||||||
|
def _get_elements_content(elements: list) -> str:
|
||||||
|
if not elements:
|
||||||
|
return ""
|
||||||
|
return (elements[0].get("text_run") or {}).get("content", "") or ""
|
||||||
|
|
||||||
|
|
||||||
def sanitize_blocks(blocks: list) -> list:
|
def sanitize_blocks(blocks: list) -> list:
|
||||||
"""
|
"""飞书 docx blocks 轻量清洗:去掉空文本/空代码块/空 callout,避免 invalid param。"""
|
||||||
飞书 docx blocks 对“空段落/异常结构”会严格校验。
|
|
||||||
这里做一次轻量清洗:去掉纯空文本块,避免 invalid param。
|
|
||||||
"""
|
|
||||||
out = []
|
out = []
|
||||||
for b in blocks:
|
for b in blocks:
|
||||||
if not isinstance(b, dict):
|
if not isinstance(b, dict):
|
||||||
continue
|
continue
|
||||||
if b.get("block_type") == 2:
|
bt = b.get("block_type")
|
||||||
c = _get_text_content(b)
|
if bt == 2:
|
||||||
if not c or not c.strip():
|
if not _get_text_content(b).strip():
|
||||||
|
continue
|
||||||
|
elif bt == 14:
|
||||||
|
elems = (b.get("code") or {}).get("elements") or []
|
||||||
|
if not _get_elements_content(elems).strip():
|
||||||
|
continue
|
||||||
|
elif bt == 19:
|
||||||
|
elems = (b.get("callout") or {}).get("elements") or []
|
||||||
|
if not _get_elements_content(elems).strip():
|
||||||
continue
|
continue
|
||||||
out.append(b)
|
out.append(b)
|
||||||
return out
|
return out
|
||||||
@@ -438,6 +449,21 @@ def _write_batch_with_fallback(doc_token: str, headers: dict, batch: list, total
|
|||||||
if r1.get("code") == 0:
|
if r1.get("code") == 0:
|
||||||
time.sleep(0.35)
|
time.sleep(0.35)
|
||||||
continue
|
continue
|
||||||
|
bt = b.get("block_type")
|
||||||
|
# code(14) 和 callout(19) 失败时降级为文本块
|
||||||
|
fallback_content = ""
|
||||||
|
if bt == 14:
|
||||||
|
elems = (b.get("code") or {}).get("elements") or []
|
||||||
|
fallback_content = _get_elements_content(elems)
|
||||||
|
elif bt == 19:
|
||||||
|
elems = (b.get("callout") or {}).get("elements") or []
|
||||||
|
fallback_content = _get_elements_content(elems)
|
||||||
|
if fallback_content:
|
||||||
|
r2 = _post_children(doc_token, headers, [{"block_type": 2, "text": {"elements": [{"text_run": {"content": fallback_content, "text_element_style": {}}}], "style": {}}}], None)
|
||||||
|
if r2.get("code") == 0:
|
||||||
|
print(f"⚠️ block_type={bt} 降级为文本块写入")
|
||||||
|
time.sleep(0.35)
|
||||||
|
continue
|
||||||
c = _get_text_content(b)
|
c = _get_text_content(b)
|
||||||
preview = (c[:60] + "...") if c and len(c) > 60 else (c or "")
|
preview = (c[:60] + "...") if c and len(c) > 60 else (c or "")
|
||||||
print(f"⚠️ 跳过非法块: code={r1.get('code')} msg={r1.get('msg')} preview={preview!r}")
|
print(f"⚠️ 跳过非法块: code={r1.get('code')} msg={r1.get('msg')} preview={preview!r}")
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
将 Markdown 本地转换为飞书文档 JSON 格式。
|
将 Markdown 本地转换为飞书文档 JSON 格式(v2)。
|
||||||
图片用占位符 __IMAGE:路径__ 标注,上传时替换为 file_token。
|
· 代码围栏 → block_type:14 code block(保留语言标注)
|
||||||
|
· > 引用 → block_type:19 callout(蓝色背景)
|
||||||
|
· --- → block_type:22 divider
|
||||||
|
· #### → block_type:6 heading4
|
||||||
|
· 图片 → __IMAGE__ 占位符,上传时替换为 file_token
|
||||||
|
· 表格 → block_type:30 sheet(超出 9×9 自动截断)
|
||||||
|
|
||||||
用法:
|
用法:
|
||||||
python3 md_to_feishu_json.py input.md output.json
|
python3 md_to_feishu_json.py input.md output.json
|
||||||
@@ -14,24 +19,71 @@ import argparse
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
def _h1(t):
|
# ── 语言代码映射(飞书 code block style.language) ──────────────────────────
|
||||||
|
LANG_MAP: dict[str, int] = {
|
||||||
|
"python": 2, "py": 2,
|
||||||
|
"javascript": 3, "js": 3,
|
||||||
|
"typescript": 3, "ts": 3,
|
||||||
|
"shell": 6, "bash": 6, "sh": 6,
|
||||||
|
"sql": 8,
|
||||||
|
"json": 9,
|
||||||
|
"html": 11, "xml": 11,
|
||||||
|
"go": 16,
|
||||||
|
"rust": 22,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Block 构造函数 ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _h1(t: str) -> dict:
|
||||||
return {"block_type": 3, "heading1": {"elements": [{"text_run": {"content": t, "text_element_style": {}}}], "style": {}}}
|
return {"block_type": 3, "heading1": {"elements": [{"text_run": {"content": t, "text_element_style": {}}}], "style": {}}}
|
||||||
|
|
||||||
|
|
||||||
def _h2(t):
|
def _h2(t: str) -> dict:
|
||||||
return {"block_type": 4, "heading2": {"elements": [{"text_run": {"content": t, "text_element_style": {}}}], "style": {}}}
|
return {"block_type": 4, "heading2": {"elements": [{"text_run": {"content": t, "text_element_style": {}}}], "style": {}}}
|
||||||
|
|
||||||
|
|
||||||
def _h3(t):
|
def _h3(t: str) -> dict:
|
||||||
return {"block_type": 5, "heading3": {"elements": [{"text_run": {"content": t, "text_element_style": {}}}], "style": {}}}
|
return {"block_type": 5, "heading3": {"elements": [{"text_run": {"content": t, "text_element_style": {}}}], "style": {}}}
|
||||||
|
|
||||||
|
|
||||||
def _text(t):
|
def _h4(t: str) -> dict:
|
||||||
|
return {"block_type": 6, "heading4": {"elements": [{"text_run": {"content": t, "text_element_style": {}}}], "style": {}}}
|
||||||
|
|
||||||
|
|
||||||
|
def _text(t: str) -> dict:
|
||||||
return {"block_type": 2, "text": {"elements": [{"text_run": {"content": t, "text_element_style": {}}}], "style": {}}}
|
return {"block_type": 2, "text": {"elements": [{"text_run": {"content": t, "text_element_style": {}}}], "style": {}}}
|
||||||
|
|
||||||
|
|
||||||
|
def _code(t: str, lang: int = 1) -> dict:
|
||||||
|
"""block_type:14 代码块,lang 见 LANG_MAP,1=纯文本/流程图"""
|
||||||
|
return {
|
||||||
|
"block_type": 14,
|
||||||
|
"code": {
|
||||||
|
"elements": [{"text_run": {"content": t, "text_element_style": {}}}],
|
||||||
|
"style": {"language": lang},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _callout(t: str, bg: int = 2) -> dict:
|
||||||
|
"""block_type:19 高亮块,bg: 1=白 2=蓝 3=绿 4=橙 5=黄 6=红 7=紫"""
|
||||||
|
return {
|
||||||
|
"block_type": 19,
|
||||||
|
"callout": {
|
||||||
|
"emoji_id": "blue_book",
|
||||||
|
"background_color": bg,
|
||||||
|
"border_color": bg,
|
||||||
|
"elements": [{"text_run": {"content": t, "text_element_style": {}}}],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _divider() -> dict:
|
||||||
|
return {"block_type": 22, "divider": {}}
|
||||||
|
|
||||||
|
|
||||||
def _image_placeholder(idx: int, path: str) -> dict:
|
def _image_placeholder(idx: int, path: str) -> dict:
|
||||||
"""图片占位符,上传时由脚本替换为 gallery block"""
|
|
||||||
return {"__image__": path, "__index__": idx}
|
return {"__image__": path, "__index__": idx}
|
||||||
|
|
||||||
|
|
||||||
@@ -45,6 +97,8 @@ def _sheet_table(values: list[list[str]]) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── 辅助函数 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _parse_md_row(line: str) -> list[str]:
|
def _parse_md_row(line: str) -> list[str]:
|
||||||
s = line.strip()
|
s = line.strip()
|
||||||
if s.startswith("|"):
|
if s.startswith("|"):
|
||||||
@@ -63,54 +117,71 @@ def _is_md_table_sep(line: str) -> bool:
|
|||||||
if s.endswith("|"):
|
if s.endswith("|"):
|
||||||
s = s[:-1]
|
s = s[:-1]
|
||||||
parts = [p.strip() for p in s.split("|")]
|
parts = [p.strip() for p in s.split("|")]
|
||||||
if not parts:
|
return bool(parts) and all(re.match(r"^:?-{3,}:?$", p or "") for p in parts)
|
||||||
return False
|
|
||||||
return all(re.match(r"^:?-{3,}:?$", p or "") for p in parts)
|
|
||||||
|
|
||||||
|
|
||||||
def _clean_inline_markdown(text: str) -> str:
|
def _clean_inline_markdown(text: str) -> str:
|
||||||
"""清理常见行内 markdown 标记,输出更适合飞书阅读的纯文本。"""
|
"""去掉常见行内 Markdown 标记,输出适合飞书的纯文本。"""
|
||||||
t = text
|
t = text
|
||||||
# 粗体/斜体标记
|
# 粗体
|
||||||
t = re.sub(r"\*\*(.*?)\*\*", r"\1", t)
|
t = re.sub(r"\*\*(.*?)\*\*", r"\1", t)
|
||||||
t = re.sub(r"__(.*?)__", r"\1", t)
|
t = re.sub(r"__(.*?)__", r"\1", t)
|
||||||
|
# 斜体
|
||||||
t = re.sub(r"\*(.*?)\*", r"\1", t)
|
t = re.sub(r"\*(.*?)\*", r"\1", t)
|
||||||
t = re.sub(r"_(.*?)_", r"\1", t)
|
t = re.sub(r"_((?!_).*?)_", r"\1", t)
|
||||||
# 行内代码保留内容,去掉反引号
|
# 行内代码(保留内容)
|
||||||
t = re.sub(r"`([^`]+)`", r"\1", t)
|
t = re.sub(r"`([^`]+)`", r"\1", t)
|
||||||
|
# 链接 [text](url) → text
|
||||||
|
t = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", t)
|
||||||
return t.strip()
|
return t.strip()
|
||||||
|
|
||||||
|
|
||||||
|
# ── 主转换函数 ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def md_to_blocks(md: str, image_paths: list[str] | None = None) -> list:
|
def md_to_blocks(md: str, image_paths: list[str] | None = None) -> list:
|
||||||
"""将 Markdown 转为飞书 blocks"""
|
"""将 Markdown 字符串转为飞书 blocks 列表。"""
|
||||||
blocks = []
|
blocks: list[dict] = []
|
||||||
image_paths = image_paths or []
|
image_paths = image_paths or []
|
||||||
img_idx = 0
|
img_idx = 0
|
||||||
first_h1_consumed = False
|
first_h1_consumed = False
|
||||||
|
|
||||||
in_code = False
|
in_code = False
|
||||||
code_lines = []
|
code_lines: list[str] = []
|
||||||
|
code_lang = 1
|
||||||
|
|
||||||
lines = md.split("\n")
|
lines = md.split("\n")
|
||||||
i = 0
|
i = 0
|
||||||
while i < len(lines):
|
while i < len(lines):
|
||||||
line = lines[i]
|
line = lines[i]
|
||||||
if line.strip().startswith("```"):
|
stripped = line.strip()
|
||||||
|
|
||||||
|
# ── 代码围栏 ───────────────────────────────────────────────────────────
|
||||||
|
if stripped.startswith("```"):
|
||||||
if in_code:
|
if in_code:
|
||||||
# 飞书 blocks 常对代码围栏/特殊格式更严格,这里转为普通文本行,提升美观与稳定性
|
# 结束代码块 → 生成单个 block_type:14
|
||||||
for cl in code_lines:
|
code_content = "\n".join(code_lines)
|
||||||
if cl.strip():
|
if code_content.strip():
|
||||||
blocks.append(_text(f"代码:{cl.strip()}"))
|
blocks.append(_code(code_content, code_lang))
|
||||||
code_lines = []
|
code_lines = []
|
||||||
in_code = not in_code
|
code_lang = 1
|
||||||
|
in_code = False
|
||||||
|
else:
|
||||||
|
# 开始代码块 → 识别语言
|
||||||
|
lang_match = re.match(r"```(\w+)?", stripped)
|
||||||
|
code_lang = 1
|
||||||
|
if lang_match and lang_match.group(1):
|
||||||
|
code_lang = LANG_MAP.get(lang_match.group(1).lower(), 1)
|
||||||
|
in_code = True
|
||||||
i += 1
|
i += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if in_code:
|
if in_code:
|
||||||
code_lines.append(line)
|
code_lines.append(line)
|
||||||
i += 1
|
i += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 图片语法 
|
# ── 图片 ──────────────────────────────────────────────────────────────
|
||||||
img_match = re.match(r"^!\[([^\]]*)\]\(([^)]+)\)\s*$", line.strip())
|
img_match = re.match(r"^!\[([^\]]*)\]\(([^)]+)\)\s*$", stripped)
|
||||||
if img_match:
|
if img_match:
|
||||||
path = img_match.group(2)
|
path = img_match.group(2)
|
||||||
if img_idx < len(image_paths):
|
if img_idx < len(image_paths):
|
||||||
@@ -120,7 +191,7 @@ def md_to_blocks(md: str, image_paths: list[str] | None = None) -> list:
|
|||||||
i += 1
|
i += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Markdown 表格:表头 + 分隔行 + 数据行
|
# ── Markdown 表格 ─────────────────────────────────────────────────────
|
||||||
if "|" in line and i + 1 < len(lines) and _is_md_table_sep(lines[i + 1]):
|
if "|" in line and i + 1 < len(lines) and _is_md_table_sep(lines[i + 1]):
|
||||||
table_lines = [line]
|
table_lines = [line]
|
||||||
j = i + 2
|
j = i + 2
|
||||||
@@ -128,7 +199,7 @@ def md_to_blocks(md: str, image_paths: list[str] | None = None) -> list:
|
|||||||
raw = lines[j].strip()
|
raw = lines[j].strip()
|
||||||
if not raw or "|" not in raw:
|
if not raw or "|" not in raw:
|
||||||
break
|
break
|
||||||
if raw.startswith("#") or raw.startswith(">") or raw.startswith("```"):
|
if raw.startswith(("#", ">", "```")):
|
||||||
break
|
break
|
||||||
table_lines.append(lines[j])
|
table_lines.append(lines[j])
|
||||||
j += 1
|
j += 1
|
||||||
@@ -143,46 +214,50 @@ def md_to_blocks(md: str, image_paths: list[str] | None = None) -> list:
|
|||||||
rr.extend([""] * (col_size - len(rr)))
|
rr.extend([""] * (col_size - len(rr)))
|
||||||
clean_rows.append(rr[:col_size])
|
clean_rows.append(rr[:col_size])
|
||||||
|
|
||||||
# 飞书空 sheet 创建限制:行列最大 9,超出时截断并提示
|
|
||||||
max_rows, max_cols = 9, 9
|
max_rows, max_cols = 9, 9
|
||||||
if len(clean_rows) > max_rows or col_size > max_cols:
|
if len(clean_rows) > max_rows or col_size > max_cols:
|
||||||
blocks.append(_text("提示:原表格超出飞书单块上限,已自动截断为 9x9。"))
|
blocks.append(_text("(注:原表格超出飞书单块上限,已自动截断为 9×9 显示)"))
|
||||||
clipped = [r[:max_cols] for r in clean_rows[:max_rows]]
|
clipped = [r[:max_cols] for r in clean_rows[:max_rows]]
|
||||||
blocks.append(_sheet_table(clipped))
|
blocks.append(_sheet_table(clipped))
|
||||||
|
|
||||||
i = j
|
i = j
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 忽略 Markdown 水平分隔线(避免在飞书出现大量“---”影响观感)
|
# ── 分割线 → block_type:22 ────────────────────────────────────────────
|
||||||
if line.strip() in {"---", "***", "___"}:
|
if stripped in {"---", "***", "___"}:
|
||||||
|
blocks.append(_divider())
|
||||||
i += 1
|
i += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 标题
|
# ── 标题 ──────────────────────────────────────────────────────────────
|
||||||
if line.startswith("# "):
|
if line.startswith("#### "):
|
||||||
# 避免正文和文档标题重复:默认跳过第一行 H1
|
blocks.append(_h4(_clean_inline_markdown(line[5:].strip())))
|
||||||
|
elif line.startswith("### "):
|
||||||
|
blocks.append(_h3(_clean_inline_markdown(line[4:].strip())))
|
||||||
|
elif line.startswith("## "):
|
||||||
|
blocks.append(_h2(_clean_inline_markdown(line[3:].strip())))
|
||||||
|
elif line.startswith("# "):
|
||||||
if first_h1_consumed:
|
if first_h1_consumed:
|
||||||
blocks.append(_h1(_clean_inline_markdown(line[2:].strip())))
|
blocks.append(_h1(_clean_inline_markdown(line[2:].strip())))
|
||||||
else:
|
else:
|
||||||
first_h1_consumed = True
|
first_h1_consumed = True
|
||||||
elif line.startswith("## "):
|
|
||||||
blocks.append(_h2(_clean_inline_markdown(line[3:].strip())))
|
# ── 引用 → block_type:19 callout ─────────────────────────────────────
|
||||||
elif line.startswith("### "):
|
elif stripped.startswith(">"):
|
||||||
blocks.append(_h3(_clean_inline_markdown(line[4:].strip())))
|
quote = stripped
|
||||||
elif line.lstrip().startswith(">"):
|
|
||||||
# 引用块转普通说明行,降低写入失败概率
|
|
||||||
quote = line.lstrip()
|
|
||||||
while quote.startswith(">"):
|
while quote.startswith(">"):
|
||||||
quote = quote[1:].lstrip()
|
quote = quote[1:].lstrip()
|
||||||
quote = _clean_inline_markdown(quote)
|
quote = _clean_inline_markdown(quote)
|
||||||
if quote:
|
if quote:
|
||||||
blocks.append(_text(quote))
|
blocks.append(_callout(quote))
|
||||||
elif line.strip():
|
|
||||||
raw = line.strip()
|
# ── 正文、列表 ────────────────────────────────────────────────────────
|
||||||
# 无序列表统一成 •,减少 markdown 观感噪音
|
elif stripped:
|
||||||
|
raw = stripped
|
||||||
|
# 无序列表 → •
|
||||||
if re.match(r"^[-*]\s+", raw):
|
if re.match(r"^[-*]\s+", raw):
|
||||||
raw = "• " + re.sub(r"^[-*]\s+", "", raw)
|
raw = "• " + re.sub(r"^[-*]\s+", "", raw)
|
||||||
# 有序列表统一成 1)2)样式
|
# 有序列表 → 1)2)
|
||||||
raw = re.sub(r"^(\d+)\.\s+", r"\1)", raw)
|
raw = re.sub(r"^(\d+)\.\s+", r"\1)", raw)
|
||||||
cleaned = _clean_inline_markdown(raw)
|
cleaned = _clean_inline_markdown(raw)
|
||||||
if cleaned:
|
if cleaned:
|
||||||
@@ -194,13 +269,9 @@ def md_to_blocks(md: str, image_paths: list[str] | None = None) -> list:
|
|||||||
|
|
||||||
|
|
||||||
def blocks_to_upload_format(blocks: list, base_dir: Path) -> tuple[list, list]:
|
def blocks_to_upload_format(blocks: list, base_dir: Path) -> tuple[list, list]:
|
||||||
"""
|
"""将含 __image__ 占位符的 blocks 转为可上传格式,返回 (blocks, image_paths)。"""
|
||||||
将含 __image__ 占位符的 blocks 转为可上传格式。
|
out: list = []
|
||||||
返回 (文本 blocks 列表, 图片路径列表,按出现顺序)。
|
paths: list[str] = []
|
||||||
image_paths 优先存相对路径(相对 base_dir),便于 JSON 移植。
|
|
||||||
"""
|
|
||||||
out = []
|
|
||||||
paths = []
|
|
||||||
for b in blocks:
|
for b in blocks:
|
||||||
if isinstance(b, dict) and "__image__" in b:
|
if isinstance(b, dict) and "__image__" in b:
|
||||||
path = b.get("__image__", "")
|
path = b.get("__image__", "")
|
||||||
@@ -216,14 +287,16 @@ def blocks_to_upload_format(blocks: list, base_dir: Path) -> tuple[list, list]:
|
|||||||
rel = str(resolved)
|
rel = str(resolved)
|
||||||
paths.append(rel)
|
paths.append(rel)
|
||||||
else:
|
else:
|
||||||
paths.append(path if path else "unknown")
|
paths.append(path or "unknown")
|
||||||
out.append({"block_type": 2, "text": {"elements": [{"text_run": {"content": f"【配图 {len(paths)}:待上传】", "text_element_style": {}}}], "style": {}}})
|
out.append(
|
||||||
|
_text(f"【配图 {len(paths)}:待上传】")
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
out.append(b)
|
out.append(b)
|
||||||
return out, paths
|
return out, paths
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main() -> None:
|
||||||
ap = argparse.ArgumentParser()
|
ap = argparse.ArgumentParser()
|
||||||
ap.add_argument("input", help="Markdown 文件")
|
ap.add_argument("input", help="Markdown 文件")
|
||||||
ap.add_argument("output", help="输出 JSON 文件")
|
ap.add_argument("output", help="输出 JSON 文件")
|
||||||
@@ -241,7 +314,7 @@ def main():
|
|||||||
final, img_paths = blocks_to_upload_format(blocks, inp.parent)
|
final, img_paths = blocks_to_upload_format(blocks, inp.parent)
|
||||||
|
|
||||||
out = {
|
out = {
|
||||||
"description": f"由 {inp.name} 转换的飞书 docx blocks",
|
"description": f"由 {inp.name} 转换的飞书 docx blocks(v2)",
|
||||||
"source": str(inp),
|
"source": str(inp),
|
||||||
"image_paths": img_paths,
|
"image_paths": img_paths,
|
||||||
"children": final,
|
"children": final,
|
||||||
|
|||||||
82
02_卡人(水)/水桥_平台对接/飞书管理/脚本/send_review_to_feishu_webhook.py
Normal file
82
02_卡人(水)/水桥_平台对接/飞书管理/脚本/send_review_to_feishu_webhook.py
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
卡若AI 对话复盘总结 → 飞书群 webhook
|
||||||
|
|
||||||
|
每次对话完成后,将简洁复盘总结发到指定飞书群。
|
||||||
|
飞书 bot v2 hook 要求:POST JSON,含 msg_type(如 text)。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
# 直接传入简洁总结(建议 ≤500 字)
|
||||||
|
python3 send_review_to_feishu_webhook.py "【卡若AI复盘】2026-03-12 15:30\n🎯 完成记忆系统使用手册\n📌 已写开发文档/9、手册/卡若AI记忆系统使用手册.md\n▶ 无"
|
||||||
|
|
||||||
|
# 从文件读
|
||||||
|
python3 send_review_to_feishu_webhook.py --file /path/to/summary.txt
|
||||||
|
|
||||||
|
# 指定 webhook(否则用默认)
|
||||||
|
python3 send_review_to_feishu_webhook.py --webhook "https://open.feishu.cn/..." "总结内容"
|
||||||
|
|
||||||
|
环境变量(可选):
|
||||||
|
FEISHU_REVIEW_WEBHOOK — 默认 webhook URL
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
# 默认 webhook(卡若AI 复盘总结群);可被环境变量或 --webhook 覆盖
|
||||||
|
DEFAULT_WEBHOOK = os.environ.get(
|
||||||
|
"FEISHU_REVIEW_WEBHOOK",
|
||||||
|
"https://open.feishu.cn/open-apis/bot/v2/hook/8b7f996e-2892-4075-989f-aa5593ea4fbc",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def send_text(webhook_url: str, text: str) -> bool:
|
||||||
|
"""POST 文本到飞书 bot v2 webhook。"""
|
||||||
|
if not text or not webhook_url:
|
||||||
|
return False
|
||||||
|
payload = {"msg_type": "text", "content": {"text": text[:4000]}} # 飞书单条文本有长度限制
|
||||||
|
try:
|
||||||
|
r = requests.post(webhook_url, json=payload, timeout=10)
|
||||||
|
body = r.json()
|
||||||
|
if body.get("code") != 0:
|
||||||
|
print(f"飞书 webhook 返回错误: {body}", file=sys.stderr)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"发送失败: {e}", file=sys.stderr)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser(description="卡若AI 复盘总结发飞书群")
|
||||||
|
ap.add_argument("text", nargs="?", default="", help="简洁复盘总结(建议≤500字)")
|
||||||
|
ap.add_argument("--file", "-f", help="从文件读取总结内容")
|
||||||
|
ap.add_argument("--webhook", "-w", default=DEFAULT_WEBHOOK, help="飞书群 webhook URL")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
if args.file:
|
||||||
|
path = Path(args.file)
|
||||||
|
if path.exists():
|
||||||
|
text = path.read_text(encoding="utf-8").strip()
|
||||||
|
else:
|
||||||
|
print(f"文件不存在: {args.file}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
else:
|
||||||
|
text = (args.text or sys.stdin.read()).strip()
|
||||||
|
|
||||||
|
if not text:
|
||||||
|
print("无内容可发送", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
ok = send_text(args.webhook, text)
|
||||||
|
if ok:
|
||||||
|
print("已发送到飞书群")
|
||||||
|
else:
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
206
03_卡木(木)/木识_软件识形/CLI万能化/SKILL.md
Normal file
206
03_卡木(木)/木识_软件识形/CLI万能化/SKILL.md
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
---
|
||||||
|
name: CLI万能化
|
||||||
|
description: 让任意软件变成 AI Agent 可驱动的 CLI 接口;一行命令完成7阶段自动流水线
|
||||||
|
triggers: CLI万能化、cli-anything、软件识形、让软件Agent化、任意软件CLI、软件CLI接口、木识、软件识形、CLI接口生成、让软件可被AI控制、给软件生成CLI、GUI转CLI
|
||||||
|
owner: 木识
|
||||||
|
group: 木(卡木)
|
||||||
|
version: "1.0"
|
||||||
|
updated: "2026-03-12"
|
||||||
|
source: https://github.com/HKUDS/CLI-Anything
|
||||||
|
---
|
||||||
|
|
||||||
|
# 木识 · CLI万能化
|
||||||
|
|
||||||
|
> **木识**(Mù Shí)是卡木第四成员,专司「软件识形」——识别任意软件的形体与架构,将其转化为 AI Agent 可驱动的 CLI 接口。
|
||||||
|
>
|
||||||
|
> 「识」字连接记忆宫殿:佛教哲学中「阿赖耶识」为万物印记之储库,木识的使命就是将任意软件的能力识别、提炼并存入 Agent 可调用的指令库——让记忆宫殿的每个房间都多一扇可编程的门。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 能做什么(Capabilities)
|
||||||
|
|
||||||
|
- **任意软件 → CLI**:对任意有源码的软件(GIMP、Blender、LibreOffice、Gitea、Jenkins、Stable Diffusion、ComfyUI 等),自动生成生产级 CLI 接口
|
||||||
|
- **7 阶段全自动流水线**:分析 → 设计 → 实现 → 规划测试 → 写测试 → 文档 → 发布,无需手写代码
|
||||||
|
- **Agent 友好输出**:每条命令均支持 `--help` 自描述 + `--json` 机器可读输出
|
||||||
|
- **REPL 交互模式**:有状态交互式会话,支持撤销/重做
|
||||||
|
- **本机集成**:生成的 CLI 以 `pip install -e .` 直接装到 PATH,卡若AI 后续技能可直接调用
|
||||||
|
- **迭代精化**:可对已生成的 CLI 做 gap analysis,增量扩展覆盖范围
|
||||||
|
- **与木根联动**:木根做逆向分析找 API,木识生成完整可用的 CLI 层
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 怎么用(Usage)
|
||||||
|
|
||||||
|
### 触发词
|
||||||
|
`CLI万能化`、`cli-anything`、`软件识形`、`让[软件名]被Agent控制`、`给[软件]生成CLI`、`木识`
|
||||||
|
|
||||||
|
### 使用示例
|
||||||
|
```
|
||||||
|
木识:把本机的 Gitea 生成 CLI 接口
|
||||||
|
木识:给 Stable Diffusion 做 CLI 万能化
|
||||||
|
木识:让 ComfyUI 可以被 Agent 调用
|
||||||
|
CLI万能化 ./blender
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 执行步骤(Steps)
|
||||||
|
|
||||||
|
### 前置检查
|
||||||
|
```bash
|
||||||
|
python3 --version # 需要 ≥ 3.10
|
||||||
|
which python3
|
||||||
|
# 确认目标软件已安装(如需要)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 方式一:在 Cursor / 卡若AI 内直接执行(推荐)
|
||||||
|
|
||||||
|
木识在 Cursor 中按以下流程手动执行 7 阶段(无需 Claude Code 插件市场):
|
||||||
|
|
||||||
|
**阶段0:获取源码**
|
||||||
|
```bash
|
||||||
|
# 本地路径(直接用)
|
||||||
|
TARGET_PATH="./gimp"
|
||||||
|
|
||||||
|
# 或克隆 GitHub 仓库
|
||||||
|
git clone https://github.com/GNOME/gimp /tmp/target-software/gimp
|
||||||
|
TARGET_PATH="/tmp/target-software/gimp"
|
||||||
|
```
|
||||||
|
|
||||||
|
**阶段1:分析(Analyze)**
|
||||||
|
- 读取目标软件源码目录
|
||||||
|
- 识别后端引擎、GUI-API 映射关系
|
||||||
|
- 参考:`参考资料/HARNESS.md` § Phase 1
|
||||||
|
|
||||||
|
**阶段2:设计(Design)**
|
||||||
|
- 设计命令分组、状态模型、输出格式
|
||||||
|
- 参考:`参考资料/HARNESS.md` § Phase 2
|
||||||
|
|
||||||
|
**阶段3:实现(Implement)**
|
||||||
|
生成以下目录结构:
|
||||||
|
```
|
||||||
|
<software>/
|
||||||
|
└── agent-harness/
|
||||||
|
├── <SOFTWARE>.md ← 软件专属 SOP
|
||||||
|
├── setup.py ← pip 可安装
|
||||||
|
└── cli_anything/
|
||||||
|
└── <software>/
|
||||||
|
├── README.md
|
||||||
|
├── __init__.py
|
||||||
|
├── __main__.py
|
||||||
|
├── <software>_cli.py ← 主 CLI(Click)
|
||||||
|
├── core/ ← 核心操作模块
|
||||||
|
├── utils/
|
||||||
|
│ ├── repl_skin.py ← REPL UI(从 harness_templates/ 复制)
|
||||||
|
│ └── <software>_backend.py ← 真实后端封装
|
||||||
|
└── tests/
|
||||||
|
├── test_core.py ← 单元测试
|
||||||
|
└── test_full_e2e.py ← 端到端测试
|
||||||
|
```
|
||||||
|
|
||||||
|
**阶段4-5:测试(Test)**
|
||||||
|
```bash
|
||||||
|
cd <software>/agent-harness
|
||||||
|
pip install -e . --break-system-packages 2>/dev/null || pip install -e . --user
|
||||||
|
python -m pytest cli_anything/<software>/tests/ -v
|
||||||
|
```
|
||||||
|
|
||||||
|
**阶段6:文档**
|
||||||
|
- 更新 TEST.md,记录测试结果
|
||||||
|
|
||||||
|
**阶段7:发布(Publish)**
|
||||||
|
```bash
|
||||||
|
pip install -e . --break-system-packages 2>/dev/null || pip install -e . --user
|
||||||
|
which cli-anything-<software>
|
||||||
|
cli-anything-<software> --help
|
||||||
|
```
|
||||||
|
|
||||||
|
### 方式二:通过 Claude Code 插件(原生方式)
|
||||||
|
|
||||||
|
如果用户已安装 Claude Code:
|
||||||
|
```bash
|
||||||
|
/plugin marketplace add HKUDS/CLI-Anything
|
||||||
|
/plugin install cli-anything
|
||||||
|
/cli-anything:cli-anything ./gimp
|
||||||
|
```
|
||||||
|
|
||||||
|
### 方式三:精化已有 CLI
|
||||||
|
```bash
|
||||||
|
# 通过 Claude Code
|
||||||
|
/cli-anything:refine ./gimp
|
||||||
|
/cli-anything:refine ./gimp "增加批量图像处理和滤镜功能"
|
||||||
|
|
||||||
|
# 在 Cursor 内:读 HARNESS.md → 执行 gap analysis → 增量实现
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7 阶段流水线详解
|
||||||
|
|
||||||
|
| 阶段 | 名称 | 做什么 |
|
||||||
|
|:--|:---|:---|
|
||||||
|
| 1 | 分析 Analyze | 扫源码、找后端引擎、GUI→API 映射 |
|
||||||
|
| 2 | 设计 Design | 命令分组、状态模型、输出格式设计 |
|
||||||
|
| 3 | 实现 Implement | Click CLI + REPL + JSON 输出 + 后端封装 |
|
||||||
|
| 4 | 规划测试 Plan Tests | 写 TEST.md(单元+E2E 计划) |
|
||||||
|
| 5 | 编写测试 Write Tests | 实现 test_core.py + test_full_e2e.py |
|
||||||
|
| 6 | 文档 Document | 更新 TEST.md + README |
|
||||||
|
| 7 | 发布 Publish | setup.py + pip install -e . + 验证 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 支持的软件类别(已验证)
|
||||||
|
|
||||||
|
| 类别 | 代表软件 |
|
||||||
|
|:--|:---|
|
||||||
|
| 创意/媒体 | GIMP、Blender、Inkscape、Audacity、Kdenlive、OBS Studio |
|
||||||
|
| AI/ML 平台 | Stable Diffusion、ComfyUI、InvokeAI |
|
||||||
|
| 数据/分析 | JupyterLab、Apache Superset、Metabase、DBeaver |
|
||||||
|
| 开发工具 | Jenkins、**Gitea**、Portainer、pgAdmin、SonarQube |
|
||||||
|
| 办公/企业 | LibreOffice、GitLab、Grafana、Mattermost |
|
||||||
|
| 图表/可视化 | Draw.io、Mermaid、PlantUML、Excalidraw |
|
||||||
|
| 任意有源码软件 | 只要有代码库,均可生成 CLI |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 与卡若AI 系统的联动
|
||||||
|
|
||||||
|
- **木根(逆向分析)**:木根分析目标软件 API → 木识生成完整 CLI 层
|
||||||
|
- **火炬(全栈开发)**:生成的 CLI 可集成进卡若AI 项目的自动化流水线
|
||||||
|
- **金仓(系统监控)**:Gitea、Jenkins 等开发工具生成 CLI 后可纳入监控体系
|
||||||
|
- **土砖(技能工厂)**:每个生成的 CLI harness 可打包为基因胶囊,分发复用
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 相关文件(Files)
|
||||||
|
|
||||||
|
- 核心规范:`参考资料/HARNESS.md`(7阶段方法论完整版)
|
||||||
|
- REPL模板:`harness_templates/repl_skin.py`(REPL UI组件,直接复制使用)
|
||||||
|
- 上游仓库:`https://github.com/HKUDS/CLI-Anything`(⭐ 7700+,MIT License)
|
||||||
|
- 本机源码镜像:`/tmp/cli-anything-src`(对话内临时克隆,可重新 clone)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 依赖(Dependencies)
|
||||||
|
|
||||||
|
- 前置技能:可与木根(逆向分析)联动
|
||||||
|
- 外部工具:
|
||||||
|
- `python3 ≥ 3.10`
|
||||||
|
- `click ≥ 8.0`(`pip install click`)
|
||||||
|
- `pytest`(`pip install pytest`)
|
||||||
|
- `prompt_toolkit`(REPL 模式需要,`pip install prompt_toolkit`)
|
||||||
|
- 目标软件需在本机已安装(如需要)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 木识 · 身份档案
|
||||||
|
|
||||||
|
| 属性 | 内容 |
|
||||||
|
|:--|:---|
|
||||||
|
| **名字** | 木识(Mù Shí) |
|
||||||
|
| **所属** | 卡木(木组)第四成员 |
|
||||||
|
| **口号** | "识形成器,万物可用。" |
|
||||||
|
| **专司** | 软件识形:识别任意软件的能力形体,转化为 Agent 可驱动的 CLI |
|
||||||
|
| **记忆宫殿连接** | 「识」= 阿赖耶识,万物印记之储库;每识形一款软件,就为记忆宫殿多开一扇可编程的门 |
|
||||||
|
| **五行属性** | 木(生长、工具、创造)+ 识(认知、识别、意识) |
|
||||||
|
| **互补成员** | 木根(逆向分析找路)→ 木识(识形生成CLI通道) |
|
||||||
498
03_卡木(木)/木识_软件识形/CLI万能化/harness_templates/repl_skin.py
Normal file
498
03_卡木(木)/木识_软件识形/CLI万能化/harness_templates/repl_skin.py
Normal file
@@ -0,0 +1,498 @@
|
|||||||
|
"""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()
|
||||||
|
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
|
||||||
|
|
||||||
|
# ── 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"
|
||||||
|
|
||||||
|
# ── 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))
|
||||||
|
|
||||||
|
|
||||||
|
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):
|
||||||
|
"""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
|
||||||
|
"""
|
||||||
|
self.software = software.lower().replace("-", "_")
|
||||||
|
self.display_name = software.replace("_", " ").title()
|
||||||
|
self.version = version
|
||||||
|
self.accent = _ACCENT_COLORS.get(self.software, _DEFAULT_ACCENT)
|
||||||
|
|
||||||
|
# History file
|
||||||
|
if history_file is None:
|
||||||
|
from pathlib import Path
|
||||||
|
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."""
|
||||||
|
inner = 54
|
||||||
|
|
||||||
|
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}"
|
||||||
|
|
||||||
|
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 = ""
|
||||||
|
|
||||||
|
print(top)
|
||||||
|
print(_box_line(title))
|
||||||
|
print(_box_line(ver))
|
||||||
|
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
|
||||||
|
}
|
||||||
622
03_卡木(木)/木识_软件识形/CLI万能化/参考资料/HARNESS.md
Normal file
622
03_卡木(木)/木识_软件识形/CLI万能化/参考资料/HARNESS.md
Normal file
@@ -0,0 +1,622 @@
|
|||||||
|
# Agent Harness: GUI-to-CLI for Open Source Software
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This harness provides a standard operating procedure (SOP) and toolkit for coding
|
||||||
|
agents (Claude Code, Codex, etc.) to build powerful, stateful CLI interfaces for
|
||||||
|
open-source GUI applications. The goal: let AI agents operate software that was
|
||||||
|
designed for humans, without needing a display or mouse.
|
||||||
|
|
||||||
|
## General SOP: Turning Any GUI App into an Agent-Usable CLI
|
||||||
|
|
||||||
|
### Phase 1: Codebase Analysis
|
||||||
|
|
||||||
|
1. **Identify the backend engine** — Most GUI apps separate presentation from logic.
|
||||||
|
Find the core library/framework (e.g., MLT for Shotcut, ImageMagick for GIMP).
|
||||||
|
2. **Map GUI actions to API calls** — Every button click, drag, and menu item
|
||||||
|
corresponds to a function call. Catalog these mappings.
|
||||||
|
3. **Identify the data model** — What file formats does it use? How is project state
|
||||||
|
represented? (XML, JSON, binary, database?)
|
||||||
|
4. **Find existing CLI tools** — Many backends ship their own CLI (`melt`, `ffmpeg`,
|
||||||
|
`convert`). These are building blocks.
|
||||||
|
5. **Catalog the command/undo system** — If the app has undo/redo, it likely uses a
|
||||||
|
command pattern. These commands are your CLI operations.
|
||||||
|
|
||||||
|
### Phase 2: CLI Architecture Design
|
||||||
|
|
||||||
|
1. **Choose the interaction model**:
|
||||||
|
- **Stateful REPL** for interactive sessions (agents that maintain context)
|
||||||
|
- **Subcommand CLI** for one-shot operations (scripting, pipelines)
|
||||||
|
- **Both** (recommended) — a CLI that works in both modes
|
||||||
|
|
||||||
|
2. **Define command groups** matching the app's logical domains:
|
||||||
|
- Project management (new, open, save, close)
|
||||||
|
- Core operations (the app's primary purpose)
|
||||||
|
- Import/Export (file I/O, format conversion)
|
||||||
|
- Configuration (settings, preferences, profiles)
|
||||||
|
- Session/State management (undo, redo, history, status)
|
||||||
|
|
||||||
|
3. **Design the state model**:
|
||||||
|
- What must persist between commands? (open project, cursor position, selection)
|
||||||
|
- Where is state stored? (in-memory for REPL, file-based for CLI)
|
||||||
|
- How does state serialize? (JSON session files)
|
||||||
|
|
||||||
|
4. **Plan the output format**:
|
||||||
|
- Human-readable (tables, colors) for interactive use
|
||||||
|
- Machine-readable (JSON) for agent consumption
|
||||||
|
- Both, controlled by `--json` flag
|
||||||
|
|
||||||
|
### Phase 3: Implementation
|
||||||
|
|
||||||
|
1. **Start with the data layer** — XML/JSON manipulation of project files
|
||||||
|
2. **Add probe/info commands** — Let agents inspect before they modify
|
||||||
|
3. **Add mutation commands** — One command per logical operation
|
||||||
|
4. **Add the backend integration** — A `utils/<software>_backend.py` module that
|
||||||
|
wraps the real software's CLI. This module handles:
|
||||||
|
- Finding the software executable (`shutil.which()`)
|
||||||
|
- Invoking it with proper arguments (`subprocess.run()`)
|
||||||
|
- Error handling with clear install instructions if not found
|
||||||
|
- Example (LibreOffice):
|
||||||
|
```python
|
||||||
|
# utils/lo_backend.py
|
||||||
|
def convert_odf_to(odf_path, output_format, output_path=None, overwrite=False):
|
||||||
|
lo = find_libreoffice() # raises RuntimeError with install instructions
|
||||||
|
subprocess.run([lo, "--headless", "--convert-to", output_format, ...])
|
||||||
|
return {"output": final_path, "format": output_format, "method": "libreoffice-headless"}
|
||||||
|
```
|
||||||
|
5. **Add rendering/export** — The export pipeline calls the backend module.
|
||||||
|
Generate valid intermediate files, then invoke the real software for conversion.
|
||||||
|
6. **Add session management** — State persistence, undo/redo
|
||||||
|
7. **Add the REPL with unified skin** — Interactive mode wrapping the subcommands.
|
||||||
|
- Copy `repl_skin.py` from the plugin (`cli-anything-plugin/repl_skin.py`) into
|
||||||
|
`utils/repl_skin.py` in your CLI package
|
||||||
|
- Import and use `ReplSkin` for the REPL interface:
|
||||||
|
```python
|
||||||
|
from cli_anything.<software>.utils.repl_skin import ReplSkin
|
||||||
|
|
||||||
|
skin = ReplSkin("<software>", version="1.0.0")
|
||||||
|
skin.print_banner() # Branded startup box
|
||||||
|
pt_session = skin.create_prompt_session() # prompt_toolkit with history + styling
|
||||||
|
line = skin.get_input(pt_session, project_name="my_project", modified=True)
|
||||||
|
skin.help(commands_dict) # Formatted help listing
|
||||||
|
skin.success("Saved") # ✓ green message
|
||||||
|
skin.error("Not found") # ✗ red message
|
||||||
|
skin.warning("Unsaved") # ⚠ yellow message
|
||||||
|
skin.info("Processing...") # ● blue message
|
||||||
|
skin.status("Key", "value") # Key-value status line
|
||||||
|
skin.table(headers, rows) # Formatted table
|
||||||
|
skin.progress(3, 10, "...") # Progress bar
|
||||||
|
skin.print_goodbye() # Styled exit message
|
||||||
|
```
|
||||||
|
- Make REPL the default behavior: use `invoke_without_command=True` on the main
|
||||||
|
Click group, and invoke the `repl` command when no subcommand is given:
|
||||||
|
```python
|
||||||
|
@click.group(invoke_without_command=True)
|
||||||
|
@click.pass_context
|
||||||
|
def cli(ctx, ...):
|
||||||
|
...
|
||||||
|
if ctx.invoked_subcommand is None:
|
||||||
|
ctx.invoke(repl, project_path=None)
|
||||||
|
```
|
||||||
|
- This ensures `cli-anything-<software>` with no arguments enters the REPL
|
||||||
|
|
||||||
|
### Phase 4: Test Planning (TEST.md - Part 1)
|
||||||
|
|
||||||
|
**BEFORE writing any test code**, create a `TEST.md` file in the
|
||||||
|
`agent-harness/cli_anything/<software>/tests/` directory. This file serves as your test plan and
|
||||||
|
MUST contain:
|
||||||
|
|
||||||
|
1. **Test Inventory Plan** — List planned test files and estimated test counts:
|
||||||
|
- `test_core.py`: XX unit tests planned
|
||||||
|
- `test_full_e2e.py`: XX E2E tests planned
|
||||||
|
|
||||||
|
2. **Unit Test Plan** — For each core module, describe what will be tested:
|
||||||
|
- Module name (e.g., `project.py`)
|
||||||
|
- Functions to test
|
||||||
|
- Edge cases to cover (invalid inputs, boundary conditions, error handling)
|
||||||
|
- Expected test count
|
||||||
|
|
||||||
|
3. **E2E Test Plan** — Describe the real-world scenarios to test:
|
||||||
|
- What workflows will be simulated?
|
||||||
|
- What real files will be generated/processed?
|
||||||
|
- What output properties will be verified?
|
||||||
|
- What format validations will be performed?
|
||||||
|
|
||||||
|
4. **Realistic Workflow Scenarios** — Detail each multi-step workflow:
|
||||||
|
- **Workflow name**: Brief title
|
||||||
|
- **Simulates**: What real-world task (e.g., "photo editing pipeline",
|
||||||
|
"podcast production", "product render setup")
|
||||||
|
- **Operations chained**: Step-by-step operations
|
||||||
|
- **Verified**: What output properties will be checked
|
||||||
|
|
||||||
|
This planning document ensures comprehensive test coverage before writing code.
|
||||||
|
|
||||||
|
### Phase 5: Test Implementation
|
||||||
|
|
||||||
|
Now write the actual test code based on the TEST.md plan:
|
||||||
|
|
||||||
|
1. **Unit tests** (`test_core.py`) — Every core function tested in isolation with
|
||||||
|
synthetic data. No external dependencies.
|
||||||
|
2. **E2E tests — intermediate files** (`test_full_e2e.py`) — Verify the project files
|
||||||
|
your CLI generates are structurally correct (valid XML, correct ZIP structure, etc.)
|
||||||
|
3. **E2E tests — true backend** (`test_full_e2e.py`) — **MUST invoke the real software.**
|
||||||
|
Create a project, export via the actual software backend, and verify the output:
|
||||||
|
- File exists and size > 0
|
||||||
|
- Correct format (PDF magic bytes `%PDF-`, DOCX/XLSX/PPTX is valid ZIP/OOXML, etc.)
|
||||||
|
- Content verification where possible (CSV contains expected data, etc.)
|
||||||
|
- **Print artifact paths** so users can manually inspect: `print(f"\n PDF: {path} ({size:,} bytes)")`
|
||||||
|
- **No graceful degradation** — if the software isn't installed, tests fail, not skip
|
||||||
|
4. **Output verification** — **Don't trust that export works just because it exits
|
||||||
|
successfully.** Verify outputs programmatically:
|
||||||
|
- Magic bytes / file format validation
|
||||||
|
- ZIP structure for OOXML formats (DOCX, XLSX, PPTX)
|
||||||
|
- Pixel-level analysis for video/images (probe frames, compare brightness)
|
||||||
|
- Audio analysis (RMS levels, spectral comparison)
|
||||||
|
- Duration/format checks against expected values
|
||||||
|
5. **CLI subprocess tests** — Test the installed CLI command as a real user/agent would.
|
||||||
|
The subprocess tests MUST also produce real final output (not just ODF intermediate).
|
||||||
|
Use the `_resolve_cli` helper to run the installed `cli-anything-<software>` command:
|
||||||
|
```python
|
||||||
|
def _resolve_cli(name):
|
||||||
|
"""Resolve installed CLI command; falls back to python -m for dev.
|
||||||
|
|
||||||
|
Set env CLI_ANYTHING_FORCE_INSTALLED=1 to require the installed command.
|
||||||
|
"""
|
||||||
|
import shutil
|
||||||
|
force = os.environ.get("CLI_ANYTHING_FORCE_INSTALLED", "").strip() == "1"
|
||||||
|
path = shutil.which(name)
|
||||||
|
if path:
|
||||||
|
print(f"[_resolve_cli] Using installed command: {path}")
|
||||||
|
return [path]
|
||||||
|
if force:
|
||||||
|
raise RuntimeError(f"{name} not found in PATH. Install with: pip install -e .")
|
||||||
|
module = name.replace("cli-anything-", "cli_anything.") + "." + name.split("-")[-1] + "_cli"
|
||||||
|
print(f"[_resolve_cli] Falling back to: {sys.executable} -m {module}")
|
||||||
|
return [sys.executable, "-m", module]
|
||||||
|
|
||||||
|
|
||||||
|
class TestCLISubprocess:
|
||||||
|
CLI_BASE = _resolve_cli("cli-anything-<software>")
|
||||||
|
|
||||||
|
def _run(self, args, check=True):
|
||||||
|
return subprocess.run(
|
||||||
|
self.CLI_BASE + args,
|
||||||
|
capture_output=True, text=True,
|
||||||
|
check=check,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_help(self):
|
||||||
|
result = self._run(["--help"])
|
||||||
|
assert result.returncode == 0
|
||||||
|
|
||||||
|
def test_project_new_json(self, tmp_dir):
|
||||||
|
out = os.path.join(tmp_dir, "test.json")
|
||||||
|
result = self._run(["--json", "project", "new", "-o", out])
|
||||||
|
assert result.returncode == 0
|
||||||
|
data = json.loads(result.stdout)
|
||||||
|
# ... verify structure
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key rules for subprocess tests:**
|
||||||
|
- Always use `_resolve_cli("cli-anything-<software>")` — never hardcode
|
||||||
|
`sys.executable` or module paths directly
|
||||||
|
- Do NOT set `cwd` — installed commands must work from any directory
|
||||||
|
- Use `CLI_ANYTHING_FORCE_INSTALLED=1` in CI/release testing to ensure the
|
||||||
|
installed command (not a fallback) is being tested
|
||||||
|
- Test `--help`, `--json`, project creation, key commands, and full workflows
|
||||||
|
|
||||||
|
6. **Round-trip test** — Create project via CLI, open in GUI, verify correctness
|
||||||
|
7. **Agent test** — Have an AI agent complete a real task using only the CLI
|
||||||
|
|
||||||
|
### Phase 6: Test Documentation (TEST.md - Part 2)
|
||||||
|
|
||||||
|
After running all tests successfully, **append** to the existing TEST.md:
|
||||||
|
|
||||||
|
1. **Test Results** — Paste the full `pytest -v --tb=no` output showing all tests
|
||||||
|
passing with their names and status
|
||||||
|
2. **Summary Statistics** — Total tests, pass rate, execution time
|
||||||
|
3. **Coverage Notes** — Any gaps or areas not covered by tests
|
||||||
|
|
||||||
|
The TEST.md now serves as both the test plan (written before implementation) and
|
||||||
|
the test results documentation (appended after execution), providing a complete
|
||||||
|
record of the testing process.
|
||||||
|
|
||||||
|
## Critical Lessons Learned
|
||||||
|
|
||||||
|
### Use the Real Software — Don't Reimplement It
|
||||||
|
|
||||||
|
**This is the #1 rule.** The CLI MUST call the actual software for rendering and
|
||||||
|
export — not reimplement the software's functionality in Python.
|
||||||
|
|
||||||
|
**The anti-pattern:** Building a Pillow-based image compositor to replace GIMP,
|
||||||
|
or generating bpy scripts without ever calling Blender. This produces a toy that
|
||||||
|
can't handle real workloads and diverges from the actual software's behavior.
|
||||||
|
|
||||||
|
**The correct approach:**
|
||||||
|
1. **Use the software's CLI/scripting interface** as the backend:
|
||||||
|
- LibreOffice: `libreoffice --headless --convert-to pdf/docx/xlsx/pptx`
|
||||||
|
- Blender: `blender --background --python script.py`
|
||||||
|
- GIMP: `gimp -i -b '(script-fu-console-eval ...)'`
|
||||||
|
- Inkscape: `inkscape --actions="..." --export-filename=...`
|
||||||
|
- Shotcut/Kdenlive: `melt project.mlt -consumer avformat:output.mp4`
|
||||||
|
- Audacity: `sox` for effects processing
|
||||||
|
- OBS: `obs-websocket` protocol
|
||||||
|
|
||||||
|
2. **The software is a required dependency**, not optional. Add it to installation
|
||||||
|
instructions. The CLI is useless without the actual software.
|
||||||
|
|
||||||
|
3. **Generate valid project/intermediate files** (ODF, MLT XML, .blend, SVG, etc.)
|
||||||
|
then hand them to the real software for rendering. Your CLI is a structured
|
||||||
|
command-line interface to the software, not a replacement for it.
|
||||||
|
|
||||||
|
**Example — LibreOffice CLI export pipeline:**
|
||||||
|
```python
|
||||||
|
# 1. Build the document as a valid ODF file (our XML builder)
|
||||||
|
odf_path = write_odf(tmp_path, doc_type, project)
|
||||||
|
|
||||||
|
# 2. Convert via the REAL LibreOffice (not a reimplementation)
|
||||||
|
subprocess.run([
|
||||||
|
"libreoffice", "--headless",
|
||||||
|
"--convert-to", "pdf",
|
||||||
|
"--outdir", output_dir,
|
||||||
|
odf_path,
|
||||||
|
])
|
||||||
|
# Result: a real PDF rendered by LibreOffice's full engine
|
||||||
|
```
|
||||||
|
|
||||||
|
### The Rendering Gap
|
||||||
|
|
||||||
|
**This is the #2 pitfall.** Most GUI apps apply effects at render time via their
|
||||||
|
engine. When you build a CLI that manipulates project files directly, you must also
|
||||||
|
handle rendering — and naive approaches will silently drop effects.
|
||||||
|
|
||||||
|
**The problem:** Your CLI adds filters/effects to the project file format. But when
|
||||||
|
rendering, if you use a simple tool (e.g., ffmpeg concat demuxer), it reads raw
|
||||||
|
media files and **ignores** all project-level effects. The output looks identical to
|
||||||
|
the input. Users can't tell anything happened.
|
||||||
|
|
||||||
|
**The solution — a filter translation layer:**
|
||||||
|
1. **Best case:** Use the app's native renderer (`melt` for MLT projects). It reads
|
||||||
|
the project file and applies everything.
|
||||||
|
2. **Fallback:** Build a translation layer that converts project-format effects into
|
||||||
|
the rendering tool's native syntax (e.g., MLT filters → ffmpeg `-filter_complex`).
|
||||||
|
3. **Last resort:** Generate a render script the user can run manually.
|
||||||
|
|
||||||
|
**Priority order for rendering:** native engine → translated filtergraph → script.
|
||||||
|
|
||||||
|
### Filter Translation Pitfalls
|
||||||
|
|
||||||
|
When translating effects between formats (e.g., MLT → ffmpeg), watch for:
|
||||||
|
|
||||||
|
- **Duplicate filter types:** Some tools (ffmpeg) don't allow the same filter twice
|
||||||
|
in a chain. If your project has both `brightness` and `saturation` filters, and
|
||||||
|
both map to ffmpeg's `eq=`, you must **merge** them into a single `eq=brightness=X:saturation=Y`.
|
||||||
|
- **Ordering constraints:** ffmpeg's `concat` filter requires **interleaved** stream
|
||||||
|
ordering: `[v0][a0][v1][a1][v2][a2]`, NOT grouped `[v0][v1][v2][a0][a1][a2]`.
|
||||||
|
The error message ("media type mismatch") is cryptic if you don't know this.
|
||||||
|
- **Parameter space differences:** Effect parameters often use different scales.
|
||||||
|
MLT brightness `1.15` = +15%, but ffmpeg `eq=brightness=0.06` on a -1..1 scale.
|
||||||
|
Document every mapping explicitly.
|
||||||
|
- **Unmappable effects:** Some effects have no equivalent in the render tool. Handle
|
||||||
|
gracefully (warn, skip) rather than crash.
|
||||||
|
|
||||||
|
### Timecode Precision
|
||||||
|
|
||||||
|
Non-integer frame rates (29.97fps = 30000/1001) cause cumulative rounding errors:
|
||||||
|
|
||||||
|
- **Use `round()`, not `int()`** for float-to-frame conversion. `int(9000 * 29.97)`
|
||||||
|
truncates and loses frames; `round()` gets the right answer.
|
||||||
|
- **Use integer arithmetic for timecode display.** Convert frames → total milliseconds
|
||||||
|
via `round(frames * fps_den * 1000 / fps_num)`, then decompose with integer
|
||||||
|
division. Avoid intermediate floats that drift over long durations.
|
||||||
|
- **Accept ±1 frame tolerance** in roundtrip tests at non-integer FPS. Exact equality
|
||||||
|
is mathematically impossible.
|
||||||
|
|
||||||
|
### Output Verification Methodology
|
||||||
|
|
||||||
|
Never assume an export is correct just because it ran without errors. Verify:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Video: probe specific frames with ffmpeg
|
||||||
|
# Frame 0 for fade-in (should be near-black)
|
||||||
|
# Middle frames for color effects (compare brightness/saturation vs source)
|
||||||
|
# Last frame for fade-out (should be near-black)
|
||||||
|
|
||||||
|
# When comparing pixel values between different resolutions,
|
||||||
|
# exclude letterboxing/pillarboxing (black padding bars).
|
||||||
|
# A vertical video in a horizontal frame will have ~40% black pixels.
|
||||||
|
|
||||||
|
# Audio: check RMS levels at start/end for fades
|
||||||
|
# Compare spectral characteristics against source
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing Strategy
|
||||||
|
|
||||||
|
Four test layers with complementary purposes:
|
||||||
|
|
||||||
|
1. **Unit tests** (`test_core.py`): Synthetic data, no external dependencies. Tests
|
||||||
|
every function in isolation. Fast, deterministic, good for CI.
|
||||||
|
2. **E2E tests — native** (`test_full_e2e.py`): Tests the project file generation
|
||||||
|
pipeline (ODF structure, XML content, format validation). Verifies the
|
||||||
|
intermediate files your CLI produces are correct.
|
||||||
|
3. **E2E tests — true backend** (`test_full_e2e.py`): Invokes the **real software**
|
||||||
|
(LibreOffice, Blender, melt, etc.) to produce final output files (PDF, DOCX,
|
||||||
|
rendered images, videos). Verifies the output files:
|
||||||
|
- Exist and have size > 0
|
||||||
|
- Have correct format (magic bytes, ZIP structure, etc.)
|
||||||
|
- Contain expected content where verifiable
|
||||||
|
- **Print artifact paths** so users can manually inspect results
|
||||||
|
4. **CLI subprocess tests** (in `test_full_e2e.py`): Invokes the installed
|
||||||
|
`cli-anything-<software>` command via `subprocess.run` to run the full workflow
|
||||||
|
end-to-end: create project → add content → export via real software → verify output.
|
||||||
|
|
||||||
|
**No graceful degradation.** The real software MUST be installed. Tests must NOT
|
||||||
|
skip or fake results when the software is missing — the CLI is useless without it.
|
||||||
|
The software is a hard dependency, not optional.
|
||||||
|
|
||||||
|
**Example — true E2E test for LibreOffice:**
|
||||||
|
```python
|
||||||
|
class TestWriterToPDF:
|
||||||
|
def test_rich_writer_to_pdf(self, tmp_dir):
|
||||||
|
proj = create_document(doc_type="writer", name="Report")
|
||||||
|
add_heading(proj, text="Quarterly Report", level=1)
|
||||||
|
add_table(proj, rows=3, cols=3, data=[...])
|
||||||
|
|
||||||
|
pdf_path = os.path.join(tmp_dir, "report.pdf")
|
||||||
|
result = export(proj, pdf_path, preset="pdf", overwrite=True)
|
||||||
|
|
||||||
|
# Verify the REAL output file
|
||||||
|
assert os.path.exists(result["output"])
|
||||||
|
assert result["file_size"] > 1000 # Not suspiciously small
|
||||||
|
with open(result["output"], "rb") as f:
|
||||||
|
assert f.read(5) == b"%PDF-" # Validate format magic bytes
|
||||||
|
print(f"\n PDF: {result['output']} ({result['file_size']:,} bytes)")
|
||||||
|
|
||||||
|
|
||||||
|
class TestCLISubprocessE2E:
|
||||||
|
CLI_BASE = _resolve_cli("cli-anything-libreoffice")
|
||||||
|
|
||||||
|
def test_full_writer_pdf_workflow(self, tmp_dir):
|
||||||
|
proj_path = os.path.join(tmp_dir, "test.json")
|
||||||
|
pdf_path = os.path.join(tmp_dir, "output.pdf")
|
||||||
|
self._run(["document", "new", "-o", proj_path, "--type", "writer"])
|
||||||
|
self._run(["--project", proj_path, "writer", "add-heading", "-t", "Title"])
|
||||||
|
self._run(["--project", proj_path, "export", "render", pdf_path, "-p", "pdf", "--overwrite"])
|
||||||
|
assert os.path.exists(pdf_path)
|
||||||
|
with open(pdf_path, "rb") as f:
|
||||||
|
assert f.read(5) == b"%PDF-"
|
||||||
|
```
|
||||||
|
|
||||||
|
Run tests in force-installed mode to guarantee the real command is used:
|
||||||
|
```bash
|
||||||
|
CLI_ANYTHING_FORCE_INSTALLED=1 python3 -m pytest cli_anything/<software>/tests/ -v -s
|
||||||
|
```
|
||||||
|
The `-s` flag shows the `[_resolve_cli]` print output confirming which backend
|
||||||
|
is being used and **prints artifact paths** for manual inspection.
|
||||||
|
|
||||||
|
Real-world workflow test scenarios should include:
|
||||||
|
- Multi-segment editing (YouTube-style cut/trim)
|
||||||
|
- Montage assembly (many short clips)
|
||||||
|
- Picture-in-picture compositing
|
||||||
|
- Color grading pipelines
|
||||||
|
- Audio mixing (podcast-style)
|
||||||
|
- Heavy undo/redo stress testing
|
||||||
|
- Save/load round-trips of complex projects
|
||||||
|
- Iterative refinement (add, modify, remove, re-add)
|
||||||
|
|
||||||
|
## Key Principles
|
||||||
|
|
||||||
|
- **Use the real software** — The CLI MUST invoke the actual application for rendering
|
||||||
|
and export. Generate valid intermediate files (ODF, MLT XML, .blend, SVG), then hand
|
||||||
|
them to the real software. Never reimplement the rendering engine in Python.
|
||||||
|
- **The software is a hard dependency** — Not optional, not gracefully degraded. If
|
||||||
|
LibreOffice isn't installed, `cli-anything-libreoffice` must error clearly, not
|
||||||
|
silently produce inferior output with a fallback library.
|
||||||
|
- **Manipulate the native format directly** — Parse and modify the app's native project
|
||||||
|
files (MLT XML, ODF, SVG, etc.) as the data layer.
|
||||||
|
- **Leverage existing CLI tools** — Use `libreoffice --headless`, `blender --background`,
|
||||||
|
`melt`, `ffmpeg`, `inkscape --actions`, `sox` as subprocesses for rendering.
|
||||||
|
- **Verify rendering produces correct output** — See "The Rendering Gap" above.
|
||||||
|
- **E2E tests must produce real artifacts** — PDF, DOCX, rendered images, videos.
|
||||||
|
Print output paths so users can inspect. Never test only the intermediate format.
|
||||||
|
- **Fail loudly and clearly** — Agents need unambiguous error messages to self-correct.
|
||||||
|
- **Be idempotent where possible** — Running the same command twice should be safe.
|
||||||
|
- **Provide introspection** — `info`, `list`, `status` commands are critical for agents
|
||||||
|
to understand current state before acting.
|
||||||
|
- **JSON output mode** — Every command should support `--json` for machine parsing.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- **The real software MUST be a hard dependency.** The CLI must invoke the actual
|
||||||
|
software (LibreOffice, Blender, GIMP, etc.) for rendering and export. Do NOT
|
||||||
|
reimplement rendering in Python. Do NOT gracefully degrade to a fallback library.
|
||||||
|
If the software is not installed, the CLI must error with clear install instructions.
|
||||||
|
- **Every `cli_anything/<software>/` directory MUST contain a `README.md`** that explains how to
|
||||||
|
install the software dependency, install the CLI, run tests, and shows basic usage.
|
||||||
|
- **E2E tests MUST invoke the real software** and produce real output files (PDF, DOCX,
|
||||||
|
rendered images, videos). Tests must verify output exists, has correct format, and
|
||||||
|
print artifact paths so users can inspect results. Never test only intermediate files.
|
||||||
|
- **Every export/render function MUST be verified** with programmatic output analysis
|
||||||
|
before being marked as working. "It ran without errors" is not sufficient.
|
||||||
|
- **Every filter/effect in the registry MUST have a corresponding render mapping**
|
||||||
|
or be explicitly documented as "project-only (not rendered)".
|
||||||
|
- **Test suites MUST include real-file E2E tests**, not just unit tests with synthetic
|
||||||
|
data. Format assumptions break constantly with real media.
|
||||||
|
- **E2E tests MUST include subprocess tests** that invoke the installed
|
||||||
|
`cli-anything-<software>` command via `_resolve_cli()`. Tests must work against
|
||||||
|
the actual installed package, not just source imports.
|
||||||
|
- **Every `cli_anything/<software>/tests/` directory MUST contain a `TEST.md`** documenting what the tests
|
||||||
|
cover, what realistic workflows are tested, and the full test results output.
|
||||||
|
- **Every CLI MUST use the unified REPL skin** (`repl_skin.py`) for the interactive mode.
|
||||||
|
Copy `cli-anything-plugin/repl_skin.py` to `utils/repl_skin.py` and use `ReplSkin`
|
||||||
|
for the banner, prompt, help, messages, and goodbye. REPL MUST be the default behavior
|
||||||
|
when the CLI is invoked without a subcommand (`invoke_without_command=True`).
|
||||||
|
|
||||||
|
## Directory Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
<software>/
|
||||||
|
└── agent-harness/
|
||||||
|
├── <SOFTWARE>.md # Project-specific analysis and SOP
|
||||||
|
├── setup.py # PyPI package configuration (Phase 7)
|
||||||
|
├── cli_anything/ # Namespace package (NO __init__.py here)
|
||||||
|
│ └── <software>/ # Sub-package for this CLI
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── __main__.py # python3 -m cli_anything.<software>
|
||||||
|
│ ├── README.md # HOW TO RUN — required
|
||||||
|
│ ├── <software>_cli.py # Main CLI entry point (Click + REPL)
|
||||||
|
│ ├── core/ # Core modules (one per domain)
|
||||||
|
│ │ ├── __init__.py
|
||||||
|
│ │ ├── project.py # Project create/open/save/info
|
||||||
|
│ │ ├── ... # Domain-specific modules
|
||||||
|
│ │ ├── export.py # Render pipeline + filter translation
|
||||||
|
│ │ └── session.py # Stateful session, undo/redo
|
||||||
|
│ ├── utils/ # Shared utilities
|
||||||
|
│ │ ├── __init__.py
|
||||||
|
│ │ ├── <software>_backend.py # Backend: invokes the real software
|
||||||
|
│ │ └── repl_skin.py # Unified REPL skin (copy from plugin)
|
||||||
|
│ └── tests/ # Test suites
|
||||||
|
│ ├── TEST.md # Test documentation and results — required
|
||||||
|
│ ├── test_core.py # Unit tests (synthetic data)
|
||||||
|
│ └── test_full_e2e.py # E2E tests (real files)
|
||||||
|
└── examples/ # Example scripts and workflows
|
||||||
|
```
|
||||||
|
|
||||||
|
**Critical:** The `cli_anything/` directory must NOT contain an `__init__.py`.
|
||||||
|
This is what makes it a PEP 420 namespace package — multiple separately-installed
|
||||||
|
PyPI packages can each contribute a sub-package under `cli_anything/` without
|
||||||
|
conflicting. For example, `cli-anything-gimp` adds `cli_anything/gimp/` and
|
||||||
|
`cli-anything-blender` adds `cli_anything/blender/`, and both coexist in the
|
||||||
|
same Python environment.
|
||||||
|
|
||||||
|
Note: This HARNESS.md is part of the cli-anything-plugin. Individual software directories reference this file — do NOT duplicate it.
|
||||||
|
|
||||||
|
## Applying This to Other Software
|
||||||
|
|
||||||
|
This same SOP applies to any GUI application:
|
||||||
|
|
||||||
|
| Software | Backend CLI | Native Format | System Package | How the CLI Uses It |
|
||||||
|
|----------|-------------|---------------|----------------|-------------------|
|
||||||
|
| LibreOffice | `libreoffice --headless` | .odt/.ods/.odp (ODF ZIP) | `apt install libreoffice` | Generate ODF → convert to PDF/DOCX/XLSX/PPTX |
|
||||||
|
| Blender | `blender --background --python` | .blend-cli.json | `apt install blender` | Generate bpy script → Blender renders to PNG/MP4 |
|
||||||
|
| GIMP | `gimp -i -b '(script-fu ...)'` | .xcf | `apt install gimp` | Script-Fu commands → GIMP processes & exports |
|
||||||
|
| Inkscape | `inkscape --actions="..."` | .svg (XML) | `apt install inkscape` | Manipulate SVG → Inkscape exports to PNG/PDF |
|
||||||
|
| Shotcut/Kdenlive | `melt` or `ffmpeg` | .mlt (XML) | `apt install melt ffmpeg` | Build MLT XML → melt/ffmpeg renders video |
|
||||||
|
| Audacity | `sox` | .aup3 | `apt install sox` | Generate sox commands → sox processes audio |
|
||||||
|
| OBS Studio | `obs-websocket` | scene.json | `apt install obs-studio` | WebSocket API → OBS captures/records |
|
||||||
|
|
||||||
|
**The software is a required dependency, not optional.** The CLI generates valid
|
||||||
|
intermediate files (ODF, MLT XML, bpy scripts, SVG) and hands them to the real
|
||||||
|
software for rendering. This is what makes the CLI actually useful — it's a
|
||||||
|
command-line interface TO the software, not a replacement for it.
|
||||||
|
|
||||||
|
The pattern is always the same: **build the data → call the real software → verify
|
||||||
|
the output**.
|
||||||
|
|
||||||
|
### Phase 7: PyPI Publishing and Installation
|
||||||
|
|
||||||
|
After building and testing the CLI, make it installable and discoverable.
|
||||||
|
|
||||||
|
All cli-anything CLIs use **PEP 420 namespace packages** under the shared
|
||||||
|
`cli_anything` namespace. This allows multiple CLI packages to be installed
|
||||||
|
side-by-side in the same Python environment without conflicts.
|
||||||
|
|
||||||
|
1. **Structure the package** as a namespace package:
|
||||||
|
```
|
||||||
|
agent-harness/
|
||||||
|
├── setup.py
|
||||||
|
└── cli_anything/ # NO __init__.py here (namespace package)
|
||||||
|
└── <software>/ # e.g., gimp, blender, audacity
|
||||||
|
├── __init__.py # HAS __init__.py (regular sub-package)
|
||||||
|
├── <software>_cli.py
|
||||||
|
├── core/
|
||||||
|
├── utils/
|
||||||
|
└── tests/
|
||||||
|
```
|
||||||
|
|
||||||
|
The key rule: `cli_anything/` has **no** `__init__.py`. Each sub-package
|
||||||
|
(`gimp/`, `blender/`, etc.) **does** have `__init__.py`. This is what
|
||||||
|
enables multiple packages to contribute to the same namespace.
|
||||||
|
|
||||||
|
2. **Create setup.py** in the `agent-harness/` directory:
|
||||||
|
```python
|
||||||
|
from setuptools import setup, find_namespace_packages
|
||||||
|
|
||||||
|
setup(
|
||||||
|
name="cli-anything-<software>",
|
||||||
|
version="1.0.0",
|
||||||
|
packages=find_namespace_packages(include=["cli_anything.*"]),
|
||||||
|
install_requires=[
|
||||||
|
"click>=8.0.0",
|
||||||
|
"prompt-toolkit>=3.0.0",
|
||||||
|
# Add Python library dependencies here
|
||||||
|
],
|
||||||
|
entry_points={
|
||||||
|
"console_scripts": [
|
||||||
|
"cli-anything-<software>=cli_anything.<software>.<software>_cli:main",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
python_requires=">=3.10",
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important details:**
|
||||||
|
- Use `find_namespace_packages`, NOT `find_packages`
|
||||||
|
- Use `include=["cli_anything.*"]` to scope discovery
|
||||||
|
- Entry point format: `cli_anything.<software>.<software>_cli:main`
|
||||||
|
- The **system package** (LibreOffice, Blender, etc.) is a **hard dependency**
|
||||||
|
that cannot be expressed in `install_requires`. Document it in README.md and
|
||||||
|
have the backend module raise a clear error with install instructions:
|
||||||
|
```python
|
||||||
|
# In utils/<software>_backend.py
|
||||||
|
def find_<software>():
|
||||||
|
path = shutil.which("<software>")
|
||||||
|
if path:
|
||||||
|
return path
|
||||||
|
raise RuntimeError(
|
||||||
|
"<Software> is not installed. Install it with:\n"
|
||||||
|
" apt install <software> # Debian/Ubuntu\n"
|
||||||
|
" brew install <software> # macOS"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **All imports** use the `cli_anything.<software>` prefix:
|
||||||
|
```python
|
||||||
|
from cli_anything.gimp.core.project import create_project
|
||||||
|
from cli_anything.gimp.core.session import Session
|
||||||
|
from cli_anything.blender.core.scene import create_scene
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Test local installation**:
|
||||||
|
```bash
|
||||||
|
cd /root/cli-anything/<software>/agent-harness
|
||||||
|
pip install -e .
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Verify PATH installation**:
|
||||||
|
```bash
|
||||||
|
which cli-anything-<software>
|
||||||
|
cli-anything-<software> --help
|
||||||
|
```
|
||||||
|
|
||||||
|
6. **Run tests against the installed command**:
|
||||||
|
```bash
|
||||||
|
cd /root/cli-anything/<software>/agent-harness
|
||||||
|
CLI_ANYTHING_FORCE_INSTALLED=1 python3 -m pytest cli_anything/<software>/tests/ -v -s
|
||||||
|
```
|
||||||
|
The output must show `[_resolve_cli] Using installed command: /path/to/cli-anything-<software>`
|
||||||
|
confirming subprocess tests ran against the real installed binary, not a module fallback.
|
||||||
|
|
||||||
|
7. **Verify namespace works across packages** (when multiple CLIs installed):
|
||||||
|
```python
|
||||||
|
import cli_anything.gimp
|
||||||
|
import cli_anything.blender
|
||||||
|
# Both resolve to their respective source directories
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why namespace packages:**
|
||||||
|
- Multiple CLIs coexist in the same Python environment without conflicts
|
||||||
|
- Clean, organized imports under a single `cli_anything` namespace
|
||||||
|
- Each CLI is independently installable/uninstallable via pip
|
||||||
|
- Agents can discover all installed CLIs via `cli_anything.*`
|
||||||
|
- Standard Python packaging — no hacks or workarounds
|
||||||
25
03_卡木(木)/木识_软件识形/SKILL.md
Normal file
25
03_卡木(木)/木识_软件识形/SKILL.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
---
|
||||||
|
name: 木识(软件识形)
|
||||||
|
description: 卡木第四成员,专司软件识形——识别任意软件架构,生成 AI Agent 可驱动的 CLI 接口
|
||||||
|
triggers: 木识、软件识形、CLI万能化、cli-anything、让软件被Agent控制
|
||||||
|
owner: 木识
|
||||||
|
group: 木(卡木)
|
||||||
|
version: "1.0"
|
||||||
|
updated: "2026-03-12"
|
||||||
|
---
|
||||||
|
|
||||||
|
# 木识 · 软件识形
|
||||||
|
|
||||||
|
> 「识形成器,万物可用。」
|
||||||
|
|
||||||
|
卡木第四成员。识别任意软件的能力与架构,通过 CLI-Anything 7阶段流水线,将任意有源码的软件转化为 AI Agent 可驱动的 CLI 接口。
|
||||||
|
|
||||||
|
## 技能清单
|
||||||
|
|
||||||
|
| 技能 | 路径 | 触发词 |
|
||||||
|
|:--|:---|:---|
|
||||||
|
| CLI万能化 | `CLI万能化/SKILL.md` | cli-anything、软件识形、让软件Agent化 |
|
||||||
|
|
||||||
|
## 快速触发
|
||||||
|
|
||||||
|
说「木识」或「CLI万能化」即可激活,按 `CLI万能化/SKILL.md` 执行。
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
卡若AI(大总管)
|
卡若AI(大总管)
|
||||||
├── 卡资(金)"稳了。" → 金仓(存储备份)、金盾(数据安全)
|
├── 卡资(金)"稳了。" → 金仓(存储备份)、金盾(数据安全)
|
||||||
├── 卡人(水)"搞定了。" → 水溪(整理归档)、水泉(规划拆解)、水桥(平台对接)
|
├── 卡人(水)"搞定了。" → 水溪(整理归档)、水泉(规划拆解)、水桥(平台对接)
|
||||||
├── 卡木(木)"搞起!" → 木叶(视频内容)、木根(逆向分析)、木果(项目模板)
|
├── 卡木(木)"搞起!" → 木叶(视频内容)、木根(逆向分析)、木果(项目模板)、木识(软件识形)
|
||||||
├── 卡火(火)"让我想想…" → 火炬(全栈消息)、火锤(代码修复)、火眼(智能追问)、火种(知识模型)
|
├── 卡火(火)"让我想想…" → 火炬(全栈消息)、火锤(代码修复)、火眼(智能追问)、火种(知识模型)
|
||||||
└── 卡土(土)"先算账。" → 土基(商业分析)、土砖(技能复制)、土渠(流量招商)、土簿(财务管理)
|
└── 卡土(土)"先算账。" → 土基(商业分析)、土砖(技能复制)、土渠(流量招商)、土簿(财务管理)
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -116,6 +116,7 @@
|
|||||||
| M07 | PPT制作 | 木果 | **PPT、做PPT、制作PPT、演示文稿、汇报PPT** | `03_卡木(木)/木果_项目模板/PPT制作/SKILL.md` | python-pptx 创建/编辑 .pptx,输出到报告目录 |
|
| M07 | PPT制作 | 木果 | **PPT、做PPT、制作PPT、演示文稿、汇报PPT** | `03_卡木(木)/木果_项目模板/PPT制作/SKILL.md` | python-pptx 创建/编辑 .pptx,输出到报告目录 |
|
||||||
| M08 | Next AI Draw | 木果 | **next ai draw、AI画图、画图表、架构图、流程图** | `03_卡木(木)/木果_项目模板/Next AI Draw/SKILL.md` | AI 生成 draw.io 风格图、Mermaid 图表,与 PPT 联动 |
|
| M08 | Next AI Draw | 木果 | **next ai draw、AI画图、画图表、架构图、流程图** | `03_卡木(木)/木果_项目模板/Next AI Draw/SKILL.md` | AI 生成 draw.io 风格图、Mermaid 图表,与 PPT 联动 |
|
||||||
| M09 | 卡若个人介绍 | 木果 | **卡若介绍、个人介绍、卡若人设、我是谁** | `03_卡木(木)/木果_项目模板/卡若个人介绍/SKILL.md` | 生成卡若个人介绍(PPT/短文/一页纸) |
|
| M09 | 卡若个人介绍 | 木果 | **卡若介绍、个人介绍、卡若人设、我是谁** | `03_卡木(木)/木果_项目模板/卡若个人介绍/SKILL.md` | 生成卡若个人介绍(PPT/短文/一页纸) |
|
||||||
|
| M10 | **CLI万能化** | 木识 | **木识、CLI万能化、cli-anything、软件识形、让软件Agent化、任意软件CLI、给软件生成CLI、GUI转CLI、让软件可被AI控制** | `03_卡木(木)/木识_软件识形/CLI万能化/SKILL.md` | 任意软件→Agent可驱动CLI;7阶段全自动流水线;与木根联动 |
|
||||||
|
|
||||||
## 火组 · 卡火(技术研发优化)
|
## 火组 · 卡火(技术研发优化)
|
||||||
|
|
||||||
|
|||||||
@@ -312,3 +312,4 @@
|
|||||||
| 2026-03-12 20:55:40 | 🔄 卡若AI 同步 2026-03-12 20:55 | 更新:运营中枢、运营中枢工作台 | 排除 >20MB: 11 个 |
|
| 2026-03-12 20:55:40 | 🔄 卡若AI 同步 2026-03-12 20:55 | 更新:运营中枢、运营中枢工作台 | 排除 >20MB: 11 个 |
|
||||||
| 2026-03-12 21:30:48 | 🔄 卡若AI 同步 2026-03-12 21:26 | 更新:运营中枢、运营中枢工作台 | 排除 >20MB: 11 个 |
|
| 2026-03-12 21:30:48 | 🔄 卡若AI 同步 2026-03-12 21:26 | 更新:运营中枢、运营中枢工作台 | 排除 >20MB: 11 个 |
|
||||||
| 2026-03-12 22:05:51 | 🔄 卡若AI 同步 2026-03-12 22:05 | 更新:水桥平台对接、运营中枢工作台 | 排除 >20MB: 11 个 |
|
| 2026-03-12 22:05:51 | 🔄 卡若AI 同步 2026-03-12 22:05 | 更新:水桥平台对接、运营中枢工作台 | 排除 >20MB: 11 个 |
|
||||||
|
| 2026-03-12 22:33:45 | 🔄 卡若AI 同步 2026-03-12 22:33 | 更新:水桥平台对接、总索引与入口、运营中枢工作台 | 排除 >20MB: 11 个 |
|
||||||
|
|||||||
@@ -315,3 +315,4 @@
|
|||||||
| 2026-03-12 20:55:40 | 成功 | 成功 | 🔄 卡若AI 同步 2026-03-12 20:55 | 更新:运营中枢、运营中枢工作台 | 排除 >20MB: 11 个 | [仓库](http://open.quwanzhi.com:3000/fnvtk/karuo-ai) [百科](http://open.quwanzhi.com:3000/fnvtk/karuo-ai/wiki) |
|
| 2026-03-12 20:55:40 | 成功 | 成功 | 🔄 卡若AI 同步 2026-03-12 20:55 | 更新:运营中枢、运营中枢工作台 | 排除 >20MB: 11 个 | [仓库](http://open.quwanzhi.com:3000/fnvtk/karuo-ai) [百科](http://open.quwanzhi.com:3000/fnvtk/karuo-ai/wiki) |
|
||||||
| 2026-03-12 21:30:48 | 成功 | 成功 | 🔄 卡若AI 同步 2026-03-12 21:26 | 更新:运营中枢、运营中枢工作台 | 排除 >20MB: 11 个 | [仓库](http://open.quwanzhi.com:3000/fnvtk/karuo-ai) [百科](http://open.quwanzhi.com:3000/fnvtk/karuo-ai/wiki) |
|
| 2026-03-12 21:30:48 | 成功 | 成功 | 🔄 卡若AI 同步 2026-03-12 21:26 | 更新:运营中枢、运营中枢工作台 | 排除 >20MB: 11 个 | [仓库](http://open.quwanzhi.com:3000/fnvtk/karuo-ai) [百科](http://open.quwanzhi.com:3000/fnvtk/karuo-ai/wiki) |
|
||||||
| 2026-03-12 22:05:51 | 成功 | 成功 | 🔄 卡若AI 同步 2026-03-12 22:05 | 更新:水桥平台对接、运营中枢工作台 | 排除 >20MB: 11 个 | [仓库](http://open.quwanzhi.com:3000/fnvtk/karuo-ai) [百科](http://open.quwanzhi.com:3000/fnvtk/karuo-ai/wiki) |
|
| 2026-03-12 22:05:51 | 成功 | 成功 | 🔄 卡若AI 同步 2026-03-12 22:05 | 更新:水桥平台对接、运营中枢工作台 | 排除 >20MB: 11 个 | [仓库](http://open.quwanzhi.com:3000/fnvtk/karuo-ai) [百科](http://open.quwanzhi.com:3000/fnvtk/karuo-ai/wiki) |
|
||||||
|
| 2026-03-12 22:33:45 | 成功 | 成功 | 🔄 卡若AI 同步 2026-03-12 22:33 | 更新:水桥平台对接、总索引与入口、运营中枢工作台 | 排除 >20MB: 11 个 | [仓库](http://open.quwanzhi.com:3000/fnvtk/karuo-ai) [百科](http://open.quwanzhi.com:3000/fnvtk/karuo-ai/wiki) |
|
||||||
|
|||||||
Reference in New Issue
Block a user