From 7502ebe3878c8518b63cc64f161f7408f5cd7e68 Mon Sep 17 00:00:00 2001 From: Rene Zander Date: Wed, 3 Jun 2026 15:06:43 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20tier=201=20&=202=20commands=20?= =?UTF-8?q?=E2=80=94=20describe,=20prune,=20relink,=20timeline,=20projects?= =?UTF-8?q?,=20multi-step=20undo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tier 1: - describe: JSON tool spec (name/version/global flags/commands+summaries) so agents don't scrape --help. Test enforces every command has a summary. - prune (#17): remove unreferenced materials; referenced set unions material_id + extra_material_refs[] so indirect refs survive. --dry-run aware. - relink: repair broken media paths via --dir (basename match) or --from/--to prefix replace; reports relinked/missing/present. --dry-run aware. Tier 2: - timeline: track/segment layout; JSON lanes w/ columns, -H ASCII bars (--cols). - projects: list draft folders on disk (default dirs or --drafts), name filter, --names reads each draft title. - multi-step undo: every write snapshots pre-write state to /.capcut-cli-history/ (cap 20); restore --step N / --list. Plain restore unchanged. Fresh-file writes create no history. 18 new tests; full suite 147/147. HELP, README map, CHANGELOG updated. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 11 ++ README.md | 9 +- src/doctor.ts | 2 +- src/draft.ts | 51 ++++- src/index.ts | 346 +++++++++++++++++++++++++++++++++- test/describe.test.mjs | 30 +++ test/projects.test.mjs | 49 +++++ test/prune.test.mjs | 55 ++++++ test/relink.test.mjs | 67 +++++++ test/restore-history.test.mjs | 62 ++++++ test/timeline.test.mjs | 39 ++++ 11 files changed, 707 insertions(+), 14 deletions(-) create mode 100644 test/describe.test.mjs create mode 100644 test/projects.test.mjs create mode 100644 test/prune.test.mjs create mode 100644 test/relink.test.mjs create mode 100644 test/restore-history.test.mjs create mode 100644 test/timeline.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 2dc40c0..d48907c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ All notable changes to capcut-cli are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); the project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- **`describe`** — emits the full command surface as JSON (name, version, global flags, every command + summary) so LLM/agent callers get a tool spec instead of scraping `--help`. A test enforces that every command has a summary, so nothing ships undescribed. +- **`prune`** — removes materials no segment references. The referenced set is the union of every segment's `material_id` **and** `extra_material_refs[]`, so masks/effects/animations/fades referenced indirectly are never wrongly deleted. Pairs with `--dry-run`. +- **`relink`** — repairs broken media paths. `--dir ` repoints each missing material to a same-basename file in the folder; `--from

--to ` prefix-replaces paths. Reports relinked / still-missing / present counts. Pairs with `--dry-run`. +- **`timeline`** — shows the track/segment layout. JSON default returns lanes with computed columns; `-H` renders ASCII bars (`--cols N`, default 60). Makes layout/track-order issues diagnosable without opening CapCut. +- **`projects`** — lists CapCut/JianYing draft folders on disk (scans the per-OS default dirs or `--drafts

`), with an optional name-substring filter and `--names` to read each draft's title. No more pasting 40-char UUID paths. +- **Multi-step undo** — every write now also keeps a rolling snapshot history under `/.capcut-cli-history/` (capped at 20). `restore --step N` rolls back N writes (step 1 == the `.bak`); `restore --list` shows the history. Plain `restore` is unchanged. + ## [0.8.0] — 2026-06-03 Safety, discoverability, and a long-overdue track-order fix. No breaking changes; everything stays zero-dep, JSON-by-default, and pipeable. diff --git a/README.md b/README.md index 5e6ffd6..c05d52a 100644 --- a/README.md +++ b/README.md @@ -68,15 +68,16 @@ How `capcut-cli` differs from the other CapCut / JianYing tooling: A capability map; see [Commands](#commands) for syntax. -- **Inspect** — `info` · `tracks` · `materials` · `segments` · `texts`; `segment`/`material ` for progressive-disclosure drill-down; `export-srt`. +- **Inspect** — `info` · `tracks` · `materials` · `segments` · `texts`; `segment`/`material ` for progressive-disclosure drill-down; `timeline` (ASCII layout); `export-srt`. - **Build & add** — `init` a draft, then `add-video` · `add-audio` · `add-text` from local files or [Wikimedia Commons URLs](#wikimedia-commons-phase-5) (license-gated); `add-sticker`, `add-effect`. -- **Edit** — `set-text` · `shift` · `shift-all` · `speed` · `volume` · `opacity` · `trim`; `batch` (many edits, one write); `--dry-run` preview and `restore` undo on any write. +- **Edit** — `set-text` · `shift` · `shift-all` · `speed` · `volume` · `opacity` · `trim`; `batch` (many edits, one write); `--dry-run` preview, and `restore` undo (latest `.bak` or `--step N` through snapshot history). +- **Maintain** — `prune` (drop unreferenced materials) · `relink` (repair broken media paths via `--dir` or `--from`/`--to`) · `projects` (list drafts on disk by name). - **Decorate** — `keyframe` · `transition` · `mask` · `bg-blur` · `text-style` · `text-anim` · `image-anim` · `text-ranges` (word-level highlight captions); `mix-mode` · `audio-fade` · `add-filter` · `bubble-text` · `add-cover` · `add-sfx` · `chroma`. - **Captions & translate** — `caption` (whisper → real caption objects, not text-segment mimics), `import-srt` / `import-ass`, `translate` (Anthropic-API multi-language clone, zero deps). - **Templates** — `save-template` / `apply-template`; six ship in [`templates/`](./templates/) (`gold-title`, `end-card`, `subscribe-cta`, `hook-question`, `lower-third`, `caption-pop`). - **Resilience** — `version` (support detection) · `lint` (schema-aware CI checks, exit 0/1/2) · `migrate` · `decrypt`; [schema reference](./docs/draft-schema/) + [version matrix](./docs/version-support.md). - **Discover** — `enums` — 12 categories × 2 namespaces, no network. -- **Integrate** — Node [library](#use-as-a-node-library), [Dockerfile](./Dockerfile), [GitHub Action](#github-action--lint-drafts-in-ci), `serve` (stateless JSONL runner for n8n/Make/Coze), `export --batch` (experimental render queue), `completions `, [Claude Code plugin](#claude-code-plugin). +- **Integrate** — `describe` (JSON tool spec for LLM/agent callers), Node [library](#use-as-a-node-library), [Dockerfile](./Dockerfile), [GitHub Action](#github-action--lint-drafts-in-ci), `serve` (stateless JSONL runner for n8n/Make/Coze), `export --batch` (experimental render queue), `completions `, [Claude Code plugin](#claude-code-plugin). - **Output** — JSON by default (pipe to `jq`), `-H` table, `-q` quiet. **Cross-platform:** CapCut **and** JianYing in one binary (`--jianying` switches the enum namespace); macOS · Windows · Linux; pure Node ≥ 18, zero runtime deps. @@ -572,7 +573,7 @@ Close the project in CapCut before editing, reopen after. CapCut reads the JSON |---|---| | **Edits vanish / project looks unchanged** | CapCut was open. It keeps its own copy of the draft in memory and overwrites your file when it next saves. **Close the project in CapCut, run the CLI, then reopen.** This is the single most common gotcha. | | **Track / layer order looks scrambled in CapCut** | Older builds wrote tracks in command-call order, but CapCut lays out the timeline from the tracks-array order. Recent builds normalize the array to the canonical layer order (video → audio → overlays → text) on every save. Update, re-run the edit, reopen. ([#21](https://github.com/renezander030/capcut-cli/issues/21)) | -| **Need to undo an edit** | Run `capcut restore ` — it copies the `.bak` back over the draft. Single-step (only the last write is kept). Preview any command first with `--dry-run` to avoid the round-trip. | +| **Need to undo an edit** | `capcut restore ` reverts the last write. Earlier writes are recoverable too: `capcut restore --list` shows the snapshot history (kept in `/.capcut-cli-history/`, last 20), and `--step N` rolls back N writes. Preview any command first with `--dry-run` to avoid the round-trip. | | **`caption` fails: whisper not found** | `caption` shells out to a whisper binary. Install one (`pip install openai-whisper`, `brew install whisper-cpp`, or faster-whisper) or pass `--whisper-cmd `. | | **`translate` fails: ANTHROPIC_API_KEY** | Set the env var (`export ANTHROPIC_API_KEY=…`) or pass `--api-key`. | | **`audio-fade --out` seems ignored** | `--out` is the global output-path flag. Use `--fade-out` for the fade-out duration. | diff --git a/src/doctor.ts b/src/doctor.ts index 03ce412..14b0a22 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -39,7 +39,7 @@ function nodeMajor(): number { } /** Default per-OS CapCut/JianYing project directories. */ -function draftDirs(): { label: string; path: string }[] { +export function draftDirs(): { label: string; path: string }[] { const home = homedir(); if (platform() === "darwin") { return [ diff --git a/src/draft.ts b/src/draft.ts index af43a66..098922d 100644 --- a/src/draft.ts +++ b/src/draft.ts @@ -1,5 +1,5 @@ -import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs"; -import { resolve } from "node:path"; +import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { basename, dirname, join, resolve } from "node:path"; export interface Timerange { start: number; @@ -147,6 +147,52 @@ export function isDryRun(): boolean { return dryRun; } +// Multi-step undo history. Alongside the single `.bak`, every write also keeps +// a rolling stack of the pre-write content under `/.capcut-cli-history/`, +// capped at HISTORY_MAX. `restore --step N` rolls back N writes; CapCut ignores +// the hidden dir. snapshots are named `.NNNNNN.snap` (zero-padded, +// monotonically increasing) so the newest is the lexicographically last. +const HISTORY_DIR = ".capcut-cli-history"; +const HISTORY_MAX = 20; + +function historyDir(filePath: string): string { + return join(dirname(filePath), HISTORY_DIR); +} + +function snapshotFiles(filePath: string): string[] { + const dir = historyDir(filePath); + if (!existsSync(dir)) return []; + const prefix = `${basename(filePath)}.`; + return readdirSync(dir) + .filter((f) => f.startsWith(prefix) && f.endsWith(".snap")) + .sort(); // zero-padded indices => lexicographic === numeric order, oldest first +} + +function writeHistorySnapshot(filePath: string, content: string): void { + const dir = historyDir(filePath); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + const existing = snapshotFiles(filePath); + const last = existing[existing.length - 1]; + const lastIndex = last ? Number.parseInt(last.match(/\.(\d+)\.snap$/)?.[1] ?? "0", 10) : 0; + const name = `${basename(filePath)}.${String(lastIndex + 1).padStart(6, "0")}.snap`; + writeFileSync(join(dir, name), content, "utf-8"); + // Trim oldest beyond the cap. + const all = snapshotFiles(filePath); + while (all.length > HISTORY_MAX) { + const oldest = all.shift(); + if (oldest) rmSync(join(dir, oldest)); + } +} + +// Snapshots newest-first, step 1 = most recent write (equivalent to `.bak`). +export function listSnapshots(filePath: string): Array<{ step: number; index: number; path: string }> { + const dir = historyDir(filePath); + return snapshotFiles(filePath) + .map((f) => ({ index: Number.parseInt(f.match(/\.(\d+)\.snap$/)?.[1] ?? "0", 10), path: join(dir, f) })) + .sort((a, b) => b.index - a.index) + .map((s, i) => ({ step: i + 1, index: s.index, path: s.path })); +} + export function saveDraft(filePath: string, draft: Draft): void { if (dryRun) { // Normalize in memory (so any read-back is consistent) but write nothing. @@ -157,6 +203,7 @@ export function saveDraft(filePath: string, draft: Draft): void { if (existsSync(filePath)) { const original = rawOriginal ?? readFileSync(filePath, "utf-8"); writeFileSync(bakPath, original, "utf-8"); + writeHistorySnapshot(filePath, original); } sortTracks(draft); // Detect original indent: if first line after { starts with tab use tab, else count spaces diff --git a/src/index.ts b/src/index.ts index 7834939..db50b5b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { copyFileSync, existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; +import { copyFileSync, existsSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { parseAss } from "./ass.js"; @@ -33,7 +33,7 @@ import { textAnimSlugs, } from "./decorators.js"; import { detectEncryption } from "./decrypt.js"; -import { type DoctorCheck, runDoctor } from "./doctor.js"; +import { type DoctorCheck, draftDirs, runDoctor } from "./doctor.js"; import type { Draft, Segment, Track } from "./draft.js"; import { extractText, @@ -44,6 +44,7 @@ import { getMaterialTypes, getTracksByType, isDryRun, + listSnapshots, loadDraft, saveDraft, setDryRun, @@ -129,6 +130,12 @@ export const COMMANDS = [ "migrate", "add-sfx", "chroma", + "prune", + "relink", + "timeline", + "projects", + "describe", + "completions", "enums", "doctor", "restore", @@ -218,7 +225,14 @@ Edit: opacity Set opacity (0.0-1.0) export-srt Export subtitles to SRT batch Run multiple edits from stdin (JSONL) - restore Undo the last write (restore from .bak, single-step) + restore [--step N | --list] Undo writes (latest .bak, or N writes back; --list history) + +Maintenance & inspection: + prune Remove materials no segment references + relink --dir | --from

--to Repair broken media paths + timeline [--cols N] Show track/segment layout (JSON, or -H ASCII bars) + projects [query] [--drafts

] [--names] List CapCut/JianYing draft folders on disk + describe Emit the full command surface as JSON (agent tool spec) Animate: keyframe