Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@
"en": "Records WebM through ZCode's built-in Browser Use WebView and converts it to MP4 with ffmpeg for video/URL replication; no Playwright or external Chromium required.",
"zh-CN": "基于 ZCode 内置 Browser Use WebView 录制 WebM 并用 ffmpeg 转成 MP4/URL 复刻;无需 Playwright 或外部 Chromium"
},
"version": "0.6.0",
"version": "0.6.1",
"author": {
"name": "Z.ai",
"url": "https://z.ai"
Expand Down
8 changes: 5 additions & 3 deletions plugins/video2code/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "video2code",
"version": "0.6.0",
"version": "0.6.1",
"description": "基于 ZCode 内置 Browser Use WebView 录制 WebM 并用 ffmpeg 转成 MP4/URL 复刻;无需 Playwright 或外部 Chromium",
"author": {
"name": "Z.ai",
Expand Down Expand Up @@ -34,8 +34,9 @@
"mcpServers": {
"runtime": {
"type": "stdio",
"command": "python3",
"command": "node",
"args": [
"${CLAUDE_PLUGIN_ROOT}/hooks/run_python.mjs",
"${CLAUDE_PLUGIN_ROOT}/mcp/runtime_server.py"
],
"cwd": "${CLAUDE_PROJECT_DIR}",
Expand All @@ -47,8 +48,9 @@
},
"video": {
"type": "stdio",
"command": "python3",
"command": "node",
"args": [
"${CLAUDE_PLUGIN_ROOT}/hooks/run_python.mjs",
"${CLAUDE_PLUGIN_ROOT}/mcp/video_server.py"
],
"cwd": "${CLAUDE_PROJECT_DIR}",
Expand Down
14 changes: 10 additions & 4 deletions plugins/video2code/.mcp.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@
"mcpServers": {
"runtime": {
"type": "stdio",
"command": "python3",
"args": ["${CLAUDE_PLUGIN_ROOT}/mcp/runtime_server.py"],
"command": "node",
"args": [
"${CLAUDE_PLUGIN_ROOT}/hooks/run_python.mjs",
"${CLAUDE_PLUGIN_ROOT}/mcp/runtime_server.py"
],
"cwd": "${CLAUDE_PROJECT_DIR}",
"env": {
"V2C_PLUGIN_ROOT": "${CLAUDE_PLUGIN_ROOT}",
Expand All @@ -13,8 +16,11 @@
},
"video": {
"type": "stdio",
"command": "python3",
"args": ["${CLAUDE_PLUGIN_ROOT}/mcp/video_server.py"],
"command": "node",
"args": [
"${CLAUDE_PLUGIN_ROOT}/hooks/run_python.mjs",
"${CLAUDE_PLUGIN_ROOT}/mcp/video_server.py"
],
"cwd": "${CLAUDE_PROJECT_DIR}",
"env": {
"V2C_PLUGIN_ROOT": "${CLAUDE_PLUGIN_ROOT}",
Expand Down
8 changes: 5 additions & 3 deletions plugins/video2code/.zcode-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "video2code",
"version": "0.6.0",
"version": "0.6.1",
"description": "基于 ZCode 内置 Browser Use WebView 录制 WebM 并用 ffmpeg 转成 MP4/URL 复刻;无需 Playwright 或外部 Chromium",
"author": {
"name": "Z.ai",
Expand Down Expand Up @@ -34,8 +34,9 @@
"mcpServers": {
"runtime": {
"type": "stdio",
"command": "python3",
"command": "node",
"args": [
"${CLAUDE_PLUGIN_ROOT}/hooks/run_python.mjs",
"${CLAUDE_PLUGIN_ROOT}/mcp/runtime_server.py"
],
"cwd": "${CLAUDE_PROJECT_DIR}",
Expand All @@ -47,8 +48,9 @@
},
"video": {
"type": "stdio",
"command": "python3",
"command": "node",
"args": [
"${CLAUDE_PLUGIN_ROOT}/hooks/run_python.mjs",
"${CLAUDE_PLUGIN_ROOT}/mcp/video_server.py"
],
"cwd": "${CLAUDE_PROJECT_DIR}",
Expand Down
16 changes: 12 additions & 4 deletions plugins/video2code/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,13 @@ Two stdio MCP servers, declared in both [`.mcp.json`](./.mcp.json) and the plugi

## Hooks

All four are `command` hooks running `python3` against scripts in [`hooks/`](./hooks).
All four are `command` hooks. They and both MCP servers use the bundled
[`run_python.mjs`](./hooks/run_python.mjs) launcher instead of assuming the host
provides a `python3` executable. On Windows it tries `py -3`, `python`, then
`python3`; on macOS and Linux it tries `python3`, then `python`. The launcher
preserves the selected interpreter's exit status and defaults Python stdio to
UTF-8 so localized Hook output is not limited by the Windows console code page.
Existing `PYTHONUTF8` and `PYTHONIOENCODING` values are respected.

| Event | Script | Behavior |
| --- | --- | --- |
Expand All @@ -90,18 +96,20 @@ All four are `command` hooks running `python3` against scripts in [`hooks/`](./h
Run the doctor rather than checking by hand:

```bash
python3 skills/env-setup/scripts/env_doctor.py
python3 skills/env-setup/scripts/env_doctor.py --fix
node hooks/run_python.mjs skills/env-setup/scripts/env_doctor.py
node hooks/run_python.mjs skills/env-setup/scripts/env_doctor.py --fix
```

The launcher skips interpreters older than Python 3.10. Run the doctor in the same environment as ZCode: GUI and terminal `PATH` values may select different interpreters. Python repairs install into the selected virtual environment, or the selected interpreter's user site outside a virtual environment; externally managed installations such as Homebrew additionally require pip's `--break-system-packages` flag, applied only together with `--user`.

## Side effects, network access, and data

Enabling this plugin grants code-execution trust. Concretely, it will:

- **Execute commands** — `ffmpeg`/`ffprobe` for transcoding and frame extraction, and `skills/web-replicate/scripts/init-webapp.sh`, which runs `npm install` to populate `node_modules`.
- **Write files** — `recordings/` (WebM and MP4), `app/` (the scaffolded project), `out/` (`plan.md`, `verify.jsonl`, `report.md`, comparison images under `out/cmp/`), `.v2c/` (plugin root pointer and hook state, including `.v2c/hook_state/interceptions.jsonl`), and a `node_modules` cache under `/tmp/webapp-node-modules` (override with `NM_LOCAL_ROOT`).
- **Bind a local port** — `deploy_website` starts a `http.server` on port 8765 or the next free port, bound locally, and stops it when the MCP server process exits.
- **Access the network** — the npm registry when installing template dependencies, and whatever site you ask it to record.
- **Access the network** — the configured Python package index when the doctor installs Python dependencies, the npm registry when installing template dependencies, and whatever site you ask it to record.
- **Send no telemetry** and require no API key, token, or account. There are no credentials in this plugin and nothing is uploaded anywhere.

## User configuration
Expand Down
15 changes: 11 additions & 4 deletions plugins/video2code/README_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,12 @@

## Hooks

四个都是 `command` 类型,用 `python3` 执行 [`hooks/`](./hooks) 下的脚本。
四个都是 `command` 类型。它们和两个 MCP 服务都通过插件自带的
[`run_python.mjs`](./hooks/run_python.mjs) 启动 Python,不再假设宿主一定提供
`python3` 命令。Windows 依次尝试 `py -3`、`python`、`python3`,macOS 和 Linux
依次尝试 `python3`、`python`;选中解释器后会原样传递其退出码,并默认把 Python
标准输入输出设为 UTF-8,避免中文 Hook 输出受 Windows 控制台代码页限制。调用方已经
设置的 `PYTHONUTF8` 或 `PYTHONIOENCODING` 会被保留。

| 事件 | 脚本 | 行为 |
| --- | --- | --- |
Expand All @@ -90,18 +95,20 @@
不要手工逐项确认,直接跑体检脚本:

```bash
python3 skills/env-setup/scripts/env_doctor.py
python3 skills/env-setup/scripts/env_doctor.py --fix
node hooks/run_python.mjs skills/env-setup/scripts/env_doctor.py
node hooks/run_python.mjs skills/env-setup/scripts/env_doctor.py --fix
```

启动器会跳过低于 Python 3.10 的解释器。请在与 ZCode 相同的环境中运行体检:GUI 和终端的 `PATH` 可能选中不同解释器。Python 修复会安装到选中的虚拟环境;不在虚拟环境时则安装到该解释器的用户目录。Homebrew 等受外部管理的环境还需要 pip 的 `--break-system-packages` 参数,脚本仅在同时使用 `--user` 时添加它。

## 副作用、网络访问与数据

启用本插件等于授予代码执行信任。具体地,它会:

- **执行命令** —— `ffmpeg`/`ffprobe` 做转码与抽帧;`skills/web-replicate/scripts/init-webapp.sh` 会执行 `npm install` 拉起 `node_modules`。
- **写文件** —— `recordings/`(WebM 与 MP4)、`app/`(脚手架项目)、`out/`(`plan.md`、`verify.jsonl`、`report.md`,以及 `out/cmp/` 下的对照图)、`.v2c/`(插件根路径指针与 hook 状态,含 `.v2c/hook_state/interceptions.jsonl`),以及 `/tmp/webapp-node-modules` 下的 `node_modules` 缓存(可用 `NM_LOCAL_ROOT` 改)。
- **监听本地端口** —— `deploy_website` 在 8765 或之后第一个空闲端口起 `http.server`,只监听本地,MCP 服务进程退出时统一清理。
- **访问网络** —— 安装模板依赖时访问 npm registry;以及你让它录制的目标网站。
- **访问网络** —— 体检脚本安装 Python 依赖时访问配置的 Python 包索引;安装模板依赖时访问 npm registry;以及你让它录制的目标网站。
- **不上报任何遥测**,不需要 API key、token 或账号。插件里没有任何凭据,也不会把内容上传到任何地方。

## 用户配置
Expand Down
5 changes: 3 additions & 2 deletions plugins/video2code/hooks/check_closeout.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,8 +127,9 @@ def main() -> None:
allow() # 三振/会话预算用尽/已顶回过 → 放行并已记录, 防死循环
block_stop(
"复刻契约未闭环, 不能收尾:\n- " + "\n- ".join(res["gaps"]) +
"\n先自己跑审计脚本对账: python3 <plugin_root>/skills/video2code/scripts/"
"contract_audit.py (plugin_root 见会话开头横幅, 兜底 cat .v2c/plugin_root), "
"\n先自己跑审计脚本对账: node \"<plugin_root>/hooks/run_python.mjs\" "
"\"<plugin_root>/skills/video2code/scripts/contract_audit.py\" "
"(plugin_root 见会话开头横幅, 兜底 cat .v2c/plugin_root), "
"按缺口逐条补完 (D 项成对证据用 composite_view 的 beats 视频输入一轮即出), "
"全绿后再收尾。\n"
"若用户本轮要的其实不是这个复刻任务 (契约是本会话早前认领的, 但用户已经转去别的事, "
Expand Down
10 changes: 5 additions & 5 deletions plugins/video2code/hooks/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"hooks": [
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/env_check.py\"",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/run_python.mjs\" \"${CLAUDE_PLUGIN_ROOT}/hooks/env_check.py\"",
"timeout": 30,
"statusMessage": "video2code 环境检查…"
}
Expand All @@ -18,12 +18,12 @@
"hooks": [
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/check_video_input.py\"",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/run_python.mjs\" \"${CLAUDE_PLUGIN_ROOT}/hooks/check_video_input.py\"",
"timeout": 15
},
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/check_url_input.py\"",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/run_python.mjs\" \"${CLAUDE_PLUGIN_ROOT}/hooks/check_url_input.py\"",
"timeout": 10
}
]
Expand All @@ -35,7 +35,7 @@
"hooks": [
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/check_plan_first.py\"",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/run_python.mjs\" \"${CLAUDE_PLUGIN_ROOT}/hooks/check_plan_first.py\"",
"timeout": 10
}
]
Expand All @@ -46,7 +46,7 @@
"hooks": [
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/check_closeout.py\"",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/run_python.mjs\" \"${CLAUDE_PLUGIN_ROOT}/hooks/check_closeout.py\"",
"timeout": 15
}
]
Expand Down
63 changes: 63 additions & 0 deletions plugins/video2code/hooks/run_python.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
#!/usr/bin/env node
/** Run a plugin Python entrypoint with platform-appropriate interpreter names. */

import { spawnSync } from "node:child_process";

const [script, ...scriptArgs] = process.argv.slice(2);

if (!script) {
process.stderr.write("Usage: run_python.mjs <script> [...args]\n");
process.exit(2);
}

const candidates =
process.platform === "win32"
? [
["py", ["-3"]],
["python", []],
["python3", []],
]
: [
["python3", []],
["python", []],
];

const childEnv = { ...process.env };
if (childEnv.PYTHONUTF8 === undefined) childEnv.PYTHONUTF8 = "1";
if (childEnv.PYTHONIOENCODING === undefined) childEnv.PYTHONIOENCODING = "utf-8";

for (const [command, prefixArgs] of candidates) {
const probe = spawnSync(
command,
[
...prefixArgs,
"-c",
"import sys; raise SystemExit(0 if sys.version_info >= (3, 10) else 1)",
],
{ env: childEnv, stdio: "ignore" },
);

if (probe.error?.code === "ENOENT") continue;
if (probe.error || probe.status !== 0) continue;

const result = spawnSync(command, [...prefixArgs, script, ...scriptArgs], {
env: childEnv,
stdio: "inherit",
});

if (result.error) {
process.stderr.write(`[video2code] failed to start ${command}: ${result.error}\n`);
process.exit(1);
}
if (result.signal) {
process.stderr.write(`[video2code] ${command} exited via ${result.signal}\n`);
process.exit(1);
}

process.exit(result.status ?? 1);
}

process.stderr.write(
"[video2code] No compatible Python interpreter was found; install Python 3.10+ and retry.\n",
);
process.exit(127);
3 changes: 2 additions & 1 deletion plugins/video2code/mcp/v2c_tools/deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import shutil
import socket
import subprocess
import sys
import time
import uuid
from pathlib import Path
Expand Down Expand Up @@ -88,7 +89,7 @@ def _last_server(ctx: RunContext) -> dict | None:
def _spawn_httpd(dst: Path, port: int) -> subprocess.Popen:
"""起 http.server (allow_reuse_address 默认开, 原端口进程死后可立即重绑)。"""
proc = subprocess.Popen(
["python3", "-m", "http.server", str(port), "--bind", "127.0.0.1"],
[sys.executable, "-m", "http.server", str(port), "--bind", "127.0.0.1"],
cwd=str(dst),
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
Expand Down
4 changes: 2 additions & 2 deletions plugins/video2code/skills/env-setup/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ description: "Check and provision the video2code plugin environment: Python/MCP/
唯一入口:

```bash
python3 <plugin_root>/skills/env-setup/scripts/env_doctor.py
python3 <plugin_root>/skills/env-setup/scripts/env_doctor.py --fix
node "<plugin_root>/hooks/run_python.mjs" "<plugin_root>/skills/env-setup/scripts/env_doctor.py"
node "<plugin_root>/hooks/run_python.mjs" "<plugin_root>/skills/env-setup/scripts/env_doctor.py" --fix
```

插件根路径由 SessionStart 输出,并写入 `.v2c/plugin_root`。完整探测给 Bash ≥120s。
Expand Down
23 changes: 18 additions & 5 deletions plugins/video2code/skills/env-setup/scripts/env_doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,16 @@

import argparse
import hashlib
import importlib
import importlib.util
import importlib.metadata as importlib_metadata
import json
import os
import site
import shutil
import subprocess
import sys
import sysconfig
from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable
Expand All @@ -42,9 +45,14 @@
WEB_SCRIPTS = PLUGIN_ROOT / "skills" / "web-replicate" / "scripts"
TPL_DIR = PLUGIN_ROOT / "skills" / "web-replicate" / "templates" / "default" / "template"
NM_LOCAL_ROOT = Path(os.environ.get("NM_LOCAL_ROOT", "/tmp/webapp-node-modules"))
PY = sys.executable or "python3"
PY = sys.executable or ("python" if os.name == "nt" else "python3")
# 命令行里用 PYQ: Windows 解释器路径常含空格, do_fix 又走 shell=True
PYQ = f'"{PY}"' if " " in PY else PY
PIP_INSTALL = f"{PYQ} -m pip install"
if sys.prefix == sys.base_prefix:
PIP_INSTALL += " --user"
if (Path(sysconfig.get_path("stdlib")) / "EXTERNALLY-MANAGED").is_file():
PIP_INSTALL += " --break-system-packages"

OS = ("macos" if sys.platform == "darwin"
else "windows" if os.name == "nt" else "linux")
Expand Down Expand Up @@ -231,7 +239,7 @@ def probe_nm_master() -> tuple[bool, str]:
]

CHECKS: list[Check] = [
Check("py", "python3 ≥ 3.10", "hook 与 MCP server 用了 3.10+ 语法 (X | Y 注解)",
Check("py", "Python ≥ 3.10", "hook 与 MCP server 用了 3.10+ 语法 (X | Y 注解)",
probe_py_version,
need="Python 3.10 或更高 (CC 调 hook/MCP 用的就是这个解释器)",
fix=by_os(any=["# 换一个 3.10+ 解释器, 例如 conda create -n v2c python=3.12"]),
Expand All @@ -240,11 +248,11 @@ def probe_nm_master() -> tuple[bool, str]:
"MCP server 本体 — 缺少或装到 2.x 会导致 deploy/clip_video 工具不存在",
probe_mcp_sdk,
need=f"pip 包 mcp=={MCP_SDK_VERSION} (2.x 已删除本插件使用的 Server decorator API)",
fix=[f"{PYQ} -m pip install --user --break-system-packages mcp=={MCP_SDK_VERSION}"],
fix=[f"{PIP_INSTALL} mcp=={MCP_SDK_VERSION}"],
cheap=True, auto=True, needs="py"),
*[Check(f"py-{mod}", f"python 包: {mod}", why, probe_module(mod),
need=f"pip 包 {pkg} (装进上面那个解释器, 不是系统里随便哪个 python)",
fix=[f"{PYQ} -m pip install {pkg}"], cheap=True, auto=True, needs="py")
fix=[f"{PIP_INSTALL} {pkg}"], cheap=True, auto=True, needs="py")
for mod, pkg, why in PY_MODULES],
Check("ffmpeg", "ffmpeg + ffprobe", "video MCP 摄入 / clip 抽帧 / 时长探测 / url2video 把录制的 WebM 转成 MP4(必需)",
probe_bins("ffmpeg", "ffprobe"),
Expand Down Expand Up @@ -364,7 +372,7 @@ def render(results: list[Result]) -> str:
for cmd in r.check.fix:
lines.append(f" {cmd}")
if any(r.check.auto for r in bad if r.status == "missing"):
lines.append(f"[env] 自动项可一把过: python3 {Path(__file__).name} --fix "
lines.append(f"[env] 自动项可一把过: {PYQ} {Path(__file__).name} --fix "
"(只跑上面标 自动可装 的命令)")
return "\n".join(lines)

Expand All @@ -390,6 +398,11 @@ def do_fix(results: list[Result]) -> list[Result]:
fixed.add(r.check.id)
if not fixed:
return results
user_site = site.getusersitepackages()
if (site.ENABLE_USER_SITE and isinstance(user_site, str)
and Path(user_site).is_dir() and user_site not in sys.path):
site.addsitedir(user_site)
importlib.invalidate_caches()
recheck = {res.check.id: res for res in run_checks(only=fixed)}
return [recheck.get(r.check.id, r) for r in results]

Expand Down
Loading