diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index a09078b..e7babb6 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -21,6 +21,19 @@ jobs:
- run: npm test
- run: npm run build
+ docs-drift:
+ name: Docs drift check
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ - uses: actions/setup-node@v4
+ with:
+ node-version: "20"
+ - run: node scripts/gen-docs-manifest.mjs
+ - run: node scripts/check-docs-drift.mjs
+
python:
name: Python tests
runs-on: ubuntu-latest
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 914970a..c8bdf91 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -142,6 +142,8 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: write
+ env:
+ LANDING_DISPATCH_TOKEN: ${{ secrets.LANDING_DISPATCH_TOKEN }}
steps:
- uses: actions/download-artifact@v4
with:
@@ -156,3 +158,11 @@ jobs:
with:
files: dist/*
generate_release_notes: true
+ - name: Rebuild the landing site so the changelog picks up this release
+ if: ${{ env.LANDING_DISPATCH_TOKEN != '' }}
+ run: |
+ curl -sf -X POST \
+ -H "Authorization: Bearer $LANDING_DISPATCH_TOKEN" \
+ -H "Accept: application/vnd.github+json" \
+ https://api.github.com/repos/nmbrthirteen/podcli-landing/dispatches \
+ -d '{"event_type":"release"}'
diff --git a/.gitignore b/.gitignore
index 30bc139..6312001 100644
--- a/.gitignore
+++ b/.gitignore
@@ -58,3 +58,6 @@ cli/internal/backend/files/
cli/internal/podstack/commands/
cli/podcli
podcli-clips/
+
+# generated by scripts/gen-docs-manifest.mjs
+docs-manifest.json
diff --git a/README.md b/README.md
index f9d7b3b..95549b0 100644
--- a/README.md
+++ b/README.md
@@ -9,6 +9,7 @@
podcli.com ·
+ Docs ·
Quick start ·
MCP ·
Features
@@ -32,6 +33,8 @@ podcli process episode.mp4
One command transcribes, picks the best moments, crops to the face, and burns captions in. Nothing leaves your machine.
+Full documentation lives at [podcli.com/docs](https://podcli.com/docs). The docs are open source, edit them at [nmbrthirteen/podcli-docs](https://github.com/nmbrthirteen/podcli-docs).
+
---
## What It Does
@@ -148,8 +151,8 @@ Both halves share the same **knowledge base** (`.podcli/knowledge/`) — your sh
- **Clip history** — tracks everything to avoid duplicates
- **Preset system** — save named configurations per show
- **Content studio** — generate titles, descriptions, tags, and hashtags in the web UI; regenerate any section with your own guidance, or ask for anything custom
-- **MCP server** — 17 tools for Claude Desktop / Claude Code integration
-- **Web UI** — single-page flow at `localhost:3847`
+- **MCP server** — 22 tools for Claude Desktop / Claude Code integration
+- **Web UI** — multi-page studio at `localhost:3847`
- **CLI** — one-command processing: `podcli process episode.mp4`
---
diff --git a/scripts/check-docs-drift.mjs b/scripts/check-docs-drift.mjs
new file mode 100644
index 0000000..29fdab2
--- /dev/null
+++ b/scripts/check-docs-drift.mjs
@@ -0,0 +1,43 @@
+import { readFileSync } from "fs";
+import { join, dirname } from "path";
+import { fileURLToPath } from "url";
+
+const root = join(dirname(fileURLToPath(import.meta.url)), "..");
+const read = (p) => readFileSync(join(root, p), "utf8");
+
+let manifest;
+try {
+ manifest = JSON.parse(read("docs-manifest.json"));
+} catch {
+ console.error("docs-manifest.json not found. Run: node scripts/gen-docs-manifest.mjs");
+ process.exit(1);
+}
+
+const readme = read("README.md");
+const errors = [];
+const warnings = [];
+
+if (/single-page/i.test(readme) && manifest.studioRouteCount > 1) {
+ errors.push(`README calls the studio "single-page" but it has ${manifest.studioRouteCount} routes.`);
+}
+
+const toolClaims = [...new Set([...readme.matchAll(/(\d+)\s+(?:MCP\s+)?tools/gi)].map((m) => Number(m[1])))];
+for (const claim of toolClaims) {
+ if (claim !== manifest.mcpToolCount) {
+ warnings.push(`README mentions "${claim} tools" but src registers ${manifest.mcpToolCount} (server.tool calls).`);
+ }
+}
+
+if (manifest.packageVersion && manifest.version && !manifest.version.endsWith(manifest.packageVersion)) {
+ warnings.push(`package.json version ${manifest.packageVersion} lags the latest tag ${manifest.version}.`);
+}
+
+for (const w of warnings) console.warn("warning:", w);
+
+if (errors.length) {
+ console.error("Docs drift detected:");
+ for (const e of errors) console.error(" -", e);
+ process.exit(1);
+}
+
+console.log(`Docs in sync: ${manifest.mcpToolCount} tools, ${manifest.studioRouteCount} studio routes, ${manifest.version}.`);
diff --git a/scripts/gen-docs-manifest.mjs b/scripts/gen-docs-manifest.mjs
new file mode 100644
index 0000000..71017b0
--- /dev/null
+++ b/scripts/gen-docs-manifest.mjs
@@ -0,0 +1,40 @@
+import { readFileSync, writeFileSync, readdirSync } from "fs";
+import { execSync } from "child_process";
+import { join, dirname } from "path";
+import { fileURLToPath } from "url";
+
+const root = join(dirname(fileURLToPath(import.meta.url)), "..");
+const read = (p) => readFileSync(join(root, p), "utf8");
+
+function countTools(dir) {
+ let n = 0;
+ for (const entry of readdirSync(join(root, dir), { withFileTypes: true })) {
+ const rel = join(dir, entry.name);
+ if (entry.isDirectory()) n += countTools(rel);
+ else if (entry.name.endsWith(".ts")) n += (read(rel).match(/server\.tool\(/g) || []).length;
+ }
+ return n;
+}
+
+const mainTsx = read("src/ui/client/main.tsx");
+const studioRoutes = [...mainTsx.matchAll(/ !/Navigate/.test(m[0]))
+ .map((m) => ({ path: m[1], component: m[2] }));
+
+let version = "";
+try {
+ version = execSync("git describe --tags --abbrev=0", { cwd: root }).toString().trim();
+} catch {
+ version = JSON.parse(read("package.json")).version || "";
+}
+
+const manifest = {
+ version,
+ packageVersion: JSON.parse(read("package.json")).version || "",
+ mcpToolCount: countTools("src"),
+ studioRouteCount: studioRoutes.length,
+ studioRoutes,
+};
+
+writeFileSync(join(root, "docs-manifest.json"), JSON.stringify(manifest, null, 2) + "\n");
+console.log("wrote docs-manifest.json:", JSON.stringify(manifest));
diff --git a/src/services/clips-history.ts b/src/services/clips-history.ts
index d110706..c673573 100644
--- a/src/services/clips-history.ts
+++ b/src/services/clips-history.ts
@@ -4,6 +4,7 @@ import { basename, join } from "path";
import { v4 as uuidv4 } from "uuid";
import { paths } from "../config/paths.js";
import { sliceTranscript } from "../utils/transcript.js";
+import { isDemoMode, demoClips } from "../ui/demo-fixtures.js";
import type { BatchClipsResult, ClipHistoryEntry, Format, WordTimestamp } from "../models/index.js";
type BatchResultRow = BatchClipsResult["results"][number];
@@ -31,6 +32,7 @@ export class ClipsHistory {
}
async load(): Promise {
+ if (isDemoMode()) return demoClips();
try {
if (!existsSync(this.historyPath)) return [];
const raw = await readFile(this.historyPath, "utf-8");
@@ -41,6 +43,7 @@ export class ClipsHistory {
}
private async save(entries: ClipHistoryEntry[]): Promise {
+ if (isDemoMode()) return; // demo fixtures are read-only; never persist to clips.json
await this.ensureDir();
// Write to a temp file and atomically rename so a crash or a concurrent
// reader never sees a half-written clips.json.
@@ -181,6 +184,8 @@ export class ClipsHistory {
async remove(idOrPrefix: string): Promise {
const id = await this.resolveId(idOrPrefix);
if (!id) return null;
+ // Demo entries are read-only fixtures — never delete their (shipped) artifacts.
+ if (isDemoMode()) return (await this.findById(id)) ?? null;
const entry = await this.mutate((entries) => {
const idx = entries.findIndex((e) => e.id === id);
if (idx < 0) return null;
diff --git a/src/ui/client/ClipDetail.tsx b/src/ui/client/ClipDetail.tsx
index 6dd5474..784ecc5 100644
--- a/src/ui/client/ClipDetail.tsx
+++ b/src/ui/client/ClipDetail.tsx
@@ -90,6 +90,7 @@ export default function ClipDetail() {
const tc = clip.thumbnail_config || {};
const dirty = title !== clip.title || captionStyle !== clip.caption_style;
const previewUrl = `/api/clips/${clip.id}/preview?t=${bust}`;
+ const posterUrl = tc.preview_path ? img(tc.preview_path, bust) : undefined;
const patch = (body: any) => api(`/clips/${clip.id}`, { method: "PATCH", body: JSON.stringify(body) });
@@ -231,7 +232,7 @@ export default function ClipDetail() {
- {clip.output_path ?
(playerTime.current = t)} /> : No rendered output
}
+ {clip.output_path ? (playerTime.current = t)} /> : No rendered output
}
{fmt(clip.start_second)}-{fmt(clip.end_second)} · {clip.duration}s
diff --git a/src/ui/client/ClipPlayer.tsx b/src/ui/client/ClipPlayer.tsx
index 12304f7..8f312ed 100644
--- a/src/ui/client/ClipPlayer.tsx
+++ b/src/ui/client/ClipPlayer.tsx
@@ -4,7 +4,7 @@ import { PlayIcon, PauseIcon } from "./icons";
const fmt = (s: number) => (Number.isFinite(s) ? fmtTime(s) : "0:00");
-export default function ClipPlayer({ src, onTime }: { src: string; onTime?: (t: number) => void }) {
+export default function ClipPlayer({ src, poster, onTime }: { src: string; poster?: string; onTime?: (t: number) => void }) {
const ref = useRef(null);
const [playing, setPlaying] = useState(false);
const [t, setT] = useState(0);
@@ -36,6 +36,7 @@ export default function ClipPlayer({ src, onTime }: { src: string; onTime?: (t: