diff --git a/.gitignore b/.gitignore
index a7bdcfa..5c339eb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,9 @@
node_modules/
dist/
*.bak
+
+# python/ client package build artifacts
+python/dist/
+python/build/
+python/*.egg-info/
+__pycache__/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 896ebeb..b959d16 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,7 @@ All notable changes to capcut-cli are documented here. The format follows [Keep
- `doctor` reports what each draft store holds — for every default CapCut/JianYing project directory it finds (or the one folder named with the new `--drafts
`), a `draft-store` check counts the projects as readable, markerless, encrypted or unreadable. A JianYing 6.0+ store, where every project the app wrote is an encrypted payload, is now named once and up front (warn) with what still works — `init`, `quickstart` and `compile` build plaintext drafts from the bundled template — instead of being discovered one failed command at a time. Same classification as the `template.store` report of `init`/`quickstart`/`compile`. `capcut doctor --drafts ` also makes the check usable on a machine without the app, and in CI.
- `examples/short-video-narration.md` (+ zh-CN) — silent clip → 9:16 draft with a TTS voiceover and script-accurate captions, as four commands (`quickstart --ratio 9:16` → `tts --text-file` → `caption --from-segment --script` → `lint`) and as one script, `examples/scripts/narrate-short.sh`. `examples/scripts/edge-tts-wav.sh` bridges edge-tts (MP3 only) to the WAV `tts` expects at `{out}`; any other engine plugs in through `--tts-cmd`. The vision-model step that writes the script is optional and stays outside the CLI: the script is a text file.
+- `python/` — a thin Python client, published to PyPI as `capcut` (`pip install capcut`). `capcut.run(cmd, *args, **flags)` spawns the CLI once without a shell and returns the JSON it prints; keyword arguments become flags (`font_size=16` → `--font-size 16`), positionals pass through as single argv tokens, a non-zero exit raises `CommandError` with `status` and the CLI's JSON. `capcut.serve(jobs)` feeds the stateless JSONL queue and returns one result per job. `capcut.describe()`, `capcut.doctor()`, `capcut.version()`. Pure Python, no dependencies, Python ≥ 3.9; the binary is found on PATH or through `CAPCUT_CLI`. Not part of the npm tarball.
## [0.25.0] — 2026-09-18
diff --git a/README.md b/README.md
index e3c2aff..d5d5485 100644
--- a/README.md
+++ b/README.md
@@ -32,6 +32,8 @@ https://github.com/user-attachments/assets/4e6ee99c-0745-4cfb-8e9b-ad873fb1259b
npm install -g capcut-cli
```
+From Python: `pip install capcut` wraps the same binary — `capcut.run("quickstart", "my-short", video="clip.mp4", ratio="9:16")` — see [python/README.md](https://github.com/renezander030/capcut-cli/blob/master/python/README.md).
+
```bash
capcut doctor
capcut quickstart my-first --video clip.mp4 --srt captions.srt
diff --git a/README.zh-CN.md b/README.zh-CN.md
index fbd9c24..ea3c3ce 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -30,6 +30,8 @@ https://github.com/user-attachments/assets/4e6ee99c-0745-4cfb-8e9b-ad873fb1259b
npm install -g capcut-cli
```
+在 Python 里用:`pip install capcut` 封装同一个命令行 —— `capcut.run("quickstart", "我的短视频", video="clip.mp4", ratio="9:16")`,见 [python/README.md](https://github.com/renezander030/capcut-cli/blob/master/python/README.md)。
+
```bash
capcut doctor
capcut quickstart my-first --video clip.mp4 --srt captions.srt
diff --git a/python/README.md b/python/README.md
new file mode 100644
index 0000000..7cdaae7
--- /dev/null
+++ b/python/README.md
@@ -0,0 +1,124 @@
+# capcut(Python 客户端) · capcut (Python client)
+
+中文 | [English](#english)
+
+用 Python 创建和编辑 CapCut / 剪映草稿。这是 [capcut-cli](https://github.com/renezander030/capcut-cli) 的一层薄封装:每次调用启动一次 `capcut` 命令,不经过 shell,返回它打印的那一份 JSON。没有服务、没有守护进程,磁盘上的草稿就是全部状态。打开剪映时,每一轨都还是可编辑的。
+
+## 安装
+
+```bash
+npm install -g capcut-cli # 命令行本体,需要 Node ≥ 18
+pip install capcut # 本包,纯 Python,无依赖
+capcut doctor # 检查环境
+```
+
+## 五行起步
+
+```python
+import capcut
+
+d = capcut.run("quickstart", "旁白短视频", video="clip.mp4", ratio="9:16")
+capcut.run("add-text", d["draft_path"], "0s", "3s", "你好,世界", font_size=16)
+print(capcut.run("lint", d["draft_path"])["summary"])
+```
+
+- **关键字参数就是命令行选项**:`font_size=16` → `--font-size 16`,`karaoke=True` → `--karaoke`,列表会重复该选项,`None` / `False` 直接省略。
+- **位置参数原样传递**,每个参数就是一个 argv,中文、空格、引号都不需要转义。
+- 全部命令、参数和选项见[命令参考(中文)](https://github.com/renezander030/capcut-cli/blob/master/docs/command-reference.zh-CN.md),或者在 Python 里 `capcut.describe()`。
+
+## 出错时
+
+命令非零退出会抛出 `capcut.CommandError`,带 `status`、`data`(CLI 打印的 JSON,通常含 `error`)、`stdout`、`stderr`:
+
+```python
+try:
+ capcut.run("lint", path)
+except capcut.CommandError as e:
+ print(e.status, e.data) # lint 有错误时退出码为 2
+```
+
+不想抛异常就用 `capcut.run_raw(...)`,它返回 `Result`(`ok`、`status`、`data`、`error`)。找不到 `capcut` 命令时抛 `capcut.CliNotFound`,提示里有安装命令;也可以用环境变量 `CAPCUT_CLI` 指定,例如 `CAPCUT_CLI="node /path/to/capcut-cli/dist/index.js"`。
+
+## 批量:`serve`
+
+`capcut serve` 是一个无状态的 JSONL 任务队列。从 Python 喂任务进去,拿回每个任务一条结果:
+
+```python
+results = capcut.serve([
+ capcut.Job("add-text", project=path, args=["8s", "2s", "关注我"], id="title"),
+ capcut.Job("lint", project=path),
+], workers=2)
+for r in results:
+ print(r["id"], r["ok"], r["status"], r["stdout"])
+```
+
+失败的任务是一条 `ok: false` 的结果,不是异常。
+
+## 剪映 6.0+ 用户
+
+新建的草稿是明文,据报告剪映 11.4(macOS)能打开并就地升级,其他版本未验证;已有的加密草稿本 CLI 不读取。`capcut.doctor()` 会报告环境,`capcut.run("decrypt", path)` 会报告某个草稿的加密状态;来龙去脉见 [jianying-encryption.zh-CN.md](https://github.com/renezander030/capcut-cli/blob/master/docs/jianying-encryption.zh-CN.md)。
+
+---
+
+## English
+
+Create and edit CapCut / JianYing drafts from Python. A thin layer over [capcut-cli](https://github.com/renezander030/capcut-cli): each call spawns the `capcut` binary once, without a shell, and returns the one JSON document it prints. No server, no daemon; the draft on disk is the only state, and every track stays editable in the app.
+
+### Install
+
+```bash
+npm install -g capcut-cli # the CLI itself, Node >= 18
+pip install capcut # this package, pure Python, no dependencies
+capcut doctor # environment check
+```
+
+### Five lines
+
+```python
+import capcut
+
+d = capcut.run("quickstart", "Narrated short", video="clip.mp4", ratio="9:16")
+capcut.run("add-text", d["draft_path"], "0s", "3s", "Hello, world", font_size=16)
+print(capcut.run("lint", d["draft_path"])["summary"])
+```
+
+- **Keyword arguments are flags**: `font_size=16` → `--font-size 16`, `karaoke=True` → `--karaoke`, a list repeats the flag, `None` / `False` are dropped.
+- **Positional arguments pass through as they are**, one argv token each: text with spaces or quotes never needs escaping.
+- Every command, argument and option: [command reference](https://github.com/renezander030/capcut-cli/blob/master/docs/command-reference.md), or `capcut.describe()` from Python.
+
+### Errors
+
+A non-zero exit raises `capcut.CommandError` with `status`, `data` (the CLI's JSON, usually with `error`), `stdout`, `stderr`:
+
+```python
+try:
+ capcut.run("lint", path)
+except capcut.CommandError as e:
+ print(e.status, e.data) # lint exits 2 on errors
+```
+
+`capcut.run_raw(...)` never raises; it returns a `Result` (`ok`, `status`, `data`, `error`). A missing binary raises `capcut.CliNotFound` with the install line; `CAPCUT_CLI` can point at one explicitly, e.g. `CAPCUT_CLI="node /path/to/capcut-cli/dist/index.js"`.
+
+### Batch: `serve`
+
+`capcut serve` is a stateless JSONL job queue. Feed it jobs from Python and get one result per job:
+
+```python
+results = capcut.serve([
+ capcut.Job("add-text", project=path, args=["8s", "2s", "Subscribe"], id="title"),
+ capcut.Job("lint", project=path),
+], workers=2)
+for r in results:
+ print(r["id"], r["ok"], r["status"], r["stdout"])
+```
+
+A failed job is a result with `ok: false`, not an exception.
+
+### Development
+
+```bash
+cd python && python -m unittest discover -s tests -v
+python -m build
+```
+
+MIT, same as capcut-cli.
diff --git a/python/capcut/__init__.py b/python/capcut/__init__.py
new file mode 100644
index 0000000..a15ec06
--- /dev/null
+++ b/python/capcut/__init__.py
@@ -0,0 +1,303 @@
+"""capcut — a thin Python client for capcut-cli, the CapCut / JianYing (剪映) draft CLI.
+
+Every call spawns the ``capcut`` binary once, without a shell, and returns the one
+JSON document it prints. Nothing runs in the background; the draft on disk is the
+only state.
+
+ import capcut
+ d = capcut.run("quickstart", "旁白短视频", video="clip.mp4", ratio="9:16")
+ capcut.run("add-text", d["draft_path"], "0s", "3s", "你好,世界", font_size=16)
+ print(capcut.run("lint", d["draft_path"])["summary"])
+
+Keyword arguments become flags: ``font_size=16`` → ``--font-size 16``, ``karaoke=True``
+→ ``--karaoke``, a list repeats the flag, ``None``/``False`` are dropped. Positional
+arguments are passed through as they are, each as one argv token, so text with spaces
+or quotes never needs escaping.
+
+The binary is found through ``CAPCUT_CLI`` (a command line, e.g.
+``"node /path/to/capcut-cli/dist/index.js"``) or ``capcut`` on PATH.
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import shlex
+import shutil
+import subprocess
+import tempfile
+from dataclasses import dataclass, field
+from typing import Any, Iterable, List, Mapping, Optional, Sequence, Union
+
+__version__ = "0.1.0"
+__all__ = [
+ "CapcutError",
+ "CliNotFound",
+ "CommandError",
+ "Job",
+ "Result",
+ "cli_command",
+ "describe",
+ "doctor",
+ "flag_args",
+ "run",
+ "run_raw",
+ "serve",
+ "version",
+]
+
+INSTALL_HINT = (
+ "capcut-cli not found.\n"
+ " 安装:npm install -g capcut-cli (需要 Node ≥ 18,之后运行 capcut doctor 检查环境)\n"
+ " Install: npm install -g capcut-cli (needs Node >= 18; then run `capcut doctor`)\n"
+ ' Or point CAPCUT_CLI at the binary, e.g. CAPCUT_CLI="node /path/to/capcut-cli/dist/index.js".'
+)
+
+
+class CapcutError(Exception):
+ """Base class for everything this module raises."""
+
+
+class CliNotFound(CapcutError):
+ """The ``capcut`` binary is not on PATH and ``CAPCUT_CLI`` is not set."""
+
+
+@dataclass
+class Result:
+ """One CLI invocation: exit status, raw streams, and the parsed JSON (``data``)."""
+
+ cmd: str
+ args: List[str]
+ status: int
+ stdout: str
+ stderr: str
+ data: Any = None
+
+ @property
+ def ok(self) -> bool:
+ return self.status == 0
+
+ @property
+ def error(self) -> Optional[str]:
+ """The CLI's own error message when it printed ``{"error": ...}``."""
+ if isinstance(self.data, dict) and isinstance(self.data.get("error"), str):
+ return self.data["error"]
+ return None
+
+
+class CommandError(CapcutError):
+ """The command exited non-zero. ``result`` carries status, streams and parsed JSON."""
+
+ def __init__(self, result: Result):
+ self.result = result
+ message = result.error or result.stderr.strip() or result.stdout.strip() or f"exit {result.status}"
+ super().__init__(f"capcut {result.cmd} failed (exit {result.status}): {message}")
+
+ @property
+ def status(self) -> int:
+ return self.result.status
+
+ @property
+ def data(self) -> Any:
+ return self.result.data
+
+ @property
+ def stdout(self) -> str:
+ return self.result.stdout
+
+ @property
+ def stderr(self) -> str:
+ return self.result.stderr
+
+
+def cli_command() -> List[str]:
+ """The argv prefix that runs capcut-cli: ``CAPCUT_CLI`` split like a shell would, else ``capcut`` on PATH."""
+ configured = os.environ.get("CAPCUT_CLI", "").strip()
+ if configured:
+ return shlex.split(configured, posix=(os.name != "nt"))
+ found = shutil.which("capcut")
+ if found:
+ return [found]
+ raise CliNotFound(INSTALL_HINT)
+
+
+def flag_args(flags: Mapping[str, Any]) -> List[str]:
+ """``{"font_size": 16, "karaoke": True, "tag": ["a", "b"]}`` → ``["--font-size", "16", "--karaoke", "--tag", "a", "--tag", "b"]``."""
+ out: List[str] = []
+ for key, value in flags.items():
+ flag = "--" + key.replace("_", "-")
+ if value is None or value is False:
+ continue
+ if value is True:
+ out.append(flag)
+ elif isinstance(value, (list, tuple)):
+ for item in value:
+ out.extend([flag, str(item)])
+ else:
+ out.extend([flag, str(value)])
+ return out
+
+
+def _spawn(argv: List[str], *, input: Optional[str], timeout: Optional[float], cwd: Optional[str]) -> "subprocess.CompletedProcess[str]":
+ """Run argv with stdout/stderr captured to temporary files, not pipes.
+
+ Large documents (``describe`` is >64 KiB) can be cut at the pipe buffer when the CLI
+ exits; the repository's own test helper captures to files for the same reason.
+ """
+ with tempfile.TemporaryFile(mode="w+", encoding="utf-8", errors="replace") as out, tempfile.TemporaryFile(
+ mode="w+", encoding="utf-8", errors="replace"
+ ) as err:
+ try:
+ proc = subprocess.run(
+ argv,
+ input=input,
+ stdout=out,
+ stderr=err,
+ text=True,
+ encoding="utf-8",
+ errors="replace",
+ timeout=timeout,
+ cwd=cwd,
+ )
+ except FileNotFoundError as exc:
+ raise CliNotFound(INSTALL_HINT) from exc
+ out.seek(0)
+ err.seek(0)
+ return subprocess.CompletedProcess(argv, proc.returncode, out.read(), err.read())
+
+
+def _parse(stdout: str) -> Any:
+ text = stdout.strip()
+ if not text:
+ return None
+ try:
+ return json.loads(text)
+ except ValueError:
+ pass
+ # Commands print one JSON document; if anything else reached stdout, the document is the last line.
+ last = text.splitlines()[-1].strip()
+ try:
+ return json.loads(last)
+ except ValueError:
+ return None
+
+
+def run_raw(
+ cmd: str,
+ *args: Any,
+ human: bool = False,
+ timeout: Optional[float] = None,
+ cwd: Optional[str] = None,
+ input: Optional[str] = None,
+ **flags: Any,
+) -> Result:
+ """Run one command and return the :class:`Result` whatever the exit status.
+
+ ``human=True`` appends ``-H`` and leaves ``data`` as ``None`` (the table is in ``stdout``).
+ """
+ argv_args = [str(a) for a in args] + flag_args(flags) + (["-H"] if human else [])
+ argv = cli_command() + [cmd] + argv_args
+ proc = _spawn(argv, input=input, timeout=timeout, cwd=cwd)
+ data = None if human else _parse(proc.stdout)
+ if data is None and proc.returncode != 0:
+ data = _parse(proc.stderr) # the CLI prints {"error": ...} on stderr
+ return Result(cmd=cmd, args=argv_args, status=proc.returncode, stdout=proc.stdout, stderr=proc.stderr, data=data)
+
+
+def run(cmd: str, *args: Any, **kwargs: Any) -> Any:
+ """Run one command; return its parsed JSON (or the ``-H`` text). Raise :class:`CommandError` on a non-zero exit."""
+ result = run_raw(cmd, *args, **kwargs)
+ if not result.ok:
+ raise CommandError(result)
+ return result.data if result.data is not None else result.stdout
+
+
+def version() -> str:
+ """The installed capcut-cli version, e.g. ``"0.25.0"``."""
+ result = run_raw("--version")
+ if not result.ok:
+ raise CommandError(result)
+ return result.stdout.strip()
+
+
+def describe() -> Any:
+ """The full command surface as JSON (``capcut describe``): names, usage, options, exit codes."""
+ return run("describe")
+
+
+def doctor(**flags: Any) -> Any:
+ """The ``capcut doctor`` report. Returned even when a hard requirement is missing (exit 1); check ``["ok"]``."""
+ result = run_raw("doctor", **flags)
+ if result.data is None:
+ raise CommandError(result)
+ return result.data
+
+
+@dataclass
+class Job:
+ """One line of the ``capcut serve`` JSONL queue."""
+
+ cmd: str
+ project: Optional[str] = None
+ args: Sequence[Any] = field(default_factory=list)
+ id: Optional[str] = None
+ retries: Optional[int] = None
+ timeout: Optional[int] = None
+
+ def to_dict(self) -> dict:
+ job: dict = {"cmd": self.cmd}
+ if self.project is not None:
+ job["project"] = self.project
+ if self.args:
+ job["args"] = [str(a) for a in self.args]
+ if self.id is not None:
+ job["id"] = self.id
+ if self.retries is not None:
+ job["retries"] = self.retries
+ if self.timeout is not None:
+ job["timeout"] = self.timeout
+ return job
+
+
+def serve(
+ jobs: Iterable[Union[Job, Mapping[str, Any]]],
+ *,
+ workers: Optional[int] = None,
+ fail_fast: bool = False,
+ retries: Optional[int] = None,
+ job_timeout_ms: Optional[int] = None,
+ backoff_ms: Optional[int] = None,
+ max_buffer_mb: Optional[int] = None,
+ timeout: Optional[float] = None,
+) -> List[dict]:
+ """Feed jobs to ``capcut serve`` over stdin and return one result dict per job.
+
+ Each result is ``{id, ok, cmd, args, status, stdout, stderr, attempts, duration_ms, deduplicated}``
+ with ``stdout`` already parsed. A failed job is a result with ``ok: false``, not an exception;
+ only a queue that produced no results at all raises :class:`CommandError`.
+ """
+ lines = [json.dumps(j.to_dict() if isinstance(j, Job) else dict(j), ensure_ascii=False) for j in jobs]
+ flags = flag_args(
+ {
+ "workers": workers,
+ "fail_fast": fail_fast,
+ "retries": retries,
+ "timeout": job_timeout_ms,
+ "backoff_ms": backoff_ms,
+ "max_buffer_mb": max_buffer_mb,
+ }
+ )
+ argv = cli_command() + ["serve"] + flags
+ proc = _spawn(argv, input="\n".join(lines) + "\n", timeout=timeout, cwd=None)
+ results: List[dict] = []
+ for line in proc.stdout.splitlines():
+ line = line.strip()
+ if not line:
+ continue
+ try:
+ results.append(json.loads(line))
+ except ValueError:
+ continue
+ if not results and proc.returncode != 0:
+ raise CommandError(Result(cmd="serve", args=flags, status=proc.returncode, stdout=proc.stdout, stderr=proc.stderr))
+ return results
diff --git a/python/pyproject.toml b/python/pyproject.toml
new file mode 100644
index 0000000..ab457a1
--- /dev/null
+++ b/python/pyproject.toml
@@ -0,0 +1,33 @@
+[build-system]
+requires = ["setuptools>=68"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "capcut"
+version = "0.1.0"
+description = "Python client for capcut-cli: create and edit CapCut / JianYing (剪映) drafts from Python. JSON in, JSON out, no server."
+readme = "README.md"
+requires-python = ">=3.9"
+license = { text = "MIT" }
+authors = [{ name = "Rene Zander" }]
+keywords = ["capcut", "jianying", "剪映", "video", "draft", "cli", "automation", "short-video"]
+classifiers = [
+ "Development Status :: 4 - Beta",
+ "Intended Audience :: Developers",
+ "License :: OSI Approved :: MIT License",
+ "Operating System :: OS Independent",
+ "Programming Language :: Python :: 3",
+ "Programming Language :: Python :: 3 :: Only",
+ "Topic :: Multimedia :: Video",
+ "Topic :: Software Development :: Libraries",
+]
+dependencies = []
+
+[project.urls]
+Homepage = "https://github.com/renezander030/capcut-cli"
+Documentation = "https://github.com/renezander030/capcut-cli/blob/master/python/README.md"
+Changelog = "https://github.com/renezander030/capcut-cli/blob/master/CHANGELOG.md"
+Issues = "https://github.com/renezander030/capcut-cli/issues"
+
+[tool.setuptools]
+packages = ["capcut"]
diff --git a/python/tests/fake_capcut.py b/python/tests/fake_capcut.py
new file mode 100644
index 0000000..5b54d14
--- /dev/null
+++ b/python/tests/fake_capcut.py
@@ -0,0 +1,53 @@
+#!/usr/bin/env python3
+"""Stand-in for the capcut binary: echoes what it was called with, as the real CLI would shape it."""
+
+import json
+import sys
+
+argv = sys.argv[1:]
+if argv == ["--version"]:
+ print("0.0.0-fake")
+ sys.exit(0)
+
+cmd = argv[0] if argv else ""
+rest = argv[1:]
+
+if cmd == "serve":
+ for line in sys.stdin:
+ if not line.strip():
+ continue
+ job = json.loads(line)
+ ok = job["cmd"] != "fail"
+ print(
+ json.dumps(
+ {
+ "id": job.get("id"),
+ "ok": ok,
+ "cmd": job["cmd"],
+ "args": job.get("args", []),
+ "status": 0 if ok else 2,
+ "stdout": {"echo": job.get("args", [])} if ok else {"error": "boom"},
+ "stderr": "",
+ "attempts": 1,
+ "duration_ms": 1,
+ "deduplicated": False,
+ },
+ ensure_ascii=False,
+ )
+ )
+ sys.exit(0 if "--fail-fast" not in rest else 1)
+
+if cmd == "fail":
+ print(json.dumps({"error": "boom", "args": rest}))
+ sys.exit(2)
+
+if cmd == "doctor":
+ print(json.dumps({"ok": False, "checks": [{"name": "node", "status": "missing"}], "args": rest}))
+ sys.exit(1)
+
+if "-H" in rest:
+ print("Project: fake")
+ print("Duration: 1.00s")
+ sys.exit(0)
+
+print(json.dumps({"ok": True, "cmd": cmd, "args": rest}, ensure_ascii=False))
diff --git a/python/tests/test_client.py b/python/tests/test_client.py
new file mode 100644
index 0000000..755c009
--- /dev/null
+++ b/python/tests/test_client.py
@@ -0,0 +1,104 @@
+import os
+import shlex
+import sys
+import unittest
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
+
+import capcut # noqa: E402
+
+FAKE = Path(__file__).with_name("fake_capcut.py")
+
+
+class FakeCliMixin:
+ def setUp(self):
+ self._saved = os.environ.get("CAPCUT_CLI")
+ os.environ["CAPCUT_CLI"] = " ".join(shlex.quote(p) for p in (sys.executable, str(FAKE)))
+
+ def tearDown(self):
+ if self._saved is None:
+ os.environ.pop("CAPCUT_CLI", None)
+ else:
+ os.environ["CAPCUT_CLI"] = self._saved
+
+
+class FlagArgs(unittest.TestCase):
+ def test_kwargs_become_flags(self):
+ got = capcut.flag_args({"font_size": 16, "karaoke": True, "no_probe": False, "color": None, "tag": ["a", "b"]})
+ self.assertEqual(got, ["--font-size", "16", "--karaoke", "--tag", "a", "--tag", "b"])
+
+
+class Run(FakeCliMixin, unittest.TestCase):
+ def test_returns_parsed_json(self):
+ self.assertEqual(capcut.run("info", "/p"), {"ok": True, "cmd": "info", "args": ["/p"]})
+
+ def test_positionals_are_single_tokens_and_kwargs_are_flags(self):
+ got = capcut.run("add-text", "/p", "0s", "3s", "你好 世界 \"quoted\"", font_size=16, karaoke=True)
+ self.assertEqual(got["args"], ["/p", "0s", "3s", "你好 世界 \"quoted\"", "--font-size", "16", "--karaoke"])
+
+ def test_non_zero_exit_raises_with_status_and_data(self):
+ with self.assertRaises(capcut.CommandError) as cm:
+ capcut.run("fail", "/p")
+ self.assertEqual(cm.exception.status, 2)
+ self.assertEqual(cm.exception.data["error"], "boom")
+ self.assertIn("boom", str(cm.exception))
+
+ def test_run_raw_never_raises(self):
+ r = capcut.run_raw("fail", "/p")
+ self.assertFalse(r.ok)
+ self.assertEqual(r.error, "boom")
+
+ def test_human_returns_text(self):
+ out = capcut.run("info", "/p", human=True)
+ self.assertIn("Project:", out)
+
+ def test_version(self):
+ self.assertEqual(capcut.version(), "0.0.0-fake")
+
+ def test_doctor_returns_report_even_on_exit_1(self):
+ report = capcut.doctor(drafts="/store")
+ self.assertFalse(report["ok"])
+ self.assertEqual(report["args"], ["--drafts", "/store"])
+
+
+class Serve(FakeCliMixin, unittest.TestCase):
+ def test_jobs_round_trip_and_failed_jobs_are_results(self):
+ results = capcut.serve(
+ [capcut.Job("info", project="/p", id="a"), {"cmd": "fail", "project": "/p", "args": ["x"]}],
+ workers=2,
+ )
+ self.assertEqual([r["id"] for r in results], ["a", None])
+ self.assertTrue(results[0]["ok"])
+ self.assertFalse(results[1]["ok"])
+ self.assertEqual(results[1]["status"], 2)
+
+ def test_job_to_dict_drops_unset_fields(self):
+ self.assertEqual(capcut.Job("lint", project="/p").to_dict(), {"cmd": "lint", "project": "/p"})
+ self.assertEqual(
+ capcut.Job("add-text", "/p", ["0s", "2s", "hi"], id="t", retries=2, timeout=5000).to_dict(),
+ {"cmd": "add-text", "project": "/p", "args": ["0s", "2s", "hi"], "id": "t", "retries": 2, "timeout": 5000},
+ )
+
+
+class NotInstalled(unittest.TestCase):
+ def test_missing_binary_names_both_install_lines(self):
+ saved = {k: os.environ.get(k) for k in ("CAPCUT_CLI", "PATH")}
+ try:
+ os.environ.pop("CAPCUT_CLI", None)
+ os.environ["PATH"] = ""
+ with self.assertRaises(capcut.CliNotFound) as cm:
+ capcut.run("info", "/p")
+ self.assertIn("npm install -g capcut-cli", str(cm.exception))
+ self.assertIn("安装", str(cm.exception))
+ self.assertIn("CAPCUT_CLI", str(cm.exception))
+ finally:
+ for k, v in saved.items():
+ if v is None:
+ os.environ.pop(k, None)
+ else:
+ os.environ[k] = v
+
+
+if __name__ == "__main__":
+ unittest.main()