Kaynağa Gözat

feat: skills for claude

Talegorithm 1 ay önce
ebeveyn
işleme
c363aa34af

+ 6 - 3
agent/tools/builtin/content/cache.py

@@ -2,7 +2,8 @@
 内容搜索缓存(磁盘持久化)
 
 搜索结果按 trace_id 隔离,同一 Agent session 内的 CLI 多次调用也能复用。
-文件格式:/tmp/content_cache_{trace_id}.json
+文件格式:<cwd>/.cache/content_search/{trace_id}.json
+锚在调用方 CWD 的 .cache/ 下,每个项目隔离且 gitignore 友好。
 """
 
 import json
@@ -11,13 +12,15 @@ import time
 from pathlib import Path
 from typing import Any, Dict, List, Optional
 
-_CACHE_DIR = Path("/tmp")
+# CWD 锚定 —— 每个调用项目有独立缓存目录,避免 /tmp 跨会话污染
+_CACHE_DIR = Path(os.getcwd()) / ".cache" / "content_search"
+_CACHE_DIR.mkdir(parents=True, exist_ok=True)
 _CACHE_TTL = 3600  # 1 小时过期
 
 
 def _cache_path(trace_id: str) -> Path:
     safe_id = trace_id.replace("/", "_").replace("..", "_")
-    return _CACHE_DIR / f"content_cache_{safe_id}.json"
+    return _CACHE_DIR / f"{safe_id}.json"
 
 
 def _load_raw(trace_id: str) -> dict:

+ 72 - 0
skills4claude/README.md

@@ -0,0 +1,72 @@
+# skills
+
+Claude Code skills 的版本化源头。所有能给 Claude Code 使用的 skill 都放在这里,通过 `install.sh` 同步到 `~/.claude/skills/`。
+
+## 这里放什么?
+
+只放**本仓库相关**的 skill —— 也就是依赖或协作于 `cyber-agent` / KnowHub / ToolHub 的那些。
+
+| Skill | 形态 | 依赖 |
+|-------|------|------|
+| `agent/` | CLI 薄壳 + 共享 config | `cyber-agent` editable install |
+| `toolhub/` | 自包含单脚本 | 仅 `httpx`(可选 `cyber_sdk` 走 OSS) |
+| `knowhub/` | 自包含单脚本 | 仅 `httpx` |
+| `content-search/` | 纯文档,调 `python -m ...` | `cyber-agent` editable install |
+
+个人/跨项目的 skill(如 `glk-*`、`stitch-images`)不放这里。
+
+## 安装
+
+```bash
+# 默认:symlink,适合本机开发
+bash skills/install.sh
+
+# 跨机器部署:copy,脱离 repo
+bash skills/install.sh --copy
+
+# 只装指定 skill
+bash skills/install.sh --skills agent,toolhub
+
+# 改安装路径
+bash skills/install.sh --target /some/other/dir
+
+# 先看会做什么,不动文件
+bash skills/install.sh --dry-run
+
+# 强制覆盖已有内容(否则冲突即失败)
+bash skills/install.sh --force
+```
+
+`symlink` 模式下在 `skills/X/*` 改文件会立即反映在 `~/.claude/skills/X/`。
+`copy` 模式下每次改完需要重跑脚本。
+
+## 冲突处理
+
+`install.sh` **不自动备份**。遇到目标已存在的情况会这样处理:
+
+- 已是指向本 repo 的 symlink → 安静跳过(no-op)
+- 其他(旧目录 / 错位 symlink / 普通文件)→ **报错退出(exit 2)**,打印现状让你自己决定
+
+想覆盖加 `--force`,它会 `rm -rf` 现有目标再装。**不会**偷偷复制到 `.bak.*` —— 那样只会污染 `~/.claude/skills/`(Claude Code 会把 `.bak.*` 当成 skill 去索引)。旧内容若有价值,大多数时候本来就在 git 里;真要保底,自己 `mv` 一下再跑脚本。
+
+## 跨项目调用
+
+所有脚本都用**绝对路径**调用,不受 CWD 影响:
+
+```bash
+# agent — 本地或远端 agent
+python ~/.claude/skills/agent/invoke.py --agent_type=remote_librarian --task="..."
+
+# toolhub — 远程 AI 工具
+python ~/.claude/skills/toolhub/toolhub.py call --tool_id=flux_gen --params='{...}'
+
+# knowhub — 知识库
+python ~/.claude/skills/knowhub/knowhub.py search --query="..."
+python ~/.claude/skills/knowhub/knowhub.py save --task="..." --content="..."
+python ~/.claude/skills/knowhub/knowhub.py ask --query="..."
+
+# content-search — 内容平台搜索
+python -m agent.tools.builtin.content.tools platforms
+```
+
+每个脚本的 trace / 输出都落在**调用方 CWD 的 `.cache/`** 下,和项目解耦。

+ 71 - 0
skills4claude/agent/SKILL.md

@@ -0,0 +1,71 @@
+---
+name: agent
+description: 调用 Agent 执行任务 —— 能从知识库获取内容制作经验、擅长使用浏览器做深度调研。
+---
+
+# Agent 调用
+
+统一入口调用远端(KnowHub 服务器)或本地 Agent。底层走 `cyber-agent` SDK 的 `invoke_agent()`。
+
+## 前置
+
+需要 `cyber-agent` 包可 import。若未安装,在该仓库根目录执行:
+
+```bash
+pip install -e .
+```
+
+## 用法
+
+```bash
+python <this_skill_dir>/invoke.py \
+    --agent_type=<type> \
+    --task="<任务描述>" \
+    [--skills=skill1,skill2] \
+    [--continue_from=<sub_trace_id>] \
+    [--project_root=<本地项目目录>]
+```
+
+## 远端 Agent(`remote_` 前缀)
+
+**`remote_librarian`** — 知识库查询与上传:
+- `skills=ask_strategy`(默认):查询整合,返回带引用的回答
+- `skills=upload_strategy`:上传(`task` 为 JSON 字符串 `{knowledge:[...], tools:[...], resources:[...]}`)
+
+**`remote_research`** — 深度调研:自动全网搜集 + 总结,成果自动入库。
+
+## 本地 Agent
+
+`agent_type` 无 `remote_` 前缀。**默认使用 skill 目录自带的共享 config**,无需每个项目单独建 `config.py`。
+
+- **默认行为**:不传 `--project_root` → 用 skill 目录的 `config.py`,trace 落在调用方 CWD 的 `.cache/trace/`(自动创建)
+- **项目覆盖**:如果项目需要自己的 `RUN_CONFIG` / `presets.json` / `tools/`,传 `--project_root=<项目目录>`,该目录需满足:
+  - `config.py` 定义 `RUN_CONFIG`(必需)
+  - `presets.json` 定义 preset(可选)
+  - `tools/__init__.py` 注册自定义工具(可选)
+
+## 续跑
+
+首次调用返回的 `sub_trace_id` 作为下次的 `--continue_from`,同一 Agent 累积上下文。
+
+## 返回值
+
+stdout 输出 JSON,成功退出码 0、失败 1:
+
+```json
+{
+  "mode": "remote" | "local",
+  "agent_type": "...",
+  "sub_trace_id": "...",
+  "status": "completed" | "failed",
+  "summary": "Agent 最终产出的 message 文本(结构化信息由 prompt 约定写在这里)",
+  "stats": {"total_messages": N, "total_tokens": N, "total_cost": 0.xxx},
+  "error": null
+}
+```
+
+## 注意
+
+- 远端 `skills` 由服务器白名单过滤,非法项静默丢弃
+- 远端 Agent 无法访问调用方本地文件系统——大数据先通过 knowhub / toolhub 上传再传 ID
+- `summary` 是纯文本;需要结构化字段(引用来源、ID 等)时由 Agent prompt 约定格式,调用方自己 parse

+ 149 - 0
skills4claude/agent/config.py

@@ -0,0 +1,149 @@
+"""
+Agent skill 共享配置
+=====================
+
+这是 /Users/sunlit/.claude/skills/agent/ 的本地 agent 运行配置,所有项目共用。
+如果某个项目需要不同的 RUN_CONFIG / presets / tools,在那个项目里放一份自己的
+config.py 并显式传 `--project_root=<项目目录>`,CLI 会用项目 config 覆盖这份默认。
+
+字段来源:
+- RunConfig        → agent/core/runner.py:97
+- KnowledgeConfig  → agent/tools/builtin/knowledge.py:26
+- MemoryConfig     → agent/core/memory.py(可选,默认 None = 无长期记忆)
+- FileSystemTraceStore(base_path=TRACE_STORE_PATH) → agent/trace/store.py:36
+
+调用方式:
+    python invoke.py --agent_type=<type> --task="..."   # 自动使用本 config
+    python invoke.py --agent_type=<type> --task="..." \
+                     --project_root=/path/to/project    # 改用项目 config
+
+注:CLI 传入的 --agent_type / --skills / --continue_from 会在加载 config 后
+覆盖 RUN_CONFIG 的对应字段(见 agent/client.py:172-176)。
+"""
+
+import os
+from pathlib import Path
+
+from agent.core.runner import RunConfig
+from agent.tools.builtin.knowledge import KnowledgeConfig
+# 如果需要 memory-bearing agent,取消下一行注释:
+# from agent.core.memory import MemoryConfig
+
+
+# =============================================================================
+# Agent 运行配置(RUN_CONFIG)
+# =============================================================================
+# 所有字段都有默认值。仅列出"值得关注或常被调"的字段;完整字段见 runner.py:97。
+# agent_type / skills / trace_id 会被 CLI 覆盖,这里写什么都行(留占位符即可)。
+
+RUN_CONFIG = RunConfig(
+    # --- 模型层 -------------------------------------------------------------
+    model="qwen3.5-plus",           # LLM 模型名。常用:"gpt-4o" / "qwen3.5-plus" / "claude-sonnet-4-6"
+    temperature=0.3,                # 采样温度。调研/规划建议 0.3;创意任务可升到 0.7
+    max_iterations=1000,            # 单次 run 的最大工具调用轮数。远端 agent 一般够用,复杂本地任务可调大
+
+    # 传给 LLM 的额外参数(OpenAI SDK kwargs)。
+    # 常用例子:阿里 Qwen 的 thinking 模式、Claude 的 thinking budget。
+    extra_llm_params={
+        "extra_body": {"enable_thinking": True},   # 启用 Qwen thinking(仅对 qwen 生效,其他模型忽略)
+    },
+
+    # --- 工具选择 -----------------------------------------------------------
+    # tools=None 时按 tool_groups 白名单过滤;显式列 tool name 则精确指定。
+    tools=None,
+    tool_groups=["core"],           # 只开核心工具组;需要 knowledge/browser 工具时加 "knowledge" / "browser"
+    exclude_tools=[],               # 即使在 groups 里命中,也从最终集合里剔除的工具名
+
+    # --- 框架层 -------------------------------------------------------------
+    agent_type="default",           # 被 CLI --agent_type 覆盖,这里写啥都行
+    skills=None,                    # 被 CLI --skills 覆盖;None 时按 preset 决定 skill 注入
+    name=None,                      # Trace 显示名。None 让 utility_llm 基于 task 自动生成
+    enable_memory=True,             # 是否把 goal tree / collaborators 周期性注入(每 5 轮)
+    auto_execute_tools=True,        # False 则每个 tool call 需外部确认后才执行(人工审核场景)
+    enable_prompt_caching=True,     # Anthropic prompt caching,仅 Claude 模型有效
+
+    # --- Goal / 压缩 --------------------------------------------------------
+    goal_compression="on_overflow", # "none" | "on_complete" | "on_overflow";on_overflow 最省 token
+    side_branch_max_turns=5,        # 压缩/反思等侧分支的最大轮数
+
+    # --- Trace 续跑控制 -----------------------------------------------------
+    trace_id=None,                  # 被 CLI --continue_from 覆盖。None = 新 trace
+    parent_trace_id=None,           # 子 agent 调用时由上游注入,人工一般不填
+    parent_goal_id=None,
+    after_sequence=None,            # 指定从哪条 message sequence 后续跑(回溯重跑)
+
+    # --- 研究流程 -----------------------------------------------------------
+    # True = 自动跑"知识检索 → 经验检索 → 调研 → 计划"前置流程;
+    # 如果 agent 本身就是 research 性质,关掉避免双重调研。
+    enable_research_flow=True,
+
+    # --- 知识管理(KnowledgeConfig,详见 knowledge.py:26)-------------------
+    knowledge=KnowledgeConfig(
+        # 压缩触发时的反思提取
+        enable_extraction=False,            # True 在压缩时反思并落知识库
+        reflect_prompt="",                  # 自定义 prompt,空则用默认 REFLECT_PROMPT
+        reflect_auto_commit=False,          # True 自动提交反思产物(风险:知识库污染)
+
+        # 运行完成后的复盘提取
+        enable_completion_extraction=False, # True 在 agent 结束时复盘
+        completion_reflect_prompt="",
+
+        # 知识注入(focus goal 时自动拉相关知识进 context)
+        enable_injection=False,
+
+        # 默认字段(保存/搜索时自动带上)
+        owner="sunlit.howard@gmail.com",    # 空则 fallback 到 git user.email 再到 agent:{agent_id}
+        default_tags={},                    # 例如 {"project": "xyz", "domain": "ai_agent"}
+        default_scopes=["org:cybertogether"],
+        default_search_types=None,          # 搜索时默认的 type 过滤,如 ["strategy"]
+        default_search_owner="",            # 空 = 不按 owner 过滤
+    ),
+
+    # --- Memory(长期记忆,可选)-------------------------------------------
+    # None = 无长期记忆。若要启用:
+    #   from agent.core.memory import MemoryConfig
+    #   memory=MemoryConfig(...)  # 详见 agent/docs/memory.md
+    memory=None,
+
+    # --- 额外上下文(自定义元数据,透传给工具) -----------------------------
+    context={},
+)
+
+
+# =============================================================================
+# 基础设施路径
+# =============================================================================
+
+# --- Trace 存储 -------------------------------------------------------------
+# 锚在**调用方 CWD**,每个项目有独立的 trace 目录。
+# 用绝对路径是为了绕过 client.py:203-204 的 "非绝对路径 → rebase 到 project_root" 逻辑。
+# 如果想所有项目共享一个 trace 目录,把它改成任意绝对路径即可(如 Path.home() / ".agent_trace")。
+_CALLER_CWD = Path(os.getcwd()).resolve()
+_TRACE_DIR = _CALLER_CWD / ".cache" / "trace"
+# FileSystemTraceStore 只做 mkdir(exist_ok=True) 不带 parents,这里预先兜底创建父目录。
+_TRACE_DIR.mkdir(parents=True, exist_ok=True)
+TRACE_STORE_PATH = str(_TRACE_DIR)
+
+
+# --- Skills 目录 ------------------------------------------------------------
+# agent 会从这里加载 @skill 声明的技能定义。默认指向本 skill 目录本身,
+# 空着也没关系(SDK 容错);有自定义 skill 时把路径改到放 skill 的目录。
+SKILLS_DIR = str(Path(__file__).resolve().parent)
+
+
+# =============================================================================
+# 日志 / 调试
+# =============================================================================
+
+DEBUG = False                   # True 会打开更多调试输出(SDK 自己读这个标志)
+LOG_LEVEL = "INFO"              # "DEBUG" / "INFO" / "WARNING" / "ERROR"
+LOG_FILE = None                 # 设置为文件路径可同时输出到文件,None = 仅 stderr
+
+
+# =============================================================================
+# 浏览器配置(仅当启用 browser 工具组时生效)
+# =============================================================================
+# 使用场景:agent 需要访问网页(搜索、抓取、登录等)。不用时这段可忽略。
+
+BROWSER_TYPE = "local"          # "local"(本地 Chrome)/ "cloud"(云端)/ "container"(容器内账户)
+HEADLESS = False                # True 无头(CI / 服务器);False 有界面(本地调试观察)

+ 57 - 0
skills4claude/agent/invoke.py

@@ -0,0 +1,57 @@
+#!/usr/bin/env python
+"""
+薄 CLI 脚本:透传命令行参数到 cyber-agent 的 invoke_agent() SDK。
+远端 / 本地由 agent_type 前缀决定,同步返回 JSON 到 stdout。
+"""
+
+import argparse
+import asyncio
+import json
+import sys
+from pathlib import Path
+
+from agent import invoke_agent
+
+
+# skill 目录本身包含共享 config.py,作为未显式传 --project_root 时的默认锚点。
+_SKILL_DIR = str(Path(__file__).resolve().parent)
+
+
+def main() -> int:
+    parser = argparse.ArgumentParser(description="调用 Agent(远端或本地)")
+    parser.add_argument("--agent_type", required=True,
+                        help="Agent 类型。'remote_' 前缀走 KnowHub 服务器;否则本地执行")
+    parser.add_argument("--task", required=True, help="任务描述")
+    parser.add_argument("--skills", default=None,
+                        help="逗号分隔的 skill 列表(如 ask_strategy,upload_strategy)")
+    parser.add_argument("--continue_from", default=None,
+                        help="已有 sub_trace_id,传入则续跑")
+    parser.add_argument("--messages", default=None,
+                        help="预置消息(JSON 数组字符串,OpenAI 格式)")
+    parser.add_argument("--project_root", default=None,
+                        help="本地 agent 可选——项目目录(含 config.py)。"
+                             "不传则使用 skill 目录自带的共享 config.py")
+    args = parser.parse_args()
+
+    skills = [s.strip() for s in args.skills.split(",") if s.strip()] if args.skills else None
+    messages = json.loads(args.messages) if args.messages else None
+
+    # 本地 agent 且未显式指定项目 → 回落到 skill 自带的 config.py。
+    # 远端 agent(remote_ 前缀)无视 project_root,不影响。
+    project_root = args.project_root or _SKILL_DIR
+
+    result = asyncio.run(invoke_agent(
+        agent_type=args.agent_type,
+        task=args.task,
+        skills=skills,
+        continue_from=args.continue_from,
+        messages=messages,
+        project_root=project_root,
+    ))
+
+    print(json.dumps(result, ensure_ascii=False, indent=2))
+    return 0 if result.get("status") == "completed" else 1
+
+
+if __name__ == "__main__":
+    sys.exit(main())

+ 59 - 0
skills4claude/content-search/SKILL.md

@@ -0,0 +1,59 @@
+---
+name: content-search
+description: 通过 API 快速搜索指定内容平台(可选择小红书/B站/知乎/GitHub/YouTube/X 等)的帖子和视频,返回结构化数据。无需打开浏览器、无需处理登录/反爬问题,但有时不稳定。需要深度交互或访问API不支持的平台或功能时,请改用浏览器工具。
+---
+
+# Content Search
+
+跨 11 个平台搜索内容,支持 小红书/公众号/视频号/GitHub/头条/抖音/B站/知乎/微博/YouTube/X。
+
+## 前置
+
+需要 `cyber-agent` 包已 editable install(`pip install -e /path/to/Agent`)。
+本 skill 通过 `-m` 形式调用模块,从任意 CWD 都能跑。
+
+## 用法
+
+```bash
+# 查看所有平台
+python -m agent.tools.builtin.content.tools platforms
+
+# 查看指定平台的参数(支持模糊匹配:ID/中文名/别名)
+python -m agent.tools.builtin.content.tools platforms --platform=小红书
+
+# 搜索
+python -m agent.tools.builtin.content.tools search --platform=xhs --keyword=胶片摄影
+python -m agent.tools.builtin.content.tools search --platform=youtube --keyword=Claude
+
+# 查看详情(index 来自搜索结果)
+python -m agent.tools.builtin.content.tools detail --platform=xhs --index=3
+
+# 搜索建议词(仅 xhs/toutiao/douyin/bili/zhihu)
+python -m agent.tools.builtin.content.tools suggest --platform=xhs --keyword=摄影
+```
+
+## 平台专属参数
+
+通过 `--extras` 传 JSON,如小红书筛选:
+
+```bash
+python -m agent.tools.builtin.content.tools search \
+  --platform=xhs --keyword=摄影 --extras='{"sort_type":"最新发布","publish_time":"近7天"}'
+```
+
+不确定参数时先 `platforms --platform=<name>` 查看。
+
+## trace_id
+
+同一 session 内 search → detail 需共享 trace_id(detail 从磁盘缓存取数据):
+
+```bash
+export TRACE_ID=my-session
+python -m agent.tools.builtin.content.tools search --platform=github --keyword=agent
+python -m agent.tools.builtin.content.tools detail --platform=github --index=1
+```
+
+## 注意
+
+- 磁盘缓存(用于 search → detail 复用)默认落在运行时的临时目录,由 cyber-agent 内部管理
+- 为什么不像 toolhub 那样把脚本复制过来:content-search 是 8 个文件的子包(tools.py + registry.py + cache.py + 3 个 platform 模块 + 图片工具),复制出去维护成本高,所以保留 `-m` 形式依赖 editable install

+ 135 - 0
skills4claude/install.sh

@@ -0,0 +1,135 @@
+#!/usr/bin/env bash
+#
+# 把 Agent 仓库的 skills/ 同步到 ~/.claude/skills/
+#
+# 默认用 **symlink**:源头在 repo,edit-in-repo 立即生效,适合本机开发。
+# 跨机器部署/发布用 --copy:把文件 rsync 过去,脱离 repo。
+#
+# 默认行为:
+#   - 目标不存在 → 创建
+#   - 目标已是指向本 repo 的 symlink → 跳过(no-op)
+#   - 目标已存在(其他 symlink / 目录 / 文件)→ **拒绝安装并报错**,让你自己决定如何处理
+#     - 用 --force 才会强制覆盖(先 rm 再装)
+#
+# 不自动备份的原因:skill 已进版本控制,旧内容通常有 git 历史;
+# 盲目 backup 会污染 ~/.claude/skills/(Claude Code 会把 .bak.* 当 skill 索引)。
+#
+# 用法:
+#   bash install.sh                  # symlink 模式(默认,冲突即失败)
+#   bash install.sh --copy           # copy 模式
+#   bash install.sh --force          # 冲突时强制覆盖
+#   bash install.sh --target DIR     # 改安装目录(默认 ~/.claude/skills)
+#   bash install.sh --dry-run        # 只打印会做什么,不动文件
+#   bash install.sh --skills a,b     # 只装指定的(默认全装)
+#
+set -euo pipefail
+
+REPO_SKILLS_DIR="$(cd "$(dirname "$0")" && pwd)"
+TARGET_DIR="${HOME}/.claude/skills"
+MODE="symlink"
+DRY_RUN=0
+FORCE=0
+ONLY_SKILLS=""
+
+while [[ $# -gt 0 ]]; do
+  case "$1" in
+    --copy)     MODE="copy"; shift ;;
+    --symlink)  MODE="symlink"; shift ;;
+    --target)   TARGET_DIR="$2"; shift 2 ;;
+    --dry-run)  DRY_RUN=1; shift ;;
+    --force)    FORCE=1; shift ;;
+    --skills)   ONLY_SKILLS="$2"; shift 2 ;;
+    -h|--help)
+      grep '^#' "$0" | sed 's/^# \{0,1\}//' | head -30
+      exit 0 ;;
+    *) echo "unknown arg: $1" >&2; exit 1 ;;
+  esac
+done
+
+run() {
+  if [[ $DRY_RUN -eq 1 ]]; then
+    echo "[dry-run] $*"
+  else
+    eval "$@"
+  fi
+}
+
+# 收集要安装的 skill 列表
+if [[ -n "$ONLY_SKILLS" ]]; then
+  IFS=',' read -r -a skills <<< "$ONLY_SKILLS"
+else
+  skills=()
+  for d in "$REPO_SKILLS_DIR"/*/; do
+    [[ -d "$d" ]] || continue
+    name="$(basename "$d")"
+    [[ "$name" == "__pycache__" ]] && continue
+    skills+=("$name")
+  done
+fi
+
+echo "mode   : $MODE"
+echo "source : $REPO_SKILLS_DIR"
+echo "target : $TARGET_DIR"
+echo "force  : $FORCE"
+echo "skills : ${skills[*]}"
+echo "---"
+
+run "mkdir -p '$TARGET_DIR'"
+
+conflict_count=0
+for name in "${skills[@]}"; do
+  src="$REPO_SKILLS_DIR/$name"
+  dst="$TARGET_DIR/$name"
+
+  if [[ ! -d "$src" ]]; then
+    echo "⚠ skip (not found in repo): $name"
+    continue
+  fi
+
+  # 已经是指向本 repo 的 symlink → 跳过
+  if [[ -L "$dst" ]]; then
+    current="$(readlink "$dst")"
+    if [[ "$current" == "$src" ]]; then
+      echo "= $name (already linked, skip)"
+      continue
+    fi
+  fi
+
+  # 目标存在但不是我们想要的状态 → 要么 --force 覆盖,要么报错
+  if [[ -e "$dst" || -L "$dst" ]]; then
+    if [[ $FORCE -eq 0 ]]; then
+      echo "✗ $name: target exists → $dst"
+      echo "    (现状: $( [[ -L "$dst" ]] && echo "symlink → $(readlink "$dst")" || echo "directory/file" ))"
+      echo "    用 --force 覆盖;或手动处理后重跑。"
+      conflict_count=$((conflict_count + 1))
+      continue
+    fi
+    echo "↺ $name (forced: removing existing)"
+    run "rm -rf '$dst'"
+  fi
+
+  case "$MODE" in
+    symlink)
+      run "ln -s '$src' '$dst'"
+      echo "✓ $name (symlinked)"
+      ;;
+    copy)
+      run "cp -R '$src' '$dst'"
+      run "rm -rf '$dst/__pycache__'"
+      echo "✓ $name (copied)"
+      ;;
+  esac
+done
+
+echo ""
+if [[ $conflict_count -gt 0 ]]; then
+  echo "⚠ $conflict_count 个 skill 因冲突未安装。用 --force 覆盖或先手动处理。"
+  exit 2
+fi
+
+echo "安装完成。"
+if [[ "$MODE" == "symlink" ]]; then
+  echo "symlink 模式:在 $REPO_SKILLS_DIR 下改文件,$TARGET_DIR 自动跟随。"
+else
+  echo "copy 模式:repo 改动后需要重跑本脚本才会同步。"
+fi

+ 83 - 0
skills4claude/knowhub/SKILL.md

@@ -0,0 +1,83 @@
+---
+name: knowhub
+description: 查询和上传知识到 KnowHub 知识库。当用户需要检索已有调研成果、工具知识,或上传新的调研结果时使用。
+---
+
+# KnowHub 知识库
+
+自包含单脚本,三种模式覆盖全部典型用法:
+
+| 模式 | 做什么 | 何时用 |
+|------|-------|--------|
+| `ask`    | 深度回顾 —— 调远端 Librarian Agent 做规划+检索+整合 | 需要一个有引用的自然语言答案 |
+| `search` | 快速检索 —— 语义搜索 + LLM 精排 | 只想拿结构化条目列表,快 |
+| `save`   | 保存知识 | 把调研结论/工具使用经验写库 |
+
+**自包含:** 本 skill 目录下 `knowhub.py` 是独立脚本,不依赖 `cyber-agent` 安装,只需 `pip install httpx`。
+
+## 环境变量
+
+| 变量 | 作用 | 默认 |
+|------|------|------|
+| `KNOWHUB_API`   | KnowHub 服务器地址 | `http://43.106.118.91:9999` |
+| `KNOWHUB_OWNER` | `save` 的默认所有者;`search` 留空=不过滤,传 `--owner=$KNOWHUB_OWNER` 限定自己的 | `sunlit.howard@gmail.com` |
+
+也可以在 skill 目录下放 `.env` 文件覆盖默认(仅认 `KEY=VALUE` 纯文本,不依赖 python-dotenv)。
+
+## 用法
+
+### ask —— 深度回顾
+
+```bash
+# 调 remote_librarian(默认,快,基于已入库知识)
+python /Users/sunlit/.claude/skills/knowhub/knowhub.py ask --query="ControlNet 相关的工具"
+
+# 调 remote_research(深,全网调研 + 入库,慢,分钟级)
+python /Users/sunlit/.claude/skills/knowhub/knowhub.py ask --query="..." --deep
+
+# 复用上下文(用上次返回的 sub_trace_id)
+python /Users/sunlit/.claude/skills/knowhub/knowhub.py ask \
+    --query="基于刚才的结果补充..." \
+    --continue_from=65298f18-7cc4-4bc0-9fb8-6f2dd048df31
+```
+
+返回 `{"mode","agent_type","sub_trace_id","status","summary","stats","error"}`,`summary` 字段是带引用的自然语言回答。
+
+### search —— 快速检索
+
+```bash
+python /Users/sunlit/.claude/skills/knowhub/knowhub.py search \
+    --query="图片批量生成" \
+    --top_k=5 \
+    --min_score=3 \
+    --types=strategy,tool
+```
+
+**按关系过滤**(只看某 capability/tool/requirement 关联的):
+
+```bash
+python /Users/sunlit/.claude/skills/knowhub/knowhub.py search \
+    --query="..." --capability_id=CAP-008
+```
+
+返回 `{"query","count","results":[...]}`,`results` 每项含 `id / task / content / eval.score / types`。
+
+### save —— 保存知识
+
+```bash
+python /Users/sunlit/.claude/skills/knowhub/knowhub.py save \
+    --task="在用 flux 生成海报图时, 怎么让文字不错乱" \
+    --content="把文字用 [] 显式标出, 并在 prompt 末尾加 'clean typography, legible text'" \
+    --types=strategy \
+    --score=4 \
+    --source_name="flux 实测 2026-04" \
+    --source_urls=https://example.com/post-1
+```
+
+默认 `owner = $KNOWHUB_OWNER`,`scopes=["org:cybertogether"]`。需要覆盖用 `--owner=...` / `--scopes=...`。
+
+## 注意
+
+- `ask` 是同步阻塞的,timeout 600s;`search` 和 `save` 都在 60s / 30s 内返回
+- 所有命令返回标准 JSON,失败时 `error` 字段有值且退出码 1
+- `--tags` 接 JSON 字符串(如 `'{"project":"xyz"}'`);其他逗号分隔的参数接 CSV

+ 366 - 0
skills4claude/knowhub/knowhub.py

@@ -0,0 +1,366 @@
+#!/usr/bin/env python
+"""
+KnowHub 自包含 CLI
+===================
+
+三种查询/写入 KnowHub 的方式,都是直接 HTTP 调用,不依赖 cyber-agent 包。
+
+1. `ask`    —— 深度回顾:POST /api/agent,agent_type=remote_librarian(默认)或 remote_research
+                远端 Librarian Agent 会规划、检索、整合 → 带引用的自然语言回答
+2. `search` —— 快速检索:GET /api/knowledge/search
+                语义搜索 + LLM 精排,返回结构化知识条目
+3. `save`   —— 保存知识:POST /api/knowledge
+                把单条知识写库(异步校验入库)
+
+## 用法
+
+    python knowhub.py ask    --query="..."
+    python knowhub.py ask    --query="..." --deep         # 走 remote_research
+    python knowhub.py ask    --query="..." --continue_from=SUB_TRACE_ID
+
+    python knowhub.py search --query="..." [--top_k=5] [--min_score=3]
+                              [--types=strategy,tool] [--owner=...]
+                              [--capability_id=CAP-001]
+                              [--tool_id=...] [--requirement_id=...]
+
+    python knowhub.py save   --task="..." --content="..." --types=strategy
+                              [--score=4] [--source_name=...] [--source_urls=u1,u2]
+                              [--tags='{"project":"xyz"}']
+                              [--capability_ids=CAP-001,CAP-002]
+
+## 环境变量(可选)
+
+    KNOWHUB_API   KnowHub 服务器地址                 默认 http://43.106.118.91:9999
+    KNOWHUB_OWNER 默认所有者(save 和 search 均用) 默认 sunlit.howard@gmail.com
+
+.env 文件:在本 skill 目录下放 `.env`,本脚本会自动读取(仅 2 个变量,纯文本解析)。
+
+## 返回
+
+stdout 输出 JSON:
+- ask:    {"mode":"remote", "agent_type":..., "sub_trace_id":..., "status":..., "summary":..., "stats":..., "error":?}
+- search: {"query":..., "count":N, "results":[...]}
+- save:   {"knowledge_id":..., "status":"..."}
+
+退出码:成功 0,失败 1。
+"""
+
+import argparse
+import asyncio
+import json
+import os
+import sys
+from pathlib import Path
+from typing import Any, Dict, List, Optional
+
+import httpx
+
+
+# ── 默认配置 ────────────────────────────────────────
+
+DEFAULT_KNOWHUB_API = "http://43.106.118.91:9999"
+DEFAULT_OWNER = "sunlit.howard@gmail.com"
+DEFAULT_SCOPES = ["org:cybertogether"]
+
+ASK_TIMEOUT = 600.0     # Librarian agent 规划 + 多轮检索可能需要几分钟
+SEARCH_TIMEOUT = 60.0
+SAVE_TIMEOUT = 30.0
+
+
+# ── .env 读取(超简版,仅认 KEY=VALUE 格式,不依赖 python-dotenv) ──
+
+def _load_local_env() -> None:
+    """从本脚本同目录的 .env 加载 KNOWHUB_API / KNOWHUB_OWNER。现有 env 优先。"""
+    env_file = Path(__file__).resolve().parent / ".env"
+    if not env_file.exists():
+        return
+    for line in env_file.read_text(encoding="utf-8").splitlines():
+        line = line.strip()
+        if not line or line.startswith("#") or "=" not in line:
+            continue
+        key, val = line.split("=", 1)
+        key, val = key.strip(), val.strip().strip('"').strip("'")
+        if key and key not in os.environ:
+            os.environ[key] = val
+
+
+_load_local_env()
+
+KNOWHUB_API = os.getenv("KNOWHUB_API", DEFAULT_KNOWHUB_API).rstrip("/")
+KNOWHUB_OWNER = os.getenv("KNOWHUB_OWNER", DEFAULT_OWNER)
+
+
+# ── 模式实现 ────────────────────────────────────────
+
+async def ask(
+    query: str,
+    deep: bool = False,
+    continue_from: Optional[str] = None,
+    skills: Optional[List[str]] = None,
+) -> Dict[str, Any]:
+    """
+    深度回顾:调用远端 Librarian / Research Agent。
+
+    deep=False → remote_librarian(整合已有知识库的回答)
+    deep=True  → remote_research(全网调研 + 入库,较慢)
+    """
+    agent_type = "remote_research" if deep else "remote_librarian"
+    payload = {
+        "agent_type": agent_type,
+        "task": query,
+        "messages": None,
+        "continue_from": continue_from,
+        "skills": skills,
+    }
+    try:
+        async with httpx.AsyncClient(timeout=ASK_TIMEOUT) as client:
+            r = await client.post(f"{KNOWHUB_API}/api/agent", json=payload)
+            r.raise_for_status()
+            result = r.json()
+        return {
+            "mode": "remote",
+            "agent_type": agent_type,
+            "sub_trace_id": result.get("sub_trace_id"),
+            "status": result.get("status", "completed"),
+            "summary": result.get("summary", ""),
+            "stats": result.get("stats", {}),
+            "error": result.get("error"),
+        }
+    except httpx.HTTPStatusError as e:
+        return {
+            "mode": "remote", "agent_type": agent_type, "status": "failed",
+            "error": f"HTTP {e.response.status_code}: {e.response.text[:200]}",
+        }
+    except Exception as e:
+        return {
+            "mode": "remote", "agent_type": agent_type, "status": "failed",
+            "error": f"{type(e).__name__}: {e}",
+        }
+
+
+async def search(
+    query: str,
+    top_k: int = 5,
+    min_score: int = 3,
+    types: Optional[List[str]] = None,
+    owner: Optional[str] = None,
+    requirement_id: Optional[str] = None,
+    capability_id: Optional[str] = None,
+    tool_id: Optional[str] = None,
+) -> Dict[str, Any]:
+    """快速检索:调 /api/knowledge/search。"""
+    params: Dict[str, Any] = {"q": query, "top_k": top_k, "min_score": min_score}
+    if types:
+        params["types"] = ",".join(types)
+    if owner:  # 显式覆盖才用;None 时不过滤(全库搜)
+        params["owner"] = owner
+    if requirement_id:
+        params["requirement_id"] = requirement_id
+    if capability_id:
+        params["capability_id"] = capability_id
+    if tool_id:
+        params["tool_id"] = tool_id
+
+    try:
+        async with httpx.AsyncClient(timeout=SEARCH_TIMEOUT) as client:
+            r = await client.get(f"{KNOWHUB_API}/api/knowledge/search", params=params)
+            r.raise_for_status()
+            data = r.json()
+        return {
+            "query": query,
+            "count": data.get("count", 0),
+            "results": data.get("results", []),
+        }
+    except httpx.HTTPStatusError as e:
+        return {"query": query, "count": 0, "results": [],
+                "error": f"HTTP {e.response.status_code}: {e.response.text[:200]}"}
+    except Exception as e:
+        return {"query": query, "count": 0, "results": [],
+                "error": f"{type(e).__name__}: {e}"}
+
+
+async def save(
+    task: str,
+    content: str,
+    types: List[str],
+    tags: Optional[Dict[str, str]] = None,
+    scopes: Optional[List[str]] = None,
+    owner: Optional[str] = None,
+    resource_ids: Optional[List[str]] = None,
+    source_name: str = "",
+    source_category: str = "exp",
+    source_urls: Optional[List[str]] = None,
+    agent_id: str = "knowhub_cli",
+    submitted_by: str = "",
+    score: int = 3,
+    message_id: str = "",
+    capability_ids: Optional[List[str]] = None,
+    tool_ids: Optional[List[str]] = None,
+) -> Dict[str, Any]:
+    """保存知识:POST /api/knowledge。"""
+    payload = {
+        "message_id": message_id,
+        "types": types,
+        "task": task,
+        "tags": tags or {},
+        "scopes": scopes or DEFAULT_SCOPES,
+        "owner": owner or KNOWHUB_OWNER,
+        "content": content,
+        "resource_ids": resource_ids or [],
+        "source": {
+            "name": source_name,
+            "category": source_category,
+            "urls": source_urls or [],
+            "agent_id": agent_id,
+            "submitted_by": submitted_by or KNOWHUB_OWNER,
+        },
+        "eval": {"score": score, "helpful": 1, "harmful": 0, "confidence": 0.5},
+        "capability_ids": capability_ids or [],
+        "tool_ids": tool_ids or [],
+    }
+    try:
+        async with httpx.AsyncClient(timeout=SAVE_TIMEOUT) as client:
+            r = await client.post(f"{KNOWHUB_API}/api/knowledge", json=payload)
+            r.raise_for_status()
+            data = r.json()
+        return {
+            "knowledge_id": data.get("knowledge_id"),
+            "status": data.get("status", "submitted"),
+            "owner": payload["owner"],
+        }
+    except httpx.HTTPStatusError as e:
+        return {"knowledge_id": None, "status": "failed",
+                "error": f"HTTP {e.response.status_code}: {e.response.text[:200]}"}
+    except Exception as e:
+        return {"knowledge_id": None, "status": "failed",
+                "error": f"{type(e).__name__}: {e}"}
+
+
+# ── 参数工具 ────────────────────────────────────────
+
+def _split_csv(val: Optional[str]) -> Optional[List[str]]:
+    """'a,b,c' → ['a','b','c'];None → None;空串 → None。"""
+    if not val:
+        return None
+    parts = [x.strip() for x in val.split(",") if x.strip()]
+    return parts or None
+
+
+def _parse_json_maybe(val: Optional[str]) -> Optional[Any]:
+    """把字符串按 JSON 解析;解析失败则原样返回字符串。"""
+    if val is None:
+        return None
+    try:
+        return json.loads(val)
+    except (json.JSONDecodeError, ValueError):
+        return val
+
+
+# ── CLI ───────────────────────────────────────────
+
+def _build_parser() -> argparse.ArgumentParser:
+    parser = argparse.ArgumentParser(
+        description="KnowHub CLI - ask / search / save 知识库",
+        formatter_class=argparse.RawDescriptionHelpFormatter,
+        epilog=f"默认 API: {KNOWHUB_API}   默认 owner: {KNOWHUB_OWNER}",
+    )
+    sub = parser.add_subparsers(dest="cmd", required=True, metavar="{ask,search,save}")
+
+    # ask
+    p_ask = sub.add_parser("ask", help="深度回顾(远端 Librarian Agent)")
+    p_ask.add_argument("--query", required=True)
+    p_ask.add_argument("--deep", action="store_true", help="改走 remote_research,全网调研 + 入库")
+    p_ask.add_argument("--continue_from", help="已有 sub_trace_id,传入则复用 Librarian 上下文")
+    p_ask.add_argument("--skills", help="逗号分隔的 skill 名单(可选,由服务器白名单过滤)")
+
+    # search
+    p_s = sub.add_parser("search", help="快速检索(语义搜索 + 精排)")
+    p_s.add_argument("--query", required=True)
+    p_s.add_argument("--top_k", type=int, default=5)
+    p_s.add_argument("--min_score", type=int, default=3)
+    p_s.add_argument("--types", help="逗号分隔(user_profile/strategy/tool/usecase/definition/plan)")
+    p_s.add_argument("--owner", help=f"覆盖默认 owner(默认不过滤,用 --owner={KNOWHUB_OWNER} 限定自己的)")
+    p_s.add_argument("--requirement_id")
+    p_s.add_argument("--capability_id")
+    p_s.add_argument("--tool_id")
+
+    # save
+    p_sv = sub.add_parser("save", help="保存知识到 KnowHub")
+    p_sv.add_argument("--task", required=True, help="任务描述:在什么情景下 + 要完成什么目标")
+    p_sv.add_argument("--content", required=True, help="知识的核心内容")
+    p_sv.add_argument("--types", required=True, help="逗号分隔的类型")
+    p_sv.add_argument("--tags", help="JSON 字符串,如 '{\"project\":\"xyz\"}'")
+    p_sv.add_argument("--scopes", help=f"逗号分隔(默认 {','.join(DEFAULT_SCOPES)})")
+    p_sv.add_argument("--owner", help=f"覆盖默认 owner(默认 {KNOWHUB_OWNER})")
+    p_sv.add_argument("--score", type=int, default=3, help="1-5,默认 3")
+    p_sv.add_argument("--source_name", default="")
+    p_sv.add_argument("--source_category", default="exp", help="paper/exp/skill/book")
+    p_sv.add_argument("--source_urls", help="逗号分隔 URL 列表")
+    p_sv.add_argument("--agent_id", default="knowhub_cli")
+    p_sv.add_argument("--submitted_by", default="")
+    p_sv.add_argument("--capability_ids", help="逗号分隔的能力 ID")
+    p_sv.add_argument("--tool_ids", help="逗号分隔的工具 ID")
+    p_sv.add_argument("--resource_ids", help="逗号分隔的资源 ID")
+    p_sv.add_argument("--message_id", default="")
+
+    return parser
+
+
+async def _dispatch(args) -> Dict[str, Any]:
+    if args.cmd == "ask":
+        return await ask(
+            query=args.query,
+            deep=args.deep,
+            continue_from=args.continue_from,
+            skills=_split_csv(args.skills),
+        )
+
+    if args.cmd == "search":
+        return await search(
+            query=args.query,
+            top_k=args.top_k,
+            min_score=args.min_score,
+            types=_split_csv(args.types),
+            owner=args.owner,
+            requirement_id=args.requirement_id,
+            capability_id=args.capability_id,
+            tool_id=args.tool_id,
+        )
+
+    if args.cmd == "save":
+        tags_val = _parse_json_maybe(args.tags) if args.tags else None
+        return await save(
+            task=args.task,
+            content=args.content,
+            types=_split_csv(args.types) or [],
+            tags=tags_val if isinstance(tags_val, dict) else None,
+            scopes=_split_csv(args.scopes),
+            owner=args.owner,
+            resource_ids=_split_csv(args.resource_ids),
+            source_name=args.source_name,
+            source_category=args.source_category,
+            source_urls=_split_csv(args.source_urls),
+            agent_id=args.agent_id,
+            submitted_by=args.submitted_by,
+            score=args.score,
+            message_id=args.message_id,
+            capability_ids=_split_csv(args.capability_ids),
+            tool_ids=_split_csv(args.tool_ids),
+        )
+
+    raise ValueError(f"未知命令: {args.cmd}")
+
+
+def main() -> int:
+    args = _build_parser().parse_args()
+    result = asyncio.run(_dispatch(args))
+    print(json.dumps(result, ensure_ascii=False, indent=2))
+
+    # 退出码:任何 status=failed 或 error 字段非空 → 1
+    if result.get("status") == "failed" or result.get("error"):
+        return 1
+    return 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())

+ 86 - 0
skills4claude/toolhub/SKILL.md

@@ -0,0 +1,86 @@
+---
+name: toolhub
+description: 搜索和调用 ToolHub 远程 AI 工具(图片生成、图片拼接等)。当用户需要生成图片、调用远程 AI 工具、或查询可用工具时使用。
+---
+
+# ToolHub 远程工具库
+
+ToolHub 托管了各种 AI 工具(图片生成、拼接、风格迁移等),通过 CLI 调用 `http://43.106.118.91:8001`。
+
+**自包含:** 本 skill 目录下 `toolhub.py` 是独立脚本,不依赖 `cyber-agent` 安装,只需 `pip install httpx`。
+如需 OSS 上传能力(见下表),额外装 `cyber_sdk`。
+
+## 用法
+
+```bash
+# 检查服务状态
+python /Users/sunlit/.claude/skills/toolhub/toolhub.py health
+
+# 搜索可用工具(不填 keyword 返回全量)
+python /Users/sunlit/.claude/skills/toolhub/toolhub.py search --keyword=image
+
+# 调用工具
+python /Users/sunlit/.claude/skills/toolhub/toolhub.py call \
+  --tool_id=flux_gen --params='{"prompt":"a cat sitting on the moon"}'
+```
+
+## 图片参数
+
+### 输入(给 ToolHub)
+
+`params` 中的 `image` / `image_url` / `mask_image` / `pose_image` / `reference_image`(单值)和
+`images` / `image_urls` / `reference_images`(数组)都可以传**本地路径 *或* URL**:
+
+- 传 URL(`https://...` / `data:...`)→ 原样透传给 ToolHub 服务
+- 传本地路径 → `_maybe_upload_local` 先把文件上传 OSS,拿到 CDN URL 再传给服务
+
+```bash
+# 示例:传入本地图片(自动 OSS 上传)
+python /Users/sunlit/.claude/skills/toolhub/toolhub.py call \
+  --tool_id=image_stitcher \
+  --params='{"images":["/path/to/a.png","/path/to/b.png"],"direction":"horizontal"}'
+```
+
+### 输出(ToolHub 返回)
+
+每张产出图会同时生成三种形态:
+
+| 形态 | 位置 / 字段 | 用途 |
+|------|-----------|------|
+| 本地文件 | `<cwd>/.cache/toolhub_outputs/{trace_id}/` + `saved_files` | 用户归档、后续工具链按路径引用 |
+| CDN URL | 输出 JSON 里的 `cdn_urls` | **上下文压缩后 LLM 可回看** / 工具 A→B 直接用 URL 串接 |
+| base64(不出现在 CLI JSON) | `ToolResult.images` 给 agent runtime | 当前轮多模态 LLM 直接"看到"图做推理 |
+
+输出目录不存在会自动创建。
+
+## cyber_sdk 依赖矩阵
+
+| 场景 | 需要 `cyber_sdk`? | 未装会发生什么 |
+|------|------------------|---------------|
+| 输入只传 URL | ❌ 不需要 | 正常 |
+| 输入传本地路径 | ✅ **必需** | `_upload_to_oss` 失败返回 None → 本地路径原样传给服务 → ToolHub 服务无法 GET → **工具调用失败** |
+| 输出获取 `cdn_urls` | ⚠️ 可选 | 静默跳过 OSS 上传,`cdn_urls` 为空;本地 `saved_files` 和多模态 base64 都不受影响 |
+| 只调 `health` / `search` | ❌ 不需要 | 正常 |
+
+简言之:**"我要传本地图进去"就必须装 `cyber_sdk`;只消费输出的场景可以不装**。
+
+## trace_id 会话管理
+
+trace_id 控制图片输出子目录,优先级:
+
+1. `--trace_id=xxx`(CLI 参数)
+2. `TRACE_ID` 环境变量
+3. 自动生成(`cli-xxxxxxxx`)
+
+```bash
+export TRACE_ID=my-session-001
+python /Users/sunlit/.claude/skills/toolhub/toolhub.py call --tool_id=flux_gen --params='{"prompt":"..."}'
+# 图片保存到 <cwd>/.cache/toolhub_outputs/my-session-00/
+```
+
+## 注意
+
+- 调用前先用 `search` 查询目标工具的 tool_id 和参数定义
+- 图片生成类工具可能耗时数分钟(GPU 冷启动)
+- 部分工具有生命周期分组(如 RunComfy),需按 search 返回的 `usage_order` 顺序调用
+- 输出为 JSON:`{"trace_id": "...", "output": "...", "error": "...", "metadata": {...}}`

+ 763 - 0
skills4claude/toolhub/toolhub.py

@@ -0,0 +1,763 @@
+"""
+ToolHub - 远程工具库集成模块
+
+将 http://43.106.118.91:8001 的工具库 API 包装为 Agent 可调用的工具。
+提供三个工具:
+1. toolhub_health   - 健康检查
+2. toolhub_search   - 搜索/发现远程工具(GET /tools)
+3. toolhub_call     - 调用远程工具(POST /run_tool)
+
+图片参数统一使用本地文件路径:
+  - 输入:params 中的 image/image_url 等参数直接传本地路径,内部自动上传
+  - 输出:生成的图片自动保存到 outputs/ 目录,返回本地路径
+
+实际 API 端点(通过 /openapi.json 确认):
+  GET  /health      → 健康检查
+  GET  /tools       → 列出所有工具(含分组、参数 schema)
+  POST /run_tool    → 调用工具 {"tool_id": str, "params": dict}
+  POST /chat        → 对话接口(不在此封装)
+
+CLI 用法:
+  python -m agent.tools.builtin.toolhub health
+  python -m agent.tools.builtin.toolhub search --keyword=image
+  python -m agent.tools.builtin.toolhub call --tool_id=flux_gen --params='{"prompt":"a cat"}'
+"""
+
+import base64
+import contextvars
+import json
+import logging
+import mimetypes
+import os
+import time
+from pathlib import Path
+from typing import Any, Dict, List, Optional
+
+import httpx
+
+# ── agent.tools 解耦 shim ────────────────────────────
+# 原脚本依赖 `from agent.tools import tool, ToolResult`。为了让本 skill 不再
+# 强依赖 cyber-agent editable install,这里 inline 两个等价替身:
+#   - `tool(...)` 装饰器:agent runtime 用来把函数注册进全局 registry。
+#     CLI 场景下直接调用函数、不经过 registry,所以这里是 **纯空转**,
+#     保留 kwargs 仅为了接住原来的 display/groups 等参数不报错。
+#   - `ToolResult`:agent runtime 的统一返回类型。这里复刻必要字段,
+#     只给 CLI 的 `__main__` 段消费(访问 .output / .error / .metadata)。
+from dataclasses import dataclass, field as _field
+from typing import Callable, TypeVar as _TypeVar
+
+_F = _TypeVar("_F", bound=Callable)
+
+
+def tool(**_kwargs) -> Callable[[_F], _F]:  # noqa: D401 — 签名与原装饰器保持兼容
+    """空转装饰器(CLI 模式)。原 agent runtime 里负责向 registry 注册,此处不需要。"""
+    def decorator(func: _F) -> _F:
+        return func
+    return decorator
+
+
+@dataclass
+class ToolResult:
+    """简化版 ToolResult。仅保留本文件实际用到的字段,行为与 agent.tools.models.ToolResult 兼容。"""
+    title: str
+    output: str
+    long_term_memory: Optional[str] = None
+    metadata: Dict[str, Any] = _field(default_factory=dict)
+    error: Optional[str] = None
+    attachments: List[str] = _field(default_factory=list)
+    images: List[Dict[str, Any]] = _field(default_factory=list)
+
+
+logger = logging.getLogger(__name__)
+
+# ── 配置 ─────────────────────────────────────────────
+
+TOOLHUB_BASE_URL = "http://43.106.118.91:8001"
+DEFAULT_TIMEOUT = 30.0
+CALL_TIMEOUT = 600.0   # 图像生成类工具耗时较长,云端机器启动可能需要数分钟
+
+# OSS 上传配置
+OSS_BUCKET_NAME = "aigc-admin"
+OSS_BUCKET_PATH = "toolhub_images"
+
+# 输出目录(锚在调用方 CWD 的 .cache/toolhub_outputs/,每个项目独立;目录不存在会自动创建)
+# 与 agent skill 的 trace 路径风格一致,方便 gitignore:`.cache/` 加一行搞定
+OUTPUT_BASE_DIR = Path(os.getcwd()) / ".cache" / "toolhub_outputs"
+
+# trace_id 上下文变量,由 runner 在执行工具前设置
+_trace_id_var: contextvars.ContextVar[str] = contextvars.ContextVar("toolhub_trace_id", default="")
+
+
+def set_trace_context(trace_id: str):
+    """由 runner 调用,设置当前 trace_id 供图片保存使用"""
+    _trace_id_var.set(trace_id)
+
+
+def _get_output_dir(tool_id: str) -> Path:
+    """获取图片输出目录:outputs/{trace_id}/,无 trace_id 时用时间戳"""
+    trace_id = _trace_id_var.get("")
+    if trace_id:
+        # trace_id 可能含 @ 等特殊字符,取前段作为目录名
+        safe_id = trace_id.split("@")[0][:12] if "@" in trace_id else trace_id[:12]
+        out_dir = OUTPUT_BASE_DIR / safe_id
+    else:
+        out_dir = OUTPUT_BASE_DIR / f"no_trace_{int(time.time())}"
+    out_dir.mkdir(parents=True, exist_ok=True)
+    return out_dir
+
+
+# ── 图片处理辅助 ─────────────────────────────────────
+
+async def _upload_to_oss(local_path: str) -> Optional[str]:
+    """上传本地文件到 OSS,返回 CDN URL"""
+    try:
+        from cyber_sdk.ali_oss import upload_localfile
+        import os
+        safe_path = os.path.abspath(local_path).replace("\\", "/")
+        result = await upload_localfile(
+            file_path=safe_path,
+            bucket_path=OSS_BUCKET_PATH,
+            bucket_name=OSS_BUCKET_NAME,
+        )
+        oss_key = result.get("oss_object_key")
+        if oss_key:
+            cdn_url = f"https://res.cybertogether.net/{oss_key}"
+            logger.info(f"[ToolHub] 图片已上传 OSS: {cdn_url}")
+            return cdn_url
+    except Exception as e:
+        logger.warning(f"[ToolHub] OSS 上传失败: {e}")
+    return None
+
+
+async def _process_images(raw_images: List[str], tool_id: str) -> tuple:
+    """
+    统一处理工具返回的图片列表。
+
+    对每张图片:下载(如需) → 保存本地 → 上传 OSS 拿 CDN URL → base64 供 LLM 查看
+
+    三种形态并存是为了覆盖不同消费者:
+      - saved_paths: 用户本地归档、后续工具链引用
+      - cdn_urls:    永久引用,**上下文压缩后 LLM 若要重新审视,可通过 URL 二次加载**;
+                     也让"A 工具输出 → B 工具输入"直接用 URL 串起来,不经过本地中转
+      - images_for_llm: 当前轮 LLM 多模态推理用(base64 直接嵌 payload)
+
+    Returns:
+        (images_for_llm, cdn_urls, saved_paths)
+    """
+    images_for_llm = []
+    cdn_urls = []
+    saved_paths = []
+
+    out_dir = _get_output_dir(tool_id)
+
+    for idx, img in enumerate(raw_images):
+        if not isinstance(img, str) or len(img) <= 100:
+            continue
+
+        img_bytes = None
+        media_type = "image/png"
+
+        if img.startswith(("http://", "https://")):
+            try:
+                async with httpx.AsyncClient(timeout=60, trust_env=False) as dl:
+                    img_resp = await dl.get(img)
+                    img_resp.raise_for_status()
+                    ct = img_resp.headers.get("content-type", "image/png").split(";")[0].strip()
+                    if not ct.startswith("image/"):
+                        ct = mimetypes.guess_type(img.split("?")[0])[0] or "image/png"
+                    media_type = ct
+                    img_bytes = img_resp.content
+            except Exception as e:
+                logger.warning(f"[ToolHub] 图片下载失败: {e}")
+                continue
+
+        elif img.startswith("data:"):
+            header, b64 = img.split(",", 1)
+            media_type = header.split(";")[0].replace("data:", "")
+            img_bytes = base64.b64decode(b64)
+
+        else:
+            # raw base64
+            img_bytes = base64.b64decode(img)
+
+        if not img_bytes:
+            continue
+
+        # 1. 保存本地(用时间戳区分多次调用)
+        ts = int(time.time() * 1000)
+        ext = {"image/png": ".png", "image/jpeg": ".jpg", "image/webp": ".webp"}.get(media_type, ".png")
+        save_path = out_dir / f"{tool_id}_{ts}_{idx}{ext}"
+        save_path.write_bytes(img_bytes)
+        saved_paths.append(str(save_path))
+
+        # 2. 上传 OSS 拿 CDN URL(best-effort —— cyber_sdk 未装则静默跳过)
+        # 为什么输出也 CDN:上下文压缩会丢 base64,后续如果需要 LLM 重看这张图,URL 是可复访的手段;
+        # 同时对称的 CDN 设计让"工具 A 输出 → 工具 B 输入"能直接用 URL 串接,不用本地中转
+        cdn_url = await _upload_to_oss(str(save_path))
+        if cdn_url:
+            cdn_urls.append(cdn_url)
+
+        # 3. base64 给当前轮 LLM 多模态查看
+        b64_data = base64.b64encode(img_bytes).decode()
+        images_for_llm.append({"type": "base64", "media_type": media_type, "data": b64_data})
+
+    return images_for_llm, cdn_urls, saved_paths
+
+
+_SINGLE_IMAGE_PARAMS = ("image", "image_url", "mask_image", "pose_image", "reference_image")
+_ARRAY_IMAGE_PARAMS = ("images", "image_urls", "reference_images")
+
+
+async def _maybe_upload_local(val: str) -> Optional[str]:
+    """如果 val 是存在的本地文件路径,上传 OSS 并返回 CDN URL;否则返回 None。"""
+    if not isinstance(val, str):
+        return None
+    if val.startswith(("http://", "https://", "data:")):
+        return None
+    try:
+        p = Path(val)
+        if p.exists() and p.is_file():
+            return await _upload_to_oss(str(p.resolve()))
+    except Exception as e:
+        logger.warning(f"[ToolHub] 本地路径处理失败 {val}: {e}")
+    return None
+
+
+async def _preprocess_params(params: Dict[str, Any]) -> Dict[str, Any]:
+    """
+    预处理工具参数:检测本地文件路径,自动上传到 OSS 并替换为 CDN URL。
+
+    支持的单值参数:image, image_url, mask_image, pose_image, reference_image
+    支持的数组参数:images, image_urls, reference_images
+
+    设计要点:远程工具服务的 cwd 和调用方不一样,相对路径在服务器上会找不到文件。
+    所以必须在客户端就把本地路径转成 CDN URL,不能期望服务器侧有 fallback。
+    """
+    if not params:
+        return params
+
+    processed = params.copy()
+
+    # 单值图片参数
+    for key in _SINGLE_IMAGE_PARAMS:
+        if key in processed and isinstance(processed[key], str):
+            val = processed[key]
+            if val.startswith(("http://", "https://", "data:")):
+                continue
+            cdn_url = await _maybe_upload_local(val)
+            if cdn_url:
+                processed[key] = cdn_url
+                logger.info(f"[ToolHub] {key} 本地路径已替换为 CDN: {cdn_url}")
+            elif not os.path.isfile(val):
+                # 既不是远程 URL 也不是已存在的本地文件,直接报错比让远程服务抛神秘的 base64 错误强
+                logger.warning(f"[ToolHub] {key}={val!r} 既不是 URL 也不是存在的本地文件")
+
+    # 数组型图片参数
+    for array_key in _ARRAY_IMAGE_PARAMS:
+        if array_key not in processed or not isinstance(processed[array_key], list):
+            continue
+        new_list = []
+        for idx, item in enumerate(processed[array_key]):
+            if not isinstance(item, str):
+                new_list.append(item)
+                continue
+            if item.startswith(("http://", "https://", "data:")):
+                new_list.append(item)
+                continue
+            cdn_url = await _maybe_upload_local(item)
+            if cdn_url:
+                new_list.append(cdn_url)
+                logger.info(f"[ToolHub] {array_key}[{idx}] 本地路径已替换为 CDN: {cdn_url}")
+            else:
+                new_list.append(item)
+                if not os.path.isfile(item):
+                    logger.warning(
+                        f"[ToolHub] {array_key}[{idx}]={item!r} 既不是 URL 也不是存在的本地文件"
+                    )
+        processed[array_key] = new_list
+
+    return processed
+
+
+# ── 工具实现 ──────────────────────────────────────────
+
+@tool(
+    display={
+        "zh": {"name": "ToolHub 健康检查", "params": {}},
+        "en": {"name": "ToolHub Health Check", "params": {}},
+    },
+    groups=["toolhub"],
+)
+async def toolhub_health() -> ToolResult:
+    """检查 ToolHub 远程工具库服务是否可用
+
+    检查 ToolHub 服务的健康状态,确认服务是否正常运行。
+    建议在调用其他 toolhub 工具之前先检查。
+
+    Returns:
+        ToolResult 包含服务健康状态信息
+    """
+    try:
+        async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT, trust_env=False) as client:
+            resp = await client.get(f"{TOOLHUB_BASE_URL}/health")
+            resp.raise_for_status()
+            data = resp.json()
+
+        return ToolResult(
+            title="ToolHub 健康检查",
+            output=json.dumps(data, ensure_ascii=False, indent=2),
+            long_term_memory=f"ToolHub service at {TOOLHUB_BASE_URL} is healthy.",
+        )
+    except httpx.ConnectError:
+        return ToolResult(
+            title="ToolHub 健康检查",
+            output="",
+            error=f"无法连接到 ToolHub 服务 {TOOLHUB_BASE_URL},请确认服务已启动。",
+        )
+    except Exception as e:
+        err_msg = f"{type(e).__name__}: {e}" if str(e) else type(e).__name__
+        return ToolResult(
+            title="ToolHub 健康检查",
+            output="",
+            error=err_msg,
+        )
+
+
+@tool(
+    display={
+        "zh": {"name": "搜索 ToolHub 工具", "params": {"keyword": "搜索关键词"}},
+        "en": {"name": "Search ToolHub", "params": {"keyword": "Search keyword"}},
+    },
+    groups=["toolhub"],
+)
+async def toolhub_search(keyword: Optional[str] = None) -> ToolResult:
+    """搜索 ToolHub 远程工具库中可用的工具
+
+    从 ToolHub 工具库中获取可用工具列表,返回每个工具的完整信息,包括:
+    tool_id、名称、分类、状态、参数列表(含类型、是否必填、默认值)、输出 schema、
+    分组信息(如 RunComfy 生命周期组)等。
+
+    调用 toolhub_call 之前,应先使用此工具了解目标工具的 tool_id 和所需参数。
+    不填 keyword 则返回所有工具。
+
+    Args:
+        keyword: 搜索关键词,用于过滤工具名称或描述(客户端过滤);为空则返回所有工具
+
+    Returns:
+        ToolResult 包含匹配的工具列表及其参数说明
+    """
+    try:
+        async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT, trust_env=False) as client:
+            resp = await client.get(f"{TOOLHUB_BASE_URL}/tools")
+            resp.raise_for_status()
+            data = resp.json()
+
+        tools = data.get("tools", [])
+        groups = data.get("groups", [])
+
+        # 客户端关键词过滤:三层匹配策略
+        # 1) 原始子串匹配(最严格,但会被分隔符切断:nanobanana vs nano_banana)
+        # 2) 归一化子串匹配(去掉 _ - 空格 .,解决分隔符问题)
+        # 3) 分词交集匹配(keyword 拆成 token,任意 token 命中即保留,解决多词查询)
+        if keyword:
+            def _normalize(s: str) -> str:
+                """去掉分隔符和空白,全小写"""
+                return "".join(c for c in s.lower() if c.isalnum())
+
+            def _tokenize(s: str) -> set:
+                """按分隔符拆成 token 集合"""
+                import re
+                return {t for t in re.split(r"[\s_\-.,/]+", s.lower()) if t}
+
+            kw_raw = keyword.lower()
+            kw_norm = _normalize(keyword)
+            kw_tokens = _tokenize(keyword)
+
+            def _matches(t: dict) -> bool:
+                fields = [
+                    t.get("name", ""),
+                    t.get("description", ""),
+                    t.get("tool_id", ""),
+                    t.get("category", ""),
+                ]
+                combined = " ".join(fields).lower()
+                # 原始子串
+                if kw_raw in combined:
+                    return True
+                # 归一化子串(容忍分隔符差异)
+                if kw_norm and kw_norm in _normalize(combined):
+                    return True
+                # token 交集(多词关键词的 OR 匹配)
+                if kw_tokens:
+                    field_tokens = _tokenize(combined)
+                    if kw_tokens & field_tokens:
+                        return True
+                return False
+
+            tools = [t for t in tools if _matches(t)]
+
+        total = len(tools)
+
+        # 构建给 LLM 的结构化摘要
+        summaries = []
+        for t in tools:
+            input_props = t.get("input_schema", {}).get("properties", {})
+            required_fields = t.get("input_schema", {}).get("required", [])
+            params_desc = []
+            for name, info in input_props.items():
+                req = "必填" if name in required_fields else "可选"
+                desc = info.get("description", "")
+                default_str = f", 默认={info['default']}" if info.get("default") is not None else ""
+                enum_str = f", 可选值={info['enum']}" if info.get("enum") else ""
+                params_desc.append(
+                    f"  - {name} ({info.get('type','any')}, {req}): {desc}{default_str}{enum_str}"
+                )
+
+            group_str = ""
+            if t.get("group_ids"):
+                group_str = f"\n  所属分组: {', '.join(t['group_ids'])}"
+
+            tool_block = (
+                f"[{t['tool_id']}] {t['name']}\n"
+                f"  状态: {t['state']} | 运行时: {t['backend_runtime']} | 分类: {t.get('category','')}"
+                f"{group_str}\n"
+                f"  描述: {t.get('description', '')}"
+            )
+            if params_desc:
+                tool_block += "\n  参数:\n" + "\n".join(params_desc)
+            else:
+                tool_block += "\n  参数: 无"
+
+            summaries.append(tool_block)
+
+        # 分组使用说明:仅显示搜出的工具实际所属的分组,避免噪音
+        relevant_group_ids = set()
+        for t in tools:
+            for gid in t.get("group_ids", []) or []:
+                relevant_group_ids.add(gid)
+
+        group_summary = []
+        for g in groups:
+            if g["group_id"] not in relevant_group_ids:
+                continue
+            group_summary.append(
+                f"[组: {g['group_id']}] {g['name']}\n"
+                f"  调用顺序: {' → '.join(g.get('usage_order', []))}\n"
+                f"  说明: {g.get('usage_example', '')}"
+            )
+
+        output_parts = [f"共找到 {total} 个工具({'关键词: ' + keyword if keyword else '全量'}):\n"]
+        output_parts.append("\n\n".join(summaries))
+        if group_summary:
+            output_parts.append("\n\n=== 工具分组(有顺序依赖)===\n" + "\n\n".join(group_summary))
+
+        return ToolResult(
+            title=f"ToolHub 搜索{f': {keyword}' if keyword else '(全量)'}",
+            output="\n".join(output_parts),
+            long_term_memory=(
+                f"ToolHub 共 {total} 个工具: "
+                + ", ".join(t["tool_id"] for t in tools[:15])
+                + ("..." if total > 15 else "")
+            ),
+        )
+    except (httpx.ReadTimeout, httpx.ConnectTimeout, httpx.WriteTimeout, httpx.PoolTimeout) as e:
+        return ToolResult(
+            title="ToolHub /tools 超时",
+            output="",
+            error=f"ToolHub 的 /tools 接口在 {DEFAULT_TIMEOUT:.0f}s 内未响应({type(e).__name__})。"
+                  f"服务器可能在检测各工具状态导致列表慢,请稍后重试或联系维护者。",
+        )
+    except httpx.ConnectError as e:
+        return ToolResult(
+            title="ToolHub 连接失败",
+            output="",
+            error=f"无法连接到 ToolHub 服务 {TOOLHUB_BASE_URL}:{type(e).__name__}: {e}",
+        )
+    except Exception as e:
+        # 注意 httpx 的部分异常 str(e) 是空的,必须带 type name
+        err_msg = f"{type(e).__name__}: {e}" if str(e) else type(e).__name__
+        return ToolResult(
+            title="搜索 ToolHub 工具失败",
+            output="",
+            error=err_msg,
+        )
+
+
+@tool(
+    display={
+        "zh": {
+            "name": "调用 ToolHub 工具",
+            "params": {"tool_id": "工具ID", "params": "工具参数"},
+        },
+        "en": {
+            "name": "Call ToolHub Tool",
+            "params": {"tool_id": "Tool ID", "params": "Tool parameters"},
+        },
+    },
+    groups=["toolhub"],
+)
+async def toolhub_call(
+    tool_id: str,
+    params: Optional[Dict[str, Any]] = None,
+) -> ToolResult:
+    """调用 ToolHub 远程工具库中的指定工具
+
+    通过 tool_id 调用 ToolHub 工具库中的某个工具,传入该工具所需的参数。
+    不同工具的参数不同,请先用 toolhub_search 查询目标工具的参数说明。
+
+    图片参数(image、image_url、mask_image、pose_image、images)直接传本地文件路径即可,
+    系统会自动上传。生成的图片会自动保存到本地 outputs/ 目录,返回结果中的
+    saved_files 字段包含本地文件路径。
+
+    注意:部分工具为异步生命周期(如 RunComfy、即梦、FLUX),需要按分组顺序
+    依次调用多个工具(如先 launch → 再 executor → 再 stop)。
+
+    Args:
+        tool_id: 要调用的工具 ID(从 toolhub_search 获取)
+        params: 工具参数字典,键值对根据目标工具的参数定义决定。
+                图片参数可直接使用本地文件路径(如 "/path/to/image.png")。
+
+    Returns:
+        ToolResult 包含工具执行结果,图片结果通过 saved_files 返回本地路径
+    """
+    try:
+        # 预处理参数:本地文件路径自动上传成 CDN URL
+        params = await _preprocess_params(params or {})
+
+        payload = {
+            "tool_id": tool_id,
+            "params": params,
+        }
+        async with httpx.AsyncClient(timeout=CALL_TIMEOUT, trust_env=False) as client:
+            resp = await client.post(
+                f"{TOOLHUB_BASE_URL}/run_tool", json=payload
+            )
+            resp.raise_for_status()
+            data = resp.json()
+
+        status = data.get("status")
+        if status == "success":
+            result = data.get("result", {})
+            result_str = json.dumps(result, ensure_ascii=False, indent=2)
+
+            # 提取图片并统一处理(下载 → 保存本地 → 上传 OSS → CDN URL)
+            images = []
+            if isinstance(result, dict):
+                # 收集所有图片(单张 image 字段 + images 列表字段)
+                raw_images = []
+                has_single_image = False
+                has_images_list = False
+
+                if result.get("image") and isinstance(result["image"], str):
+                    raw_images.append(result["image"])
+                    has_single_image = True
+
+                if result.get("images") and isinstance(result["images"], list):
+                    raw_images.extend(result["images"])
+                    has_images_list = True
+
+                if raw_images:
+                    images, cdn_urls, saved_paths = await _process_images(raw_images, tool_id)
+
+                    # 构建文本输出(去掉原始图片数据,以本地路径为主)
+                    result_display = {k: v for k, v in result.items() if k not in ("image", "images")}
+                    result_display["image_count"] = len(images)
+                    if saved_paths:
+                        result_display["saved_files"] = saved_paths
+                    if cdn_urls:
+                        result_display["cdn_urls"] = cdn_urls
+                    result_str = json.dumps(result_display, ensure_ascii=False, indent=2)
+
+            return ToolResult(
+                title=f"ToolHub [{tool_id}] 执行成功",
+                output=result_str,
+                long_term_memory=f"Called ToolHub tool '{tool_id}' → success",
+                images=images,
+            )
+        else:
+            error_msg = data.get("error", "未知错误")
+            return ToolResult(
+                title=f"ToolHub [{tool_id}] 执行失败",
+                output=json.dumps(data, ensure_ascii=False, indent=2),
+                error=error_msg,
+            )
+    except httpx.TimeoutException as e:
+        return ToolResult(
+            title=f"ToolHub [{tool_id}] 调用超时",
+            output="",
+            error=f"调用工具 {tool_id} 超时({CALL_TIMEOUT:.0f}s,{type(e).__name__}),"
+                  f"图像生成类工具可能需要更长时间。",
+        )
+    except Exception as e:
+        err_msg = f"{type(e).__name__}: {e}" if str(e) else type(e).__name__
+        return ToolResult(
+            title=f"ToolHub [{tool_id}] 调用失败",
+            output="",
+            error=err_msg,
+        )
+
+
+# 注意:image_uploader 和 image_downloader 不再注册为 Agent 工具。
+# toolhub_call 已内置完整的图片管线(输入自动上传,输出自动下载保存),
+# 无需单独暴露上传/下载工具。以下函数保留供内部或 CLI 使用。
+
+
+async def image_uploader(local_path: str) -> ToolResult:
+    """将本地图片上传到 OSS,返回可用的 CDN URL(内部工具,不注册给 Agent)"""
+    import os
+    from pathlib import Path
+
+    p = Path(local_path)
+    if not p.exists():
+        return ToolResult(
+            title="图片上传失败",
+            output="",
+            error=f"文件不存在: {local_path}",
+        )
+    if not p.is_file():
+        return ToolResult(
+            title="图片上传失败",
+            output="",
+            error=f"路径不是文件: {local_path}",
+        )
+
+    cdn_url = await _upload_to_oss(str(p.resolve()))
+    if cdn_url:
+        result = {
+            "local_path": str(p.resolve()),
+            "cdn_url": cdn_url,
+            "file_size": os.path.getsize(p),
+        }
+        return ToolResult(
+            title="图片上传成功",
+            output=json.dumps(result, ensure_ascii=False, indent=2),
+            long_term_memory=f"Uploaded {local_path} → {cdn_url}",
+        )
+    else:
+        return ToolResult(
+            title="图片上传失败",
+            output="",
+            error=f"OSS 上传失败,请检查文件路径和网络连接: {local_path}",
+        )
+
+
+async def image_downloader(url: str, save_path: str = "") -> ToolResult:
+    """下载网络图片到本地文件(内部工具,不注册给 Agent)"""
+    import os
+    from pathlib import Path
+    from urllib.parse import urlparse, unquote
+
+    if not url.startswith(("http://", "https://")):
+        return ToolResult(
+            title="图片下载失败",
+            output="",
+            error=f"无效的 URL(必须以 http:// 或 https:// 开头): {url}",
+        )
+
+    # 自动生成保存路径
+    if not save_path:
+        out_dir = _get_output_dir("download")
+        # 从 URL 提取文件名
+        url_path = urlparse(url).path
+        filename = Path(unquote(url_path)).name if url_path else ""
+        if not filename or not any(filename.lower().endswith(ext) for ext in (".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp")):
+            filename = f"download_{int(time.time())}.png"
+        save_path = str(out_dir / filename)
+
+    # 确保目录存在
+    p = Path(save_path)
+    p.parent.mkdir(parents=True, exist_ok=True)
+
+    try:
+        async with httpx.AsyncClient(timeout=60.0, follow_redirects=True, trust_env=False) as client:
+            resp = await client.get(url)
+            resp.raise_for_status()
+            p.write_bytes(resp.content)
+
+        file_size = os.path.getsize(p)
+        result = {
+            "save_path": str(p.resolve()),
+            "file_size": file_size,
+            "source_url": url,
+        }
+        return ToolResult(
+            title="图片下载成功",
+            output=json.dumps(result, ensure_ascii=False, indent=2),
+            long_term_memory=f"Downloaded {url} → {save_path}",
+        )
+    except httpx.HTTPStatusError as e:
+        return ToolResult(
+            title="图片下载失败",
+            output="",
+            error=f"HTTP 错误 {e.response.status_code}: {url}",
+        )
+    except Exception as e:
+        return ToolResult(
+            title="图片下载失败",
+            output="",
+            error=f"下载失败: {e}",
+        )
+
+
+if __name__ == "__main__":
+    import sys
+
+    COMMANDS = {
+        "health": toolhub_health,
+        "search": toolhub_search,
+        "call": toolhub_call,
+    }
+
+    def _parse_args(argv):
+        kwargs = {}
+        for arg in argv:
+            if arg.startswith("--") and "=" in arg:
+                k, v = arg.split("=", 1)
+                k = k.lstrip("-").replace("-", "_")
+                try:
+                    v = json.loads(v)
+                except (json.JSONDecodeError, ValueError):
+                    pass
+                kwargs[k] = v
+        return kwargs
+
+    if len(sys.argv) < 2 or sys.argv[1] in ("-h", "--help"):
+        print(f"用法: python {sys.argv[0]} <command> [--key=value ...]")
+        print(f"可用命令: {', '.join(COMMANDS.keys())}")
+        sys.exit(0)
+
+    cmd = sys.argv[1]
+    if cmd not in COMMANDS:
+        print(f"未知命令: {cmd},可用: {', '.join(COMMANDS.keys())}")
+        sys.exit(1)
+
+    import asyncio
+    import uuid
+    import os
+
+    kwargs = _parse_args(sys.argv[2:])
+
+    # trace_id:CLI 参数 > 环境变量 > 自动生成(用于图片输出目录)
+    trace_id = kwargs.pop("trace_id", None) or os.getenv("TRACE_ID") or f"cli-{uuid.uuid4().hex[:8]}"
+    set_trace_context(trace_id)
+
+    result = asyncio.run(COMMANDS[cmd](**kwargs))
+
+    # 修复双重 JSON 编码:如果 output 已经是一段 JSON 字符串(toolhub_call 内部
+    # 把 result dict 做过 json.dumps),解析回原生 dict 再嵌入 CLI 的最终 JSON,
+    # 避免调用方拿到"output 字段是被字符串化的 JSON"这种反人类形式。
+    output_value = result.output
+    if isinstance(output_value, str):
+        stripped = output_value.lstrip()
+        if stripped.startswith("{") or stripped.startswith("["):
+            try:
+                output_value = json.loads(output_value)
+            except (json.JSONDecodeError, ValueError):
+                pass  # 非 JSON 文本,保持原样
+
+    out = {"trace_id": trace_id, "output": output_value}
+    if result.error:
+        out["error"] = result.error
+    if result.metadata:
+        out["metadata"] = result.metadata
+    print(json.dumps(out, ensure_ascii=False, indent=2))