mirror of
https://github.com/zhouxiaoka/autoclip.git
synced 2026-10-02 02:34:34 +08:00
feat(cli): 支持 opencode MCP 一键接入(autoclip mcp install opencode) (#229)
* feat(cli): 支持 opencode MCP 一键接入(autoclip mcp install opencode) 新增 autoclip mcp install opencode:自动探测启动命令(autoclip / autoclip-mcp / python -m backend.mcp_server),把 mcp.autoclip 合并写入 opencode 配置(全局 ~/.config/opencode/opencode.json,或 --scope project 写项目 opencode.json)。 不覆盖用户已有配置:写入前备份 opencode.json.bak;JSONC 注释配置默认不动,--force 才先备份再重写;无法解析的配置永不覆盖。新增 backend/services/opencode_setup.py(仅标准库)与 7 个单元测试;新增 docs/OPENCODE.md / docs/OPENCODE.en.md,并更新 CLI_AND_MCP、README、mcp_server.py docstring。 * docs: 文档中心收录 opencode 接入指南 * fix(cli): opencode 接入修复 [P2]:重装保留自定义字段、jsonc 优先级与冲突拒绝 重装只更新 type / command,按 key 合并 environment(模块回退写 PYTHONPATH),保留 timeout / enabled 等用户字段;只有 opencode.jsonc 时写 jsonc,两份配置都有且 jsonc 含 mcp 段时写入前直接拒绝;解析兼容 UTF-8 BOM。回归测试 16 passed。 * fix(cli): honor explicit OpenCode config and refresh fallback paths --------- Co-authored-by: 周小舟 <christine_zhouye@163.com>
This commit is contained in:
+2
-2
@@ -242,7 +242,7 @@ autoclip export PROJECT_ID --preset shorts
|
||||
autoclip mcp
|
||||
```
|
||||
|
||||
In your MCP client, set `command` to the absolute path of `autoclip` in your virtual environment and `args` to `["mcp"]`. See the [CLI / MCP guide](docs/CLI_AND_MCP.md) and [Agent skill](skills/autoclip/SKILL.md) (both in Chinese).
|
||||
In your MCP client, set `command` to the absolute path of `autoclip` in your virtual environment and `args` to `["mcp"]`. opencode users can connect with one command: `autoclip mcp install opencode` (see the [OpenCode guide](docs/OPENCODE.en.md)). See the [CLI / MCP guide](docs/CLI_AND_MCP.md) and [Agent skill](skills/autoclip/SKILL.md) (both in Chinese).
|
||||
|
||||
</details>
|
||||
|
||||
@@ -306,7 +306,7 @@ See the [first-clip guide](docs/USER_INSTALLATION_GUIDE.en.md) for sample prepar
|
||||
| Guide | Link |
|
||||
| --- | --- |
|
||||
| Getting started | [Installation](docs/USER_INSTALLATION_GUIDE.en.md) |
|
||||
| Hosting and automation | [Docker](docs/DOCKER.en.md) · [CLI / MCP (Chinese)](docs/CLI_AND_MCP.md) · [Agent skill (Chinese)](skills/autoclip/SKILL.md) |
|
||||
| Hosting and automation | [Docker](docs/DOCKER.en.md) · [CLI / MCP (Chinese)](docs/CLI_AND_MCP.md) · [Agent skill (Chinese)](skills/autoclip/SKILL.md) · [opencode guide](docs/OPENCODE.en.md) |
|
||||
| Models and troubleshooting | [Model configuration (Chinese)](docs/MULTI_LLM_PROVIDER_GUIDE.md) · [Troubleshooting](docs/FAQ.en.md) |
|
||||
| Versions and privacy | [Changelog](CHANGELOG.md) · [Privacy](docs/PRIVACY.en.md) |
|
||||
| Development and translation | [Contributing (Chinese)](CONTRIBUTING.md) · [Translation maintenance (Chinese)](docs/i18n.md) |
|
||||
|
||||
@@ -236,7 +236,7 @@ autoclip export PROJECT_ID --preset shorts
|
||||
autoclip mcp
|
||||
```
|
||||
|
||||
在 MCP 客户端中将 `command` 设为虚拟环境里 `autoclip` 的绝对路径,`args` 设为 `["mcp"]`。详见 [CLI / MCP 指南](docs/CLI_AND_MCP.md)(中文)和 [Agent skill](skills/autoclip/SKILL.md)(中文)。
|
||||
在 MCP 客户端中将 `command` 设为虚拟环境里 `autoclip` 的绝对路径,`args` 设为 `["mcp"]`。opencode 用户一条命令即可接入:`autoclip mcp install opencode`(见 [OpenCode 接入](docs/OPENCODE.md))。详见 [CLI / MCP 指南](docs/CLI_AND_MCP.md)(中文)和 [Agent skill](skills/autoclip/SKILL.md)(中文)。
|
||||
|
||||
</details>
|
||||
|
||||
@@ -298,7 +298,7 @@ AutoClip 免费、开源(MIT)。云端模型由所选服务商计费,需
|
||||
| 你想了解 | 文档 |
|
||||
| --- | --- |
|
||||
| 安装与首次出片 | [安装指南](docs/USER_INSTALLATION_GUIDE.md) |
|
||||
| 自建服务与自动化 | [Docker 部署](DOCKER.md) · [CLI / MCP](docs/CLI_AND_MCP.md) · [Agent skill](skills/autoclip/SKILL.md) |
|
||||
| 自建服务与自动化 | [Docker 部署](DOCKER.md) · [CLI / MCP](docs/CLI_AND_MCP.md) · [Agent skill](skills/autoclip/SKILL.md) · [OpenCode 接入](docs/OPENCODE.md) |
|
||||
| 配置模型与排错 | [模型配置](docs/MULTI_LLM_PROVIDER_GUIDE.md) · [常见问题](docs/FAQ.md) |
|
||||
| 版本与隐私 | [更新日志](CHANGELOG.md) · [隐私说明](docs/PRIVACY.md) |
|
||||
| 参与开发与翻译 | [贡献指南](CONTRIBUTING.md) · [翻译维护](docs/i18n.md) |
|
||||
|
||||
+60
-2
@@ -9,6 +9,7 @@ autoclip — 命令行出片。
|
||||
autoclip run video.mp4 --srt video.srt --min-score 0.6 --json
|
||||
autoclip list / show <project_id> / providers / doctor
|
||||
autoclip publish <project_id> --clip 2 --platform tiktok --platform youtube # 经 Upload-Post 发到海外平台
|
||||
autoclip mcp install opencode # 把 AutoClip 写进 opencode 的 MCP 配置(默认全局)
|
||||
|
||||
产物与桌面应用共用同一个数据目录(mac: ~/Library/Application Support/AutoClip),
|
||||
跑完在桌面应用首页就能看到。用 --data-dir 或 AUTOCLIP_DATA_DIR 可以换目录。
|
||||
@@ -444,11 +445,58 @@ def cmd_publish(args: argparse.Namespace) -> int:
|
||||
|
||||
# ---------------------------------------------------------------- mcp ---
|
||||
def cmd_mcp(args: argparse.Namespace) -> int:
|
||||
if getattr(args, "mcp_cmd", None) == "install":
|
||||
return cmd_mcp_install(args)
|
||||
from backend.mcp_server import main as mcp_main
|
||||
|
||||
return mcp_main()
|
||||
|
||||
|
||||
def _print_opencode_next_steps() -> None:
|
||||
print(_dim(" 在 opencode 里直接说:把 C:\\Videos\\talk.mp4 切片,它会调 AutoClip 的 MCP 工具"))
|
||||
print(_dim(" 模型没配好先跑 autoclip doctor;opencode 要新开会话才会加载新的 MCP"))
|
||||
|
||||
|
||||
def cmd_mcp_install(args: argparse.Namespace) -> int:
|
||||
"""把 AutoClip 写进 MCP 客户端配置;目前支持 opencode。"""
|
||||
from backend.services import opencode_setup
|
||||
|
||||
target = opencode_setup.opencode_config_path(args.scope, Path(args.dir) if args.dir else None)
|
||||
if args.print_only:
|
||||
snippet = opencode_setup.render_snippet(opencode_setup.build_entry(), name=args.name)
|
||||
if args.json:
|
||||
print(json.dumps({"client": args.client, "path": str(target), "snippet": snippet}, ensure_ascii=False, indent=2))
|
||||
else:
|
||||
print(snippet)
|
||||
print(_dim(f"\n把上面的片段合并进 {target}"), file=sys.stderr)
|
||||
return 0
|
||||
|
||||
explicit_config = args.scope == "global" and bool(os.environ.get("OPENCODE_CONFIG"))
|
||||
report = opencode_setup.install_opencode(
|
||||
target, name=args.name, force=args.force, discover_jsonc=not explicit_config,
|
||||
)
|
||||
if args.json:
|
||||
print(json.dumps(report, ensure_ascii=False, indent=2))
|
||||
elif report["action"] == "created":
|
||||
print(_c("32", "✓ ") + f"已写入 {report['path']}")
|
||||
_print_opencode_next_steps()
|
||||
elif report["action"] == "updated":
|
||||
print(_c("32", "✓ ") + f"已更新 {report['path']} 的 mcp.{args.name}")
|
||||
if report.get("backup"):
|
||||
print(_dim(f" 旧配置已备份:{report['backup']}"))
|
||||
_print_opencode_next_steps()
|
||||
elif report["action"] == "unchanged":
|
||||
print(_dim(f"• {report['path']} 里已有相同的 mcp.{args.name},未改动"))
|
||||
else:
|
||||
_err(report.get("error") or "写入失败")
|
||||
if report.get("hint"):
|
||||
print(_dim(f" {report['hint']}"), file=sys.stderr)
|
||||
print(report["snippet"])
|
||||
for w in report.get("warnings") or []:
|
||||
print(_dim(f" ! {w}"), file=sys.stderr)
|
||||
return 0 if report["ok"] else 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- parser ---
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
p = argparse.ArgumentParser(
|
||||
@@ -494,8 +542,18 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
_add_llm_args(d)
|
||||
d.set_defaults(func=cmd_doctor)
|
||||
|
||||
m = sub.add_parser("mcp", help="以 MCP server(stdio)方式运行,供 Cursor / Claude 调用")
|
||||
m.set_defaults(func=cmd_mcp)
|
||||
m = sub.add_parser("mcp", help="以 MCP server(stdio)方式运行,供 Cursor / Claude / opencode 调用;install 子命令自动写客户端配置")
|
||||
m.set_defaults(func=cmd_mcp) # 不带子命令 = 直接起 server(子命令的分发在 cmd_mcp 里判断)
|
||||
mi_sub = m.add_subparsers(dest="mcp_cmd")
|
||||
mi = mi_sub.add_parser("install", help="把 AutoClip 写进 MCP 客户端配置(目前支持 opencode)")
|
||||
mi.add_argument("client", nargs="?", default="opencode", choices=["opencode"], help="MCP 客户端(默认 opencode)")
|
||||
mi.add_argument("--scope", choices=["global", "project"], default="global",
|
||||
help="global=~/.config/opencode/opencode.json;project=<--dir 目录>/opencode.json")
|
||||
mi.add_argument("--dir", help="project 模式的项目目录(默认当前目录)")
|
||||
mi.add_argument("--name", default="autoclip", help="配置里的 MCP 服务名(默认 autoclip)")
|
||||
mi.add_argument("--print", dest="print_only", action="store_true", help="只打印配置片段,不改文件")
|
||||
mi.add_argument("--force", action="store_true", help="现有配置含注释 / 尾随逗号时,先备份再重写为纯 JSON")
|
||||
mi.add_argument("--json", action="store_true")
|
||||
|
||||
e = sub.add_parser("export", help="把切片渲成可发布成片(9:16 / 烧字幕 / 标题卡)")
|
||||
e.add_argument("project_id")
|
||||
|
||||
@@ -5,10 +5,12 @@ AutoClip MCP server(stdio)——让 Cursor / Claude Code / 任何 MCP 客户
|
||||
autoclip mcp # 装了包
|
||||
python -m backend.mcp_server # 仓库内
|
||||
|
||||
客户端配置示例(Cursor `~/.cursor/mcp.json` / Claude `claude mcp add`):
|
||||
客户端配置示例(Cursor `~/.cursor/mcp.json` / Claude `claude mcp add` / opencode):
|
||||
{ "mcpServers": { "autoclip": { "command": "autoclip", "args": ["mcp"] } } }
|
||||
或 { "command": "/path/to/autoclip/venv/bin/python", "args": ["-m", "backend.mcp_server"],
|
||||
"env": { "PYTHONPATH": "/path/to/autoclip" } }
|
||||
opencode(opencode.json;一条命令:`autoclip mcp install opencode`,见 docs/OPENCODE.md):
|
||||
{ "mcp": { "autoclip": { "type": "local", "command": ["autoclip", "mcp"], "enabled": true } } }
|
||||
|
||||
工具:
|
||||
clip_video 同步出片(几分钟到几十分钟,带进度通知)
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
"""把 AutoClip 接入 opencode CLI(opencode.json 的 mcp 配置)。
|
||||
|
||||
opencode(https://opencode.ai)的本地 MCP server 写在配置文件的 mcp 段:
|
||||
|
||||
{
|
||||
"mcp": {
|
||||
"autoclip": { "type": "local", "command": ["/abs/path/autoclip", "mcp"], "enabled": true }
|
||||
}
|
||||
}
|
||||
|
||||
- 全局配置:`~/.config/opencode/opencode.json`(`OPENCODE_CONFIG` / `XDG_CONFIG_HOME` 可改路径)
|
||||
- 项目配置:`<项目>/opencode.json`;opencode 会合并多处配置,项目覆盖全局
|
||||
- 同时存在 `.json` 与 `.jsonc` 时 opencode 先读 json 再读 jsonc(后者覆盖同名键):
|
||||
只有 jsonc 时直接写它;两份都有且 jsonc 里已有 mcp 段时拒绝写入(避免“写入成功但实际加载旧配置”)
|
||||
|
||||
对应 CLI:`autoclip mcp install opencode`(`--scope project` 写项目配置、`--print` 只打印片段)。
|
||||
本模块只做配置读写与命令探测,不 import 后端重依赖,方便 CLI 与测试单独使用。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
OPENCODE_SCHEMA = "https://opencode.ai/config.json"
|
||||
DEFAULT_SERVER_NAME = "autoclip"
|
||||
|
||||
|
||||
def detect_server() -> Dict[str, Any]:
|
||||
"""探测用什么命令启动 MCP server:PATH 里的 autoclip → autoclip-mcp → python -m backend.mcp_server。"""
|
||||
exe = shutil.which("autoclip")
|
||||
if exe:
|
||||
return {"command": [exe, "mcp"], "cwd": None, "environment": None, "kind": "autoclip"}
|
||||
exe = shutil.which("autoclip-mcp")
|
||||
if exe:
|
||||
return {"command": [exe], "cwd": None, "environment": None, "kind": "autoclip-mcp"}
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
env = {"PYTHONPATH": str(repo_root)}
|
||||
cwd = str(repo_root) if (repo_root / "backend" / "mcp_server.py").is_file() else None
|
||||
return {
|
||||
"command": [sys.executable or "python", "-m", "backend.mcp_server"],
|
||||
"cwd": cwd,
|
||||
"environment": env,
|
||||
"kind": "module",
|
||||
}
|
||||
|
||||
|
||||
def opencode_config_path(scope: str = "global", project_dir: Optional[Path] = None) -> Path:
|
||||
"""目标配置文件:global → ~/.config/opencode/opencode.json;project → <目录>/opencode.json。"""
|
||||
if scope == "project":
|
||||
base = Path(project_dir).expanduser() if project_dir else Path.cwd()
|
||||
return base / "opencode.json"
|
||||
override = os.environ.get("OPENCODE_CONFIG")
|
||||
if override:
|
||||
return Path(override).expanduser()
|
||||
xdg = os.environ.get("XDG_CONFIG_HOME")
|
||||
config_home = Path(xdg).expanduser() if xdg else Path.home() / ".config"
|
||||
return config_home / "opencode" / "opencode.json"
|
||||
|
||||
|
||||
def build_entry(server: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
"""opencode `mcp` 段里的 autoclip 条目(type=local)。"""
|
||||
server = server or detect_server()
|
||||
entry: Dict[str, Any] = {"type": "local", "command": list(server["command"]), "enabled": True}
|
||||
if server.get("cwd"):
|
||||
entry["cwd"] = server["cwd"]
|
||||
if server.get("environment"):
|
||||
entry["environment"] = dict(server["environment"])
|
||||
return entry
|
||||
|
||||
|
||||
def render_snippet(entry: Dict[str, Any], name: str = DEFAULT_SERVER_NAME) -> str:
|
||||
"""手动合并进 opencode 配置时用的 JSON 片段。"""
|
||||
return json.dumps({"mcp": {name: entry}}, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def _strip_comments(text: str) -> str:
|
||||
"""去掉 // 与 /* */ 注释;字符串(含转义)里的内容不动。"""
|
||||
out: List[str] = []
|
||||
i, n = 0, len(text)
|
||||
in_str = False
|
||||
while i < n:
|
||||
ch = text[i]
|
||||
if in_str:
|
||||
out.append(ch)
|
||||
if ch == "\\" and i + 1 < n:
|
||||
out.append(text[i + 1])
|
||||
i += 2
|
||||
continue
|
||||
if ch == '"':
|
||||
in_str = False
|
||||
i += 1
|
||||
continue
|
||||
if ch == '"':
|
||||
in_str = True
|
||||
out.append(ch)
|
||||
i += 1
|
||||
continue
|
||||
if ch == "/" and i + 1 < n and text[i + 1] == "/":
|
||||
while i < n and text[i] not in "\r\n":
|
||||
i += 1
|
||||
continue
|
||||
if ch == "/" and i + 1 < n and text[i + 1] == "*":
|
||||
i += 2
|
||||
while i + 1 < n and not (text[i] == "*" and text[i + 1] == "/"):
|
||||
i += 1
|
||||
i += 2
|
||||
continue
|
||||
out.append(ch)
|
||||
i += 1
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def _strip_trailing_commas(text: str) -> str:
|
||||
"""去掉对象 / 数组结尾多余的逗号;字符串里的逗号不动。"""
|
||||
out: List[str] = []
|
||||
i, n = 0, len(text)
|
||||
in_str = False
|
||||
while i < n:
|
||||
ch = text[i]
|
||||
if in_str:
|
||||
out.append(ch)
|
||||
if ch == "\\" and i + 1 < n:
|
||||
out.append(text[i + 1])
|
||||
i += 2
|
||||
continue
|
||||
if ch == '"':
|
||||
in_str = False
|
||||
i += 1
|
||||
continue
|
||||
if ch == '"':
|
||||
in_str = True
|
||||
out.append(ch)
|
||||
i += 1
|
||||
continue
|
||||
if ch == ",":
|
||||
j = i + 1
|
||||
while j < n and text[j] in " \t\r\n":
|
||||
j += 1
|
||||
if j < n and text[j] in "}]":
|
||||
i += 1
|
||||
continue
|
||||
out.append(ch)
|
||||
i += 1
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def parse_config_text(text: str) -> Tuple[Dict[str, Any], bool]:
|
||||
"""解析 opencode 配置文本,返回 (data, 是否走了 JSONC 宽松模式)。
|
||||
|
||||
先按严格 JSON 解析;失败再容忍注释与尾随逗号(opencode 官方支持 JSONC)。
|
||||
两种都失败时抛 json.JSONDecodeError。
|
||||
"""
|
||||
text = text.lstrip("\ufeff") # Windows 编辑器可能带 UTF-8 BOM
|
||||
try:
|
||||
data = json.loads(text)
|
||||
lenient = False
|
||||
except json.JSONDecodeError:
|
||||
data = json.loads(_strip_trailing_commas(_strip_comments(text)))
|
||||
lenient = True
|
||||
if not isinstance(data, dict):
|
||||
raise json.JSONDecodeError("opencode 配置顶层必须是 JSON 对象", text, 0)
|
||||
return data, lenient
|
||||
|
||||
|
||||
def install_opencode(
|
||||
target: Path,
|
||||
*,
|
||||
server: Optional[Dict[str, Any]] = None,
|
||||
name: str = DEFAULT_SERVER_NAME,
|
||||
force: bool = False,
|
||||
discover_jsonc: bool = True,
|
||||
) -> Dict[str, Any]:
|
||||
"""把 autoclip 写进 opencode 配置(合并写入:不动其它键,重装也保留同名条目里的自定义字段)。
|
||||
|
||||
返回报告:ok / action / path / entry / snippet / backup / warnings / error / hint。
|
||||
action 取值 created / updated / unchanged / manual;manual = 没有写文件,
|
||||
需要用户手动合并 snippet(配置解析失败,或带注释又没给 force)。
|
||||
discover_jsonc=False 时严格写 target,用于 OPENCODE_CONFIG 显式指定的文件。
|
||||
"""
|
||||
server = server or detect_server()
|
||||
entry = build_entry(server)
|
||||
report: Dict[str, Any] = {
|
||||
"ok": False,
|
||||
"action": "manual",
|
||||
"path": str(target),
|
||||
"entry": entry,
|
||||
"snippet": render_snippet(entry, name=name),
|
||||
"backup": None,
|
||||
"warnings": [],
|
||||
"error": None,
|
||||
"hint": None,
|
||||
}
|
||||
|
||||
# 同时存在 opencode.json / opencode.jsonc 时,opencode 先读 json 再读 jsonc(后者覆盖):
|
||||
# 只有 jsonc 就写 jsonc;两份都有且 jsonc 里已有 mcp 段时拒绝写入,避免“成功但不生效”。
|
||||
write_target = target
|
||||
if discover_jsonc and target.name == "opencode.json":
|
||||
jsonc_path = target.with_name("opencode.jsonc")
|
||||
if jsonc_path.is_file():
|
||||
if not target.is_file():
|
||||
write_target = jsonc_path
|
||||
else:
|
||||
try:
|
||||
jsonc_data, _ = parse_config_text(jsonc_path.read_text(encoding="utf-8-sig"))
|
||||
except Exception as e: # noqa: BLE001
|
||||
report.update(
|
||||
error=f"同目录的 opencode.jsonc 无法解析:{e}",
|
||||
hint="opencode.jsonc 覆盖 opencode.json;请先处理它,或手动合并下面的片段",
|
||||
)
|
||||
return report
|
||||
if isinstance(jsonc_data.get("mcp"), dict):
|
||||
report.update(
|
||||
error="opencode.json 与 opencode.jsonc 同时存在,且 jsonc 里已有 mcp 段(它覆盖 json),"
|
||||
"继续写入会出现“成功但实际不生效”",
|
||||
hint=f"请只保留一份配置,或手动把下面的片段合并进 {jsonc_path.name}",
|
||||
)
|
||||
return report
|
||||
report["path"] = str(write_target)
|
||||
|
||||
exists = write_target.is_file()
|
||||
data: Dict[str, Any] = {}
|
||||
lenient = False
|
||||
if exists:
|
||||
try:
|
||||
data, lenient = parse_config_text(write_target.read_text(encoding="utf-8-sig"))
|
||||
except Exception as e: # noqa: BLE001
|
||||
report.update(error=f"现有配置无法解析:{e}", hint="为免误删,请手动把下面的片段合并进配置")
|
||||
return report
|
||||
if lenient and not force:
|
||||
report.update(
|
||||
error="现有配置带注释 / 尾随逗号(JSONC),重写会丢注释,没有动它",
|
||||
hint=f"确认可接受时重跑并加 --force(会先备份为 {write_target.name}.bak)",
|
||||
)
|
||||
return report
|
||||
if write_target is not target:
|
||||
report["warnings"].append("未找到 opencode.json,本次写入 opencode.jsonc(它优先级更高)")
|
||||
|
||||
mcp = data.get("mcp")
|
||||
if mcp is not None and not isinstance(mcp, dict):
|
||||
report.update(action="error", error="配置里的 mcp 字段不是对象,未改动", hint="请手动清理 mcp 字段后重试")
|
||||
return report
|
||||
mcp = dict(mcp or {})
|
||||
|
||||
# 重装只更新安装器负责的字段(type / command / 模块回退的 cwd 与 PYTHONPATH),
|
||||
# 保留同名条目里用户自己的 environment、timeout、enabled 等设置。
|
||||
existing_entry = mcp.get(name)
|
||||
merged = entry
|
||||
if isinstance(existing_entry, dict):
|
||||
merged = dict(existing_entry)
|
||||
merged["type"] = entry["type"]
|
||||
merged["command"] = entry["command"]
|
||||
if server.get("kind") == "module":
|
||||
env = dict(merged.get("environment") or {})
|
||||
for k, v in (entry.get("environment") or {}).items():
|
||||
if k == "PYTHONPATH":
|
||||
# 当前安装路径优先,同时保留用户的额外模块路径;重复安装不再追加。
|
||||
paths = env[k].split(os.pathsep) if env.get(k) else []
|
||||
env[k] = os.pathsep.join([v, *(p for p in paths if p != v)])
|
||||
else:
|
||||
env.setdefault(k, v)
|
||||
if env:
|
||||
merged["environment"] = env
|
||||
if entry.get("cwd"):
|
||||
merged["cwd"] = entry["cwd"]
|
||||
report["entry"] = merged
|
||||
report["snippet"] = render_snippet(merged, name=name)
|
||||
|
||||
if exists and existing_entry == merged:
|
||||
report.update(ok=True, action="unchanged")
|
||||
return report
|
||||
|
||||
data.setdefault("$schema", OPENCODE_SCHEMA)
|
||||
mcp[name] = merged
|
||||
data["mcp"] = mcp
|
||||
|
||||
backup: Optional[Path] = None
|
||||
if exists:
|
||||
backup = write_target.with_name(write_target.name + ".bak")
|
||||
shutil.copy2(write_target, backup)
|
||||
write_target.parent.mkdir(parents=True, exist_ok=True)
|
||||
write_target.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
if lenient:
|
||||
report["warnings"].append(f"原配置的注释 / 尾随逗号未保留,已重写为纯 JSON(备份:{backup})")
|
||||
if isinstance(existing_entry, dict) and existing_entry.get("enabled") is False:
|
||||
report["warnings"].append("原有条目 enabled=false,opencode 不会加载它;本次未改动该字段")
|
||||
if server.get("kind") == "module":
|
||||
report["warnings"].append("PATH 里没有 autoclip 命令,已回退到 python -m backend.mcp_server(依赖 cwd / PYTHONPATH)")
|
||||
|
||||
report.update(ok=True, action="updated" if exists else "created", backup=str(backup) if backup else None)
|
||||
return report
|
||||
@@ -0,0 +1,324 @@
|
||||
"""opencode 接入:配置路径 / 合并写入 / JSONC 与失败保护。全部落在 tmp_path,不碰真实用户配置。"""
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.services import opencode_setup
|
||||
|
||||
SERVER = {"command": ["/opt/venv/bin/autoclip", "mcp"], "cwd": None, "environment": None, "kind": "autoclip"}
|
||||
|
||||
|
||||
def test_global_path_respects_xdg_and_opencode_config(tmp_path, monkeypatch):
|
||||
monkeypatch.delenv("OPENCODE_CONFIG", raising=False)
|
||||
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
|
||||
assert opencode_setup.opencode_config_path("global") == tmp_path / "opencode" / "opencode.json"
|
||||
|
||||
custom = tmp_path / "custom-opencode.json"
|
||||
monkeypatch.setenv("OPENCODE_CONFIG", str(custom))
|
||||
assert opencode_setup.opencode_config_path("global") == custom
|
||||
|
||||
|
||||
def test_creates_new_config(tmp_path):
|
||||
target = tmp_path / "opencode.json"
|
||||
report = opencode_setup.install_opencode(target, server=SERVER)
|
||||
assert report["ok"] is True
|
||||
assert report["action"] == "created"
|
||||
data = json.loads(target.read_text(encoding="utf-8"))
|
||||
assert data["$schema"] == opencode_setup.OPENCODE_SCHEMA
|
||||
assert data["mcp"]["autoclip"] == {
|
||||
"type": "local",
|
||||
"command": ["/opt/venv/bin/autoclip", "mcp"],
|
||||
"enabled": True,
|
||||
}
|
||||
|
||||
|
||||
def test_merge_preserves_other_keys_and_backs_up(tmp_path):
|
||||
target = tmp_path / "opencode.json"
|
||||
original = {
|
||||
"$schema": opencode_setup.OPENCODE_SCHEMA,
|
||||
"model": "anthropic/claude-sonnet-4-5",
|
||||
"mcp": {"other": {"type": "remote", "url": "https://example.com/mcp"}},
|
||||
}
|
||||
target.write_text(json.dumps(original, indent=2), encoding="utf-8")
|
||||
|
||||
report = opencode_setup.install_opencode(target, server=SERVER)
|
||||
assert report["ok"] is True and report["action"] == "updated"
|
||||
data = json.loads(target.read_text(encoding="utf-8"))
|
||||
assert data["model"] == "anthropic/claude-sonnet-4-5"
|
||||
assert data["mcp"]["other"] == original["mcp"]["other"]
|
||||
assert data["mcp"]["autoclip"]["command"] == SERVER["command"]
|
||||
assert json.loads((tmp_path / "opencode.json.bak").read_text(encoding="utf-8")) == original
|
||||
|
||||
|
||||
def test_second_run_unchanged(tmp_path):
|
||||
target = tmp_path / "opencode.json"
|
||||
assert opencode_setup.install_opencode(target, server=SERVER)["action"] == "created"
|
||||
report = opencode_setup.install_opencode(target, server=SERVER)
|
||||
assert report["ok"] is True and report["action"] == "unchanged"
|
||||
|
||||
|
||||
def test_jsonc_config_not_touched_without_force(tmp_path):
|
||||
target = tmp_path / "opencode.json"
|
||||
original = '{\n // opencode 支持注释\n "mcp": {},\n}\n'
|
||||
target.write_text(original, encoding="utf-8")
|
||||
|
||||
report = opencode_setup.install_opencode(target, server=SERVER)
|
||||
assert report["ok"] is False and report["action"] == "manual"
|
||||
assert target.read_text(encoding="utf-8") == original
|
||||
|
||||
report = opencode_setup.install_opencode(target, server=SERVER, force=True)
|
||||
assert report["ok"] is True and report["action"] == "updated"
|
||||
data = json.loads(target.read_text(encoding="utf-8"))
|
||||
assert data["mcp"]["autoclip"]["enabled"] is True
|
||||
assert (tmp_path / "opencode.json.bak").is_file()
|
||||
assert any("注释" in w for w in report["warnings"])
|
||||
|
||||
|
||||
def test_broken_config_never_overwritten(tmp_path):
|
||||
target = tmp_path / "opencode.json"
|
||||
target.write_text("{不是 JSON", encoding="utf-8")
|
||||
report = opencode_setup.install_opencode(target, server=SERVER, force=True)
|
||||
assert report["ok"] is False and report["action"] == "manual"
|
||||
assert target.read_text(encoding="utf-8") == "{不是 JSON"
|
||||
assert not (tmp_path / "opencode.json.bak").exists()
|
||||
|
||||
|
||||
def test_project_scope_and_module_fallback(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(opencode_setup.shutil, "which", lambda name: None)
|
||||
server = opencode_setup.detect_server()
|
||||
assert server["kind"] == "module"
|
||||
assert server["command"][1:] == ["-m", "backend.mcp_server"]
|
||||
assert server["environment"]["PYTHONPATH"]
|
||||
|
||||
target = opencode_setup.opencode_config_path("project", tmp_path)
|
||||
assert target == tmp_path / "opencode.json"
|
||||
report = opencode_setup.install_opencode(target, server=server)
|
||||
assert report["ok"] is True
|
||||
entry = json.loads(target.read_text(encoding="utf-8"))["mcp"]["autoclip"]
|
||||
assert entry["cwd"]
|
||||
assert entry["environment"]["PYTHONPATH"]
|
||||
assert any("回退" in w for w in report["warnings"])
|
||||
|
||||
|
||||
def test_reinstall_preserves_custom_entry_fields(tmp_path):
|
||||
"""[review P2] 重装只更新安装器负责的字段,用户自定义的 environment / timeout 不能丢。"""
|
||||
target = tmp_path / "opencode.json"
|
||||
existing = {
|
||||
"$schema": opencode_setup.OPENCODE_SCHEMA,
|
||||
"mcp": {
|
||||
"autoclip": {
|
||||
"type": "local",
|
||||
"command": ["/old/venv/bin/autoclip", "mcp"],
|
||||
"enabled": True,
|
||||
"environment": {"AUTOCLIP_DATA_DIR": "D:/clips", "OPENAI_API_KEY": "sk-x"},
|
||||
"timeout": 30000,
|
||||
}
|
||||
},
|
||||
}
|
||||
target.write_text(json.dumps(existing), encoding="utf-8")
|
||||
|
||||
report = opencode_setup.install_opencode(target, server=SERVER)
|
||||
assert report["ok"] is True and report["action"] == "updated"
|
||||
entry = json.loads(target.read_text(encoding="utf-8"))["mcp"]["autoclip"]
|
||||
assert entry["command"] == SERVER["command"] # 命令更新
|
||||
assert entry["timeout"] == 30000 # 用户字段保留
|
||||
assert entry["environment"] == {"AUTOCLIP_DATA_DIR": "D:/clips", "OPENAI_API_KEY": "sk-x"}
|
||||
|
||||
|
||||
def test_module_fallback_merges_environment(tmp_path, monkeypatch):
|
||||
"""[review P2] 模块回退写 PYTHONPATH 时要合并 environment,而不是整体覆盖。"""
|
||||
monkeypatch.setattr(opencode_setup.shutil, "which", lambda name: None)
|
||||
server = opencode_setup.detect_server()
|
||||
target = tmp_path / "opencode.json"
|
||||
existing = {"mcp": {"autoclip": {
|
||||
"type": "local", "command": ["python", "-m", "backend.mcp_server"],
|
||||
"enabled": True, "environment": {"OPENAI_API_KEY": "sk-x"},
|
||||
}}}
|
||||
target.write_text(json.dumps(existing), encoding="utf-8")
|
||||
|
||||
report = opencode_setup.install_opencode(target, server=server)
|
||||
assert report["ok"] is True
|
||||
env = json.loads(target.read_text(encoding="utf-8"))["mcp"]["autoclip"]["environment"]
|
||||
assert env["OPENAI_API_KEY"] == "sk-x" # 用户环境变量保留
|
||||
assert env["PYTHONPATH"] # 回退所需的 PYTHONPATH 合并进来
|
||||
|
||||
|
||||
def test_disabled_entry_stays_disabled_with_warning(tmp_path):
|
||||
target = tmp_path / "opencode.json"
|
||||
target.write_text(json.dumps({"mcp": {"autoclip": {
|
||||
"type": "local", "command": ["/old/venv/bin/autoclip", "mcp"], "enabled": False,
|
||||
}}}), encoding="utf-8")
|
||||
|
||||
report = opencode_setup.install_opencode(target, server=SERVER)
|
||||
entry = json.loads(target.read_text(encoding="utf-8"))["mcp"]["autoclip"]
|
||||
assert entry["enabled"] is False # 不静默改用户的 enabled
|
||||
assert any("enabled=false" in w for w in report["warnings"])
|
||||
|
||||
|
||||
def test_existing_jsonc_is_used_when_json_missing(tmp_path):
|
||||
"""[review P2] 只有 opencode.jsonc 时直接写它;注释沿用非 --force 保护,不再多建一份不生效的 json。"""
|
||||
jsonc = tmp_path / "opencode.jsonc"
|
||||
jsonc.write_text('{\n // opencode 支持注释\n "mcp": {}\n}\n', encoding="utf-8")
|
||||
|
||||
report = opencode_setup.install_opencode(tmp_path / "opencode.json", server=SERVER)
|
||||
assert report["ok"] is False and report["action"] == "manual" # JSONC 默认不动
|
||||
|
||||
report = opencode_setup.install_opencode(tmp_path / "opencode.json", server=SERVER, force=True)
|
||||
assert report["ok"] is True and report["action"] == "updated"
|
||||
assert report["path"].endswith("opencode.jsonc")
|
||||
data = json.loads(jsonc.read_text(encoding="utf-8"))
|
||||
assert data["mcp"]["autoclip"]["command"] == SERVER["command"]
|
||||
assert (tmp_path / "opencode.jsonc.bak").is_file()
|
||||
assert not (tmp_path / "opencode.json").exists()
|
||||
|
||||
|
||||
def test_plain_jsonc_only_is_targeted(tmp_path):
|
||||
jsonc = tmp_path / "opencode.jsonc"
|
||||
jsonc.write_text('{"model": "anthropic/claude-sonnet-4-5"}', encoding="utf-8")
|
||||
|
||||
report = opencode_setup.install_opencode(tmp_path / "opencode.json", server=SERVER)
|
||||
assert report["ok"] is True and report["action"] == "updated"
|
||||
assert report["path"].endswith("opencode.jsonc")
|
||||
assert json.loads(jsonc.read_text(encoding="utf-8"))["mcp"]["autoclip"]["enabled"] is True
|
||||
assert not (tmp_path / "opencode.json").exists()
|
||||
|
||||
|
||||
def test_conflicting_jsonc_with_mcp_blocks_write(tmp_path):
|
||||
"""[review P2] 两份配置都有且 jsonc 含 mcp 段时必须明确拒绝,不能报告成功但加载旧配置。"""
|
||||
json_file = tmp_path / "opencode.json"
|
||||
original = {"$schema": opencode_setup.OPENCODE_SCHEMA, "model": "x"}
|
||||
json_file.write_text(json.dumps(original), encoding="utf-8")
|
||||
(tmp_path / "opencode.jsonc").write_text(
|
||||
'{"mcp": {"autoclip": {"type": "local", "command": ["old"], "enabled": false}}}', encoding="utf-8")
|
||||
|
||||
report = opencode_setup.install_opencode(json_file, server=SERVER)
|
||||
assert report["ok"] is False and report["action"] == "manual"
|
||||
assert "jsonc" in report["error"]
|
||||
assert json.loads(json_file.read_text(encoding="utf-8")) == original # json 未被写
|
||||
assert report["snippet"]
|
||||
|
||||
|
||||
def test_both_exist_jsonc_without_mcp_writes_json(tmp_path):
|
||||
json_file = tmp_path / "opencode.json"
|
||||
json_file.write_text(json.dumps({"model": "x"}), encoding="utf-8")
|
||||
jsonc = tmp_path / "opencode.jsonc"
|
||||
jsonc.write_text('{"theme": "dark"}', encoding="utf-8")
|
||||
|
||||
report = opencode_setup.install_opencode(json_file, server=SERVER)
|
||||
assert report["ok"] is True and report["action"] == "updated"
|
||||
assert report["path"].endswith("opencode.json")
|
||||
assert json.loads(json_file.read_text(encoding="utf-8"))["mcp"]["autoclip"]["enabled"] is True
|
||||
assert json.loads(jsonc.read_text(encoding="utf-8")) == {"theme": "dark"} # jsonc 未被改动
|
||||
|
||||
|
||||
def test_utf8_bom_jsonc_conflict_still_detected(tmp_path):
|
||||
"""Windows 编辑器常带 UTF-8 BOM,解析要容错,冲突判定不能因此失效。"""
|
||||
json_file = tmp_path / "opencode.json"
|
||||
json_file.write_text(json.dumps({"model": "x"}), encoding="utf-8")
|
||||
(tmp_path / "opencode.jsonc").write_text(
|
||||
'{"mcp": {"autoclip": {"type": "local", "command": ["old"], "enabled": false}}}', encoding="utf-8-sig")
|
||||
|
||||
report = opencode_setup.install_opencode(json_file, server=SERVER)
|
||||
assert report["ok"] is False and report["action"] == "manual"
|
||||
assert "jsonc" in report["error"]
|
||||
|
||||
|
||||
def test_utf8_bom_json_can_be_merged(tmp_path):
|
||||
target = tmp_path / "opencode.json"
|
||||
target.write_text(json.dumps({"model": "x"}), encoding="utf-8-sig")
|
||||
|
||||
report = opencode_setup.install_opencode(target, server=SERVER)
|
||||
assert report["ok"] is True and report["action"] == "updated"
|
||||
assert json.loads(target.read_text(encoding="utf-8"))["mcp"]["autoclip"]["enabled"] is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("config_exists", [False, True])
|
||||
def test_cli_explicit_config_does_not_discover_sibling_jsonc(tmp_path, monkeypatch, capsys, config_exists):
|
||||
from backend import cli
|
||||
|
||||
target = tmp_path / "custom" / "opencode.json"
|
||||
target.parent.mkdir()
|
||||
if config_exists:
|
||||
target.write_text(json.dumps({"model": "original"}), encoding="utf-8")
|
||||
sibling = target.with_suffix(".jsonc")
|
||||
original_sibling = '{"mcp": {"other": {"type": "remote", "url": "https://example.com/mcp"}}}'
|
||||
sibling.write_text(original_sibling, encoding="utf-8")
|
||||
monkeypatch.setenv("OPENCODE_CONFIG", str(target))
|
||||
monkeypatch.setattr(opencode_setup, "detect_server", lambda: SERVER)
|
||||
|
||||
args = cli.build_parser().parse_args(["mcp", "install", "opencode", "--json"])
|
||||
assert cli.cmd_mcp_install(args) == 0
|
||||
report = json.loads(capsys.readouterr().out)
|
||||
assert report["path"] == str(target)
|
||||
assert report["action"] == ("updated" if config_exists else "created")
|
||||
data = json.loads(target.read_text(encoding="utf-8"))
|
||||
assert data["mcp"]["autoclip"]["command"] == SERVER["command"]
|
||||
if config_exists:
|
||||
assert data["model"] == "original"
|
||||
assert sibling.read_text(encoding="utf-8") == original_sibling
|
||||
|
||||
|
||||
def test_cli_project_scope_discovers_jsonc_with_global_override(tmp_path, monkeypatch, capsys):
|
||||
from backend import cli
|
||||
|
||||
global_config = tmp_path / "global" / "opencode.json"
|
||||
monkeypatch.setenv("OPENCODE_CONFIG", str(global_config))
|
||||
monkeypatch.setattr(opencode_setup, "detect_server", lambda: SERVER)
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
jsonc = project / "opencode.jsonc"
|
||||
jsonc.write_text('{"mcp": {}}', encoding="utf-8")
|
||||
|
||||
args = cli.build_parser().parse_args([
|
||||
"mcp", "install", "opencode", "--scope", "project", "--dir", str(project), "--json",
|
||||
])
|
||||
assert cli.cmd_mcp_install(args) == 0
|
||||
report = json.loads(capsys.readouterr().out)
|
||||
assert report["path"] == str(jsonc)
|
||||
assert json.loads(jsonc.read_text(encoding="utf-8"))["mcp"]["autoclip"]["command"] == SERVER["command"]
|
||||
assert not (project / "opencode.json").exists()
|
||||
assert not global_config.exists()
|
||||
|
||||
|
||||
def test_module_reinstall_refreshes_paths_after_checkout_move(tmp_path):
|
||||
old_repo = tmp_path / "old-checkout"
|
||||
backend = old_repo / "backend"
|
||||
backend.mkdir(parents=True)
|
||||
(backend / "__init__.py").write_text("", encoding="utf-8")
|
||||
(backend / "mcp_server.py").write_text('print("current-checkout")\n', encoding="utf-8")
|
||||
extra_modules = tmp_path / "custom-modules"
|
||||
extra_modules.mkdir()
|
||||
target = tmp_path / "opencode.json"
|
||||
old_server = {
|
||||
"kind": "module", "command": [sys.executable, "-m", "backend.mcp_server"],
|
||||
"cwd": str(old_repo), "environment": {"PYTHONPATH": str(old_repo)},
|
||||
}
|
||||
assert opencode_setup.install_opencode(target, server=old_server)["ok"] is True
|
||||
data = json.loads(target.read_text(encoding="utf-8"))
|
||||
data["mcp"]["autoclip"]["environment"].update({
|
||||
"PYTHONPATH": os.pathsep.join([str(old_repo), str(extra_modules)]),
|
||||
"AUTOCLIP_DATA_DIR": str(tmp_path / "clips"),
|
||||
})
|
||||
target.write_text(json.dumps(data), encoding="utf-8")
|
||||
|
||||
new_repo = tmp_path / "new-checkout"
|
||||
old_repo.rename(new_repo)
|
||||
new_server = dict(old_server, cwd=str(new_repo), environment={"PYTHONPATH": str(new_repo)})
|
||||
report = opencode_setup.install_opencode(target, server=new_server)
|
||||
assert report["ok"] is True and report["action"] == "updated"
|
||||
entry = json.loads(target.read_text(encoding="utf-8"))["mcp"]["autoclip"]
|
||||
assert entry["cwd"] == str(new_repo)
|
||||
paths = entry["environment"]["PYTHONPATH"].split(os.pathsep)
|
||||
assert paths[0] == str(new_repo)
|
||||
assert str(extra_modules) in paths
|
||||
assert entry["environment"]["AUTOCLIP_DATA_DIR"] == str(tmp_path / "clips")
|
||||
process = subprocess.run(
|
||||
entry["command"], cwd=entry["cwd"], env=dict(os.environ, **entry["environment"]),
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
assert process.returncode == 0, process.stderr
|
||||
assert process.stdout.strip() == "current-checkout"
|
||||
assert opencode_setup.install_opencode(target, server=new_server)["action"] == "unchanged"
|
||||
+16
-2
@@ -5,7 +5,7 @@
|
||||
| 形态 | 一句话 | 入口 |
|
||||
|---|---|---|
|
||||
| CLI | `autoclip run video.mp4 --provider ollama` 一条命令出片 | `backend/cli.py` |
|
||||
| MCP server | 让 Cursor / Claude Code / 任何 MCP 客户端直接调 AutoClip | `backend/mcp_server.py` |
|
||||
| MCP server | 让 opencode / Cursor / Claude Code / 任何 MCP 客户端直接调 AutoClip | `backend/mcp_server.py` |
|
||||
| 本地模型预设 | 设置页 / CLI 直接选 Ollama、LM Studio,不用填 key | `backend/core/local_presets.py` |
|
||||
|
||||
共享逻辑在 `backend/services/local_runner.py`:不起 FastAPI / Celery,在当前进程里跑 `SimplePipelineAdapter`,
|
||||
@@ -114,6 +114,20 @@ claude mcp add autoclip -- /path/to/autoclip/venv/bin/autoclip mcp
|
||||
没装包时把 `command` 换成 `/path/to/autoclip/venv/bin/python`,`args` 为 `["-m", "backend.mcp_server"]`,
|
||||
并加 `"env": {"PYTHONPATH": "/path/to/autoclip"}`。
|
||||
|
||||
**OpenCode**(`~/.config/opencode/opencode.json`,或项目里的 `opencode.json`;带注释的 `.jsonc` 也支持):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcp": {
|
||||
"autoclip": { "type": "local", "command": ["/path/to/autoclip/venv/bin/autoclip", "mcp"], "enabled": true }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
也可以让 AutoClip 自己写:`autoclip mcp install opencode`(`--scope project --dir .` 写项目配置、`--print` 只打印片段、
|
||||
`--force` 在现有配置带注释时先备份再重写)。重装会保留同名条目里的 `environment` / `timeout` 等自定义字段;存在
|
||||
`opencode.jsonc` 时优先写它,两份配置冲突会在写入前直接拒绝。接入后在 opencode 里给一个视频路径即可出片,详见 `docs/OPENCODE.md`。
|
||||
|
||||
工具:
|
||||
|
||||
| 工具 | 说明 |
|
||||
@@ -135,7 +149,7 @@ claude mcp add autoclip -- /path/to/autoclip/venv/bin/autoclip mcp
|
||||
- 任务状态在内存里;server 重启后 `get_job_status` 会退回从磁盘读项目结果。
|
||||
|
||||
**Agent skill**:`skills/autoclip/SKILL.md` 教 agent 何时用哪个工具、参数怎么选、结果怎么呈现、切片为 0 怎么办。
|
||||
复制到 `~/.cursor/skills/autoclip/` 或 `~/.claude/skills/autoclip/` 即生效。
|
||||
复制到 `~/.cursor/skills/autoclip/`、`~/.claude/skills/autoclip/` 或 `~/.config/opencode/skills/autoclip/` 即生效。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
# Connect AutoClip to opencode CLI (MCP)
|
||||
|
||||
[opencode](https://opencode.ai) is a terminal AI agent. Register AutoClip as a local MCP server and you can
|
||||
point opencode at a video path — it will call AutoClip to clip highlights, export vertical videos, and publish.
|
||||
|
||||
| Goal | How |
|
||||
|---|---|
|
||||
| One-command setup (recommended) | `autoclip mcp install opencode` |
|
||||
| Project-only setup | `autoclip mcp install opencode --scope project --dir <dir>` |
|
||||
| Print the snippet only | `autoclip mcp install opencode --print` |
|
||||
| Rewrite a commented (JSONC) config | add `--force` (backup written first) |
|
||||
|
||||
## 1. Prerequisites
|
||||
|
||||
- AutoClip CLI installed (`pip install -r requirements.txt && pip install -e .`) — see the CLI / MCP guide;
|
||||
running from the repo (`python -m backend.mcp_server`) also works
|
||||
- `ffmpeg` on PATH and a working model: `autoclip doctor`
|
||||
- opencode installed (`opencode --version`)
|
||||
|
||||
## 2. One-command setup
|
||||
|
||||
```bash
|
||||
autoclip mcp install opencode
|
||||
# ✓ wrote ~/.config/opencode/opencode.json (Windows: %USERPROFILE%\.config\opencode\opencode.json)
|
||||
```
|
||||
|
||||
The command detects the launch command (`autoclip` → `autoclip-mcp` → `python -m backend.mcp_server`),
|
||||
merges `mcp.autoclip` into your config without touching other keys, and backs the old file up as
|
||||
`opencode.json.bak`. Re-running keeps your custom fields on an existing `autoclip` entry while updating
|
||||
`type` / `command`. The module fallback refreshes `cwd` and prepends the current installation to `PYTHONPATH`,
|
||||
preserving other module paths, environment variables, `timeout`, and `enabled`.
|
||||
If `opencode.jsonc` exists it becomes the target; when both `opencode.json` and `opencode.jsonc` exist
|
||||
and the jsonc already defines `mcp`, the installer refuses with an explicit message instead of writing
|
||||
a config that would not take effect. Commented (JSONC) configs are left untouched unless you pass `--force`.
|
||||
|
||||
| Option | Effect |
|
||||
|---|---|
|
||||
| `--scope global` (default) | `~/.config/opencode/opencode.json` |
|
||||
| `--scope project --dir <dir>` | `<dir>/opencode.json` (project-only) |
|
||||
| `--print` / `--json` | print the snippet / machine-readable report |
|
||||
|
||||
`OPENCODE_CONFIG` selects the exact global file to write, without switching to a sibling `.jsonc`;
|
||||
`XDG_CONFIG_HOME` is honored.
|
||||
|
||||
## 3. Manual config
|
||||
|
||||
`opencode.json` (or `opencode.jsonc`):
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"mcp": {
|
||||
"autoclip": {
|
||||
"type": "local",
|
||||
"command": ["C:\\Users\\<you>\\autoclip\\venv\\Scripts\\autoclip.exe", "mcp"],
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- macOS / Linux: `"command": ["/path/to/autoclip/venv/bin/autoclip", "mcp"]`
|
||||
- Running from the repo without installing:
|
||||
`"command": ["<python>", "-m", "backend.mcp_server"], "cwd": "<repo>", "environment": {"PYTHONPATH": "<repo>"}`
|
||||
|
||||
opencode merges global and project configs; a project entry with the same name overrides the global one.
|
||||
|
||||
## 4. Use it
|
||||
|
||||
Restart opencode (or start a new session) so it loads the MCP server, then just ask:
|
||||
|
||||
```text
|
||||
Clip D:\Videos\lecture.mp4
|
||||
Clip C:\Users\me\Downloads\podcast.mp4 using ollama
|
||||
Export clip 2 of that project as Shorts
|
||||
```
|
||||
|
||||
opencode calls `check_environment` and `clip_video` (or `start_clip_job` + `get_job_status` for long videos)
|
||||
and reports clips by score, time range, and title. Output shares the same data directory as the desktop app.
|
||||
|
||||
## 5. Agent skill (optional)
|
||||
|
||||
Copy `skills/autoclip/` to `~/.config/opencode/skills/autoclip/` (or `.opencode/skills/` inside a project).
|
||||
opencode also reads `~/.claude/skills/`, so Claude Code installs are picked up automatically.
|
||||
|
||||
## 6. Troubleshooting
|
||||
|
||||
| Symptom | Fix |
|
||||
|---|---|
|
||||
| No `autoclip` tools in opencode | Run with `--print`, verify the target path, keep only one `opencode.json` / `.jsonc`, start a new session |
|
||||
| Tools listed but calls fail | Run `autoclip doctor` (ffmpeg / Whisper / model key) |
|
||||
| Zero clips | Retry with `min_score=0.5`; pure music / no dialogue has no highlights |
|
||||
| First run is slow | Local Whisper transcription downloads a model; pass an SRT to skip it |
|
||||
|
||||
Links: [CLI / MCP guide (Chinese)](CLI_AND_MCP.md) · [opencode MCP docs](https://opencode.ai/docs/mcp-servers/) · [opencode skills docs](https://opencode.ai/docs/skills/)
|
||||
@@ -0,0 +1,103 @@
|
||||
# 接入 opencode CLI(AutoClip MCP)
|
||||
|
||||
[opencode](https://opencode.ai) 是终端里的 AI agent。把 AutoClip 作为本地 MCP server 接进去之后,
|
||||
你在 opencode 里给出一个视频路径,它就会调 AutoClip 出片——切高光、看结果、导 9:16、发平台,
|
||||
不用自己拼 `autoclip` 参数。
|
||||
|
||||
| 你想做的 | 怎么做 |
|
||||
|---|---|
|
||||
| 一条命令接入(推荐) | `autoclip mcp install opencode` |
|
||||
| 写进某个项目 | `autoclip mcp install opencode --scope project --dir <项目目录>` |
|
||||
| 只看配置片段、不改文件 | `autoclip mcp install opencode --print` |
|
||||
| 现有配置带注释,确认要重写 | 加 `--force`(先备份为 `opencode.json.bak`) |
|
||||
|
||||
## 1. 前置条件
|
||||
|
||||
- AutoClip CLI 装好(MCP server 与 CLI 同源):`pip install -r requirements.txt && pip install -e .`,
|
||||
见 [CLI / MCP 指南](CLI_AND_MCP.md) 第 1 节;不装包也能用仓库里的 `python -m backend.mcp_server`
|
||||
- `ffmpeg` 在 PATH、模型能连上:`autoclip doctor` 检查(云端 key,或本地 Ollama / LM Studio)
|
||||
- opencode 已安装(`opencode --version`)
|
||||
|
||||
## 2. 一键接入
|
||||
|
||||
```bash
|
||||
autoclip mcp install opencode
|
||||
# ✓ 已写入 ~/.config/opencode/opencode.json(Windows: %USERPROFILE%\.config\opencode\opencode.json)
|
||||
```
|
||||
|
||||
命令做的事:
|
||||
|
||||
- 按 `autoclip` → `autoclip-mcp` → `python -m backend.mcp_server` 的顺序探测启动命令(结果在输出的 `entry` 里)
|
||||
- 把 `mcp.autoclip` **合并**写进配置(`type: local`),其它键原样保留;文件已存在时先备份 `opencode.json.bak`
|
||||
- 重装**不会丢同名条目里的自定义字段**:更新 `type` / `command`;模块回退时刷新 `cwd`,
|
||||
并把当前安装路径放到 `PYTHONPATH` 最前面,保留其余模块路径、环境变量、`timeout` 和 `enabled=false`(后者会给出提示)
|
||||
- 优先识别已有配置文件:只有 `opencode.jsonc` 时写它;`opencode.json` 和 `opencode.jsonc` 同时存在且 jsonc 里已有 `mcp` 段时,
|
||||
**写入前直接拒绝**(jsonc 覆盖 json,避免“写入成功但实际加载旧配置”)
|
||||
- 配置带注释 / 尾随逗号(JSONC,opencode 支持)时默认**不动文件**、只打印片段;加 `--force` 才会先备份再重写成纯 JSON
|
||||
|
||||
| 参数 | 作用 |
|
||||
|---|---|
|
||||
| `--scope global`(默认) | 全局配置 `~/.config/opencode/opencode.json` |
|
||||
| `--scope project --dir <目录>` | 只在这个项目生效:`<目录>/opencode.json` |
|
||||
| `--name <名字>` | 配置里的服务名(默认 `autoclip`;opencode 里工具会带 `autoclip_` 前缀) |
|
||||
| `--print` / `--json` | 只打印片段 / 输出机器可读报告 |
|
||||
|
||||
环境变量:设了 `OPENCODE_CONFIG` 就严格写它指向的文件,不改写同目录的 `.jsonc`;设了 `XDG_CONFIG_HOME` 用 `$XDG_CONFIG_HOME/opencode/opencode.json`。
|
||||
|
||||
## 3. 手动配置
|
||||
|
||||
`opencode.json`(或 `opencode.jsonc`):
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"mcp": {
|
||||
"autoclip": {
|
||||
"type": "local",
|
||||
"command": ["C:\\Users\\<你>\\autoclip\\venv\\Scripts\\autoclip.exe", "mcp"],
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- macOS / Linux:`"command": ["/path/to/autoclip/venv/bin/autoclip", "mcp"]`
|
||||
- 没装包(在仓库里跑):`"command": ["<python 绝对路径>", "-m", "backend.mcp_server"], "cwd": "<仓库根目录>", "environment": {"PYTHONPATH": "<仓库根目录>"}`
|
||||
|
||||
opencode 会合并多处配置(全局 + 项目),项目里的同名 server 覆盖全局。
|
||||
|
||||
## 4. 在 opencode 里出片
|
||||
|
||||
重启 opencode(或新开会话)让它加载 MCP,然后直接说:
|
||||
|
||||
```text
|
||||
把 D:\Videos\lecture.mp4 切片 # 用桌面应用里配好的模型
|
||||
把 C:\Users\me\Downloads\podcast.mp4 切片,用 ollama # 本地模型,不用 key
|
||||
刚才那个项目第 2 条切片导成 Shorts
|
||||
```
|
||||
|
||||
opencode 会依次调 `check_environment`、`clip_video`(长视频走 `start_clip_job` + `get_job_status` 轮询),
|
||||
按 **评分 · 时间段 · 标题** 把结果整理给你。产物和桌面应用共用一个数据目录,桌面端首页能直接看到。
|
||||
|
||||
## 5. Agent skill(可选,建议装)
|
||||
|
||||
`skills/autoclip/SKILL.md` 教 agent 何时用哪个工具、`min_score` 怎么调、结果怎么呈现:
|
||||
|
||||
```bash
|
||||
cp -r skills/autoclip ~/.config/opencode/skills/autoclip # 全局(Windows 放 %USERPROFILE%\.config\opencode\skills\)
|
||||
```
|
||||
|
||||
只在一个项目里生效,就放 `.opencode/skills/autoclip/`。opencode 也会读 `~/.claude/skills/`,给 Claude Code 装过的无需重复。
|
||||
|
||||
## 6. 排错
|
||||
|
||||
| 现象 | 处理 |
|
||||
|---|---|
|
||||
| opencode 里没有 `autoclip` 工具 | 用 `--print` 确认目标文件路径;同目录只留一份 `opencode.json` / `.jsonc`;新开会话再试 |
|
||||
| 报「jsonc 里已有 mcp 段」 | `opencode.jsonc` 覆盖 `opencode.json`:只保留一份配置,或手动把片段合并进 jsonc |
|
||||
| 工具能列出但一调就报错 | 先在终端 `autoclip doctor`:多半是 ffmpeg 不在 PATH、Whisper 没装(传 `srt_path` 可绕过)、模型没 key |
|
||||
| 切片数为 0 | 让 opencode 用 `min_score=0.5` 重试;纯音乐 / 无对话的视频本身不适合切高光 |
|
||||
| 首次出片特别慢 | 无字幕时本地 Whisper 转写,首次要下模型;已有 SRT 就传进去,或改用 `ollama` |
|
||||
| MCP 协议被日志污染 | AutoClip 已把 stdout 留给协议(print 都进 stderr);自建包装脚本时别往 stdout 打日志 |
|
||||
|
||||
相关:[CLI / MCP 全量说明](CLI_AND_MCP.md) · [opencode MCP 文档](https://opencode.ai/docs/mcp-servers/) · [opencode skills 文档](https://opencode.ai/docs/skills/)
|
||||
@@ -24,6 +24,7 @@
|
||||
- [源码 Web 服务启动](../STARTUP_GUIDE.md)
|
||||
|
||||
- [CLI、MCP 与本地模型](CLI_AND_MCP.md)(中文)
|
||||
- [opencode CLI 接入(MCP 一键注册)](OPENCODE.md)(中文 · [English](OPENCODE.en.md))
|
||||
- [AI 模型与本地转写配置](AI_MODEL_CONFIGURATION.md)(中文)
|
||||
- [贡献指南](../CONTRIBUTING.md)(中文)
|
||||
- [构建指南](../BUILD_GUIDE.md)(中文)
|
||||
|
||||
Reference in New Issue
Block a user