diff --git a/README.md b/README.md
index 027019a2..783a5253 100644
--- a/README.md
+++ b/README.md
@@ -61,7 +61,7 @@ Download the desktop app, it walks you through setting everything up:
-Use with Claude Code, Codex, Cursor, Copilot, or Gemini CLI. `/editor` is the main skill you'll use. Ask for what you want in plain language. Behind it is `dapi`, the CLI that drives the app.
+Use with Claude Code, Codex, Cursor, Copilot, or Gemini CLI. The app registers its MCP server with your agent, so just ask for what you want in plain language. Agents that list a server's prompts also offer `/diffusion:editor` and `/diffusion:watch`, which pull in the editing and watching guidance up front. `dapi` is the same set of tools as a CLI.
## Prompt examples
@@ -69,11 +69,11 @@ Use with Claude Code, Codex, Cursor, Copilot, or Gemini CLI. `/editor` is the ma
Motion graphics
```text
-/editor Create a ~20-second promo for vercel-labs/native in Vercel's presentation style. Research its official website, GitHub, and brand guidelines; use authentic assets and verified product features, with crisp typography, polished motion, and a strong final CTA.
+Create a ~20-second promo for vercel-labs/native in Vercel's presentation style. Research its official website, GitHub, and brand guidelines; use authentic assets and verified product features, with crisp typography, polished motion, and a strong final CTA.
```
```text
-/editor Recreate the 3blue1brown animation from https://youtu.be/HEfHFsfGXjs, closely matching its visual style, pacing, framing, colors, labels, and transitions. Use the exact collision mathematics from Gregory Galperin's original paper, do not approximate the physics.
+Recreate the 3blue1brown animation from https://youtu.be/HEfHFsfGXjs, closely matching its visual style, pacing, framing, colors, labels, and transitions. Use the exact collision mathematics from Gregory Galperin's original paper, do not approximate the physics.
```
@@ -82,11 +82,11 @@ Use with Claude Code, Codex, Cursor, Copilot, or Gemini CLI. `/editor` is the ma
Video editing
```text
-/editor edit the footage in /path/to/folder
+Edit the footage in /path/to/folder
```
```text
-/editor turn this footage into a polished YouTube video. Add readable captions and an attention-grabbing graphic in the opening to give viewers a strong visual hook.
+Turn this footage into a polished YouTube video. Add readable captions and an attention-grabbing graphic in the opening to give viewers a strong visual hook.
```
@@ -95,11 +95,11 @@ Use with Claude Code, Codex, Cursor, Copilot, or Gemini CLI. `/editor` is the ma
Clipping
```text
-/editor Can you pull the best 30-second moment from https://youtu.be/MtQ0qxyf-Ds and make a vertical version for social?
+Can you pull the best 30-second moment from https://youtu.be/MtQ0qxyf-Ds and make a vertical version for social?
```
```text
-/editor Make a 15-second version of this launch video. https://x.com/claudeai/status/2045156267690213649
+Make a 15-second version of this launch video. https://x.com/claudeai/status/2045156267690213649
```
@@ -108,11 +108,11 @@ Use with Claude Code, Codex, Cursor, Copilot, or Gemini CLI. `/editor` is the ma
Video understanding and reasoning
```text
-/watch In three bullets, explain what starts the conflict. Include timestamps. https://youtu.be/aqz-KE-bpKQ
+In three bullets, explain what starts the conflict. Include timestamps. https://youtu.be/aqz-KE-bpKQ
```
```text
-/watch Name three recurring locations and give one visual cue that distinguishes each. https://youtu.be/dQw4w9WgXcQ
+Name three recurring locations and give one visual cue that distinguishes each. https://youtu.be/dQw4w9WgXcQ
```
@@ -127,7 +127,7 @@ Both were created by prompting. The compositions are published in [diffusionstud
## Compositions as code
-A project is a folder of that JSX: `dapi open
` once, then edit the files. Saving recompiles the entry file and mounts it directly into the editor's ECS.
+A project is a folder of that JSX: `open` a folder once (`dapi open ` from a shell), then edit the files. Saving recompiles the entry file and mounts it directly into the editor's ECS.
Every element carries an `id`, which is how the write-back finds its target: a rect dragged on the canvas, a clip trimmed on the timeline, or a retyped line lands as a prop on the element that authored it.
@@ -176,7 +176,7 @@ Everything a mount produces stays a first-class editor node, so a person can pic
## Seeing and hearing the media
-Cutting footage requires understanding it. The CLI ships the inspection tools an agent needs to work with media it cannot watch:
+Cutting footage requires understanding it. The app exposes the inspection tools an agent needs to work with media it cannot watch — as MCP tools, and as the same commands in a shell:
```sh
dapi media probe clip.mp4 # container + codec metadata, like ffprobe
@@ -188,6 +188,8 @@ dapi media listen interview.mp4 -p "what is said in the intro?" # ask a multim
dapi capture intro -t 0 2 4 # the frames a render would produce, by scene id
```
+Each command is the MCP tool of the same name: `dapi media grab` is `media_grab`, `--per-sheet` is `perSheet`.
+
| Command | Purpose |
| --- | --- |
| `dapi open` | Launch the app and open (or create) a project folder, anywhere on disk |
@@ -199,15 +201,15 @@ dapi capture intro -t 0 2 4 # the frames a render w
| `dapi screenshot` / `dapi logs` | The app itself: capture the window, read recent console output |
| `dapi fetch` | Download a video from yt/tt/ig |
| `dapi whoami` | The authenticated account |
-| `dapi report` | Report a bug in the CLI or the app: diagnostics bundled, filed as a GitHub issue via `gh` |
+| `dapi report` | Report a bug in the tools or the app: diagnostics bundled, filed as a GitHub issue via `gh` |
-Conventions throughout: single results are one JSON value, collections are JSON Lines, errors go to stderr with exit code `1`. Everything is built to be piped, grepped, and driven by a program.
+Conventions throughout: every result is one JSON object, the same structured content the MCP tool returns; errors go to stderr with exit code `1`. Everything is built to be piped, grepped, and driven by a program.
## Documentation
-- [CLI reference](reference/README.md): every command, its options, and its output
-- [JSX reference](reference/jsx/README.md): the composition markup with elements, timing, paints, generative assets, and captions
-- [Examples](examples/README.md): runnable compositions, from basic scenes and generative assets to three.js and raw WebGPU
+- [Tool reference](knowledge/reference/tools/README.md): every tool and CLI command, its options, and its output
+- [JSX reference](knowledge/reference/jsx/README.md): the composition markup with elements, timing, paints, generative assets, and captions
+- [Examples](knowledge/examples/README.md): runnable compositions, from basic scenes and generative assets to three.js and raw WebGPU
## Repository layout
@@ -215,7 +217,7 @@ Conventions throughout: single results are one JSON value, collections are JSON
| --- | --- | --- |
| `apps/web` | `@diffusionstudio/web` | The editor UI (Solid + Vite) |
| `apps/desktop` | `@diffusionstudio/desktop` | Electron shell hosting the editor |
-| `apps/cli` | `@diffusionstudio/cli` | The `dapi` CLI |
+| `apps/cli` | `@diffusionstudio/cli` | The `dapi` CLI: a client of the app's MCP server, plus the `dapi mcp` stdio proxy |
| `packages/runtime` | `@diffusionstudio/runtime` | Headless editor runtime: the koota world, traits, actions, systems, media decoding, capture. No DOM, no Solid |
| `packages/reconciler` | `@diffusionstudio/reconciler` | Evaluates a compiled project bundle and reconciles its element tree onto runtime entities, via Solid's universal renderer |
| `packages/jsx` | `@diffusionstudio/jsx` | The authoring API: element vocabulary, types, and generated assets (`generate.*`) |
diff --git a/apps/cli/package.json b/apps/cli/package.json
index 994710b0..8e3ad53e 100644
--- a/apps/cli/package.json
+++ b/apps/cli/package.json
@@ -6,30 +6,21 @@
"bin": {
"dapi": "./dist/index.js"
},
- "exports": {
- "./protocol": "./src/protocol.ts",
- "./channels": "./src/cli-channels.ts"
- },
"scripts": {
- "build": "esbuild src/index.ts --bundle --platform=node --format=cjs --external:esbuild --external:@babel/core --external:@babel/preset-typescript --external:babel-preset-solid --external:bufferutil --external:utf-8-validate --outfile=dist/index.js && chmod +x dist/index.js",
+ "build": "esbuild src/index.ts --bundle --platform=node --format=cjs --outfile=dist/index.js && chmod +x dist/index.js",
"check": "tsc --noEmit",
"symlink:remove": "rm -f /opt/homebrew/bin/dapi",
"symlink:create": "npm run build && ln -sf \"$PWD/dist/index.js\" /opt/homebrew/bin/dapi"
},
"dependencies": {
- "@babel/core": "^7.29.7",
- "@trpc/client": "^11.18.0",
- "@trpc/server": "^11.18.0",
- "@babel/preset-typescript": "^7.27.1",
- "babel-preset-solid": "^1.9.12",
+ "@diffusionstudio/dapi": "*",
+ "@modelcontextprotocol/sdk": "^1.30.0",
"commander": "^14.0.3",
- "esbuild": "^0.28.1",
- "ws": "^8.18.3"
+ "zod": "^4.4.3"
},
"devDependencies": {
- "@types/babel__core": "^7.20.5",
"@types/node": "^24.10.1",
- "@types/ws": "^8.18.1",
+ "esbuild": "^0.28.1",
"typescript": "~5.9.3"
}
}
diff --git a/apps/cli/src/cli-channels.ts b/apps/cli/src/cli-channels.ts
deleted file mode 100644
index adecc9ca..00000000
--- a/apps/cli/src/cli-channels.ts
+++ /dev/null
@@ -1,194 +0,0 @@
-/* This Source Code Form is subject to the terms of the Mozilla Public
- * License, v. 2.0. If a copy of the MPL was not distributed with this
- * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
-
-// Wire-level channel for the CLI handshake. Each CLI command hosts a
-// short-lived WebSocket server; main's only job is to relay the connect
-// info to the renderer, which then dials the CLI directly. Main never sees
-// request payloads.
-export const CLI_WIRE = {
- CONNECT: "cli:connect",
-} as const;
-
-// Sent by the CLI to main over the unix socket, relayed verbatim to the
-// renderer. The token guards the loopback WebSocket server against other
-// local processes racing to connect first.
-export type CliHandshake = { port: number; token: string };
-
-export type CliHandshakeReply = { ok: true } | { ok: false; error: string };
-
-// One tRPC request/reply pair per WebSocket connection. `path` is the
-// dot-joined procedure path in the renderer's router (e.g. "media.frame");
-// procedure inputs and outputs are typed end-to-end via the AppRouter type,
-// so the wire envelope stays untyped.
-export type CliRequest = {
- path: string;
- input: unknown;
-};
-
-export type CliReply =
- | { ok: true; data: unknown }
- | { ok: false; error: string };
-
-export type AssetRef = { path: string };
-
-export type MediaProbeRequest = AssetRef;
-
-export type FrameQuality = "small" | "medium" | "large" | "fullres";
-export type MediaFrameRequest = AssetRef & {
- times?: number[];
- count?: number;
- start?: number;
- end?: number;
- quality?: FrameQuality;
- auto?: boolean;
- combine?: boolean;
- perSheet?: number;
-};
-
-/** Beyond this the cells get too small to be worth the tokens; use `filmstrip`. */
-export const MAX_FRAMES_PER_SHEET = 12;
-
-/**
- * One written image: a single frame stamped with its timecode, or a contact
- * sheet stamped with the span it covers (`0f-08s10f`).
- */
-export type TimecodedImage = { timecode: string; base64: string };
-
-export type MediaFrameResult = TimecodedImage[];
-
-export type CaptureRequest = {
- id: string;
- frames?: number[];
- combine?: boolean;
- perSheet?: number;
-};
-
-export type CaptureResult = TimecodedImage[];
-
-export type MediaTranscribeRequest = AssetRef;
-export type TranscriptWord = { text: string; start: number; end: number };
-export type TranscriptSegment = { text: string; words: TranscriptWord[] };
-export type MediaTranscribeResult = { segments: TranscriptSegment[] };
-
-export type MediaFilmstripRequest = AssetRef & { start?: number; end?: number; scale?: number };
-export type MediaFilmstripResult = { base64: string };
-
-export type MediaWaveformRequest = AssetRef & { start?: number; end?: number; scale?: number };
-export type MediaWaveformResult = {
- base64: string;
- silences: Array<{ start: number; end: number }>;
-};
-
-export type MediaListenRequest = AssetRef & { prompt?: string; start?: number; end?: number; stripVideo?: boolean };
-export type MediaListenResult = { result?: string; start?: number; end?: number };
-
-export type CheckRequest = { id: string };
-
-export type CheckIssueCode =
- | "black-frames"
- | "no-visuals"
- | "never-visible"
- | "zero-duration"
- | "transparent"
- | "source-error";
-
-/**
- * One structural finding. `ranges` (where present) are seconds relative to
- * the checked node's start — the same clock `capture --time` uses.
- */
-export type CheckIssue = {
- code: CheckIssueCode;
- severity: "error" | "warning";
- message: string;
- /** Source stamp of the offending node; absent when the issue is about the subtree as a whole. */
- node?: string;
- ranges?: Array<{ start: number; end: number }>;
-};
-
-export type CheckResult = {
- stats: {
- /** Nodes in the subtree, the checked node included. */
- nodes: number;
- byKind: Record;
- /** Deepest nesting level below the checked node (0 = no children). */
- depth: number;
- /** Seconds the checked node plays (its workarea, when one is set). */
- duration: number;
- };
- issues: CheckIssue[];
-};
-
-export type ExportFormat = "mp4" | "webm" | "ogg" | "mov";
-
-// The settings shape mirrors the scene's `diffusion.export.` entry in the
-// project's package.json (see the web app's engine/project-config), spelled
-// out here so the wire seam stays dependency-free. Codecs are strings on the
-// wire; the app validates them against what the encoder accepts.
-export type ExportVideoSettings = {
- enabled?: boolean;
- codec?: string;
- bitrate?: number;
- fps?: number;
- resolution?: number;
-};
-
-export type ExportAudioSettings = {
- enabled?: boolean;
- codec?: string;
- sampleRate?: number;
- bitrate?: number;
-};
-
-export type ExportSettings = {
- format?: ExportFormat;
- video?: ExportVideoSettings;
- audio?: ExportAudioSettings;
-};
-
-/**
- * `path` is the absolute output file, whose extension picks the container;
- * omitted, the app writes `exports/.` in the project folder.
- * Everything else — codecs, bitrates, resolution — is read from the scene's
- * export entry in the project's package.json.
- */
-export type ExportRequest = { id: string; path?: string };
-
-/**
- * `config` echoes the settings the export was made with — the package.json
- * entry (or the defaults), with the container the extension resolved to — so
- * a caller sees what its config edit actually did. `width`/`height` are the
- * encoded pixel size (0×0 for an audio-only export); `duration` is seconds.
- */
-export type ExportResult = {
- path: string;
- width: number;
- height: number;
- duration: number;
- size: number;
- config: ExportSettings;
-};
-
-export type GeneratedAsset = { id: string; name: string; type: string };
-
-export type ModelsRequest = { type?: "image" | "video" | "audio" };
-
-export type ModelInfo = {
- type: "image" | "video" | "audio";
- id: string;
- name: string;
- durations?: string[];
- aspectRatios?: string[];
- features?: Array<"start-frame" | "end-frame" | "audio">;
-};
-
-export type VoiceInfo = { id: string; label: string; description: string };
-
-export type ScreenshotResult = { base64: string; width: number; height: number };
-
-export type LogLevel = "debug" | "info" | "warning" | "error";
-
-export type LogEntry = { ts: number; level: LogLevel; message: string; source: string };
-
-export type LogsRequest = { tail?: number; level?: LogLevel };
-
diff --git a/apps/cli/src/cli-client.ts b/apps/cli/src/cli-client.ts
index fc125a02..48a311d3 100644
--- a/apps/cli/src/cli-client.ts
+++ b/apps/cli/src/cli-client.ts
@@ -2,166 +2,109 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+import { execFile } from "node:child_process";
import { connect } from "node:net";
-import { randomBytes } from "node:crypto";
-import type { AddressInfo } from "node:net";
-import { WebSocketServer } from "ws";
-import { createTRPCClient, TRPCClientError } from "@trpc/client";
-import type { TRPCLink } from "@trpc/client";
-import { observable } from "@trpc/server/observable";
-import { SOCKET_PATH } from "./protocol";
-import type { CliHandshake, CliHandshakeReply, CliReply, CliRequest } from "./protocol";
-import type { AppRouter } from "../../web/src/context/dapi";
+import { Client } from "@modelcontextprotocol/sdk/client/index.js";
+import { SOCKET_PATH, SocketTransport } from "@diffusionstudio/dapi/socket";
+import { version } from "../../../package.json";
-const DEFAULT_TIMEOUT_MS = 60000;
-export const GENERATE_TIMEOUT_MS = 600000;
-export const EXPORT_TIMEOUT_MS = 3600000;
+import type { Socket } from "node:net";
+import type { ToolInput, ToolName, ToolOutput } from "@diffusionstudio/dapi";
-// Asks the app (via the unix socket) to have the renderer dial our WebSocket
-// server. Main replies once the connect info has been delivered, so a
-// rejection here means the app is down or the renderer never became ready.
-function requestConnection(handshake: CliHandshake, timeoutMs: number): Promise {
- return new Promise((resolve, reject) => {
- const sock = connect(SOCKET_PATH);
- let buf = "";
- let settled = false;
- const settle = (fn: () => void) => {
- if (settled) return;
- settled = true;
- sock.destroy();
- fn();
- };
+export const APP_NAME = "Diffusion Studio";
- sock.setEncoding("utf8");
- sock.setTimeout(timeoutMs, () =>
- settle(() => reject(new Error("Timed out waiting for the app to accept the connection"))),
- );
- sock.on("connect", () => sock.end(JSON.stringify(handshake)));
- sock.on("data", (chunk) => {
- buf += chunk;
- });
- sock.on("end", () => {
- let reply: CliHandshakeReply;
- try {
- reply = JSON.parse(buf) as CliHandshakeReply;
- } catch (e) {
- settle(() => reject(e instanceof Error ? e : new Error(String(e))));
- return;
- }
- if (reply.ok) settle(resolve);
- else settle(() => reject(new Error(reply.error)));
- });
- sock.on("error", (err) => settle(() => reject(err)));
- });
-}
-
-async function transport(request: CliRequest, timeoutMs: number): Promise {
- const token = randomBytes(16).toString("hex");
- // Frame batches and other base64 replies can exceed ws's 100 MiB default,
- // so disable the payload cap; the server only lives for one request.
- const wss = new WebSocketServer({ host: "127.0.0.1", port: 0, maxPayload: 0 });
+// Renders, AI generation, and downloads outlive the 60s default.
+const TIMEOUTS: Record = {
+ export: 3_600_000,
+ capture: 600_000,
+ media_transcribe: 600_000,
+ media_listen: 600_000,
+ fetch: 600_000,
+};
+/**
+ * Calls one tool in the running app over an MCP session on its socket.
+ * Typed by the catalog: the input is what the tool's schema accepts, the
+ * output its structured content. One session per call; a command makes one
+ * or two, and the process exits when it settles.
+ */
+export async function call(name: N, input: ToolInput): Promise> {
+ const client = new Client({ name: "dapi", version });
try {
- const reply = await new Promise((resolve, reject) => {
- let settled = false;
- const timer = setTimeout(() => {
- settle(() => reject(new Error("Timed out waiting for response")));
- }, timeoutMs);
- const settle = (fn: () => void) => {
- if (settled) return;
- settled = true;
- clearTimeout(timer);
- fn();
- };
-
- wss.on("error", (err) => settle(() => reject(err)));
- wss.on("connection", (ws, req) => {
- const url = new URL(req.url ?? "/", "ws://127.0.0.1");
- if (url.searchParams.get("token") !== token) {
- ws.terminate();
- return;
- }
- ws.on("message", (raw) => {
- try {
- const parsed = JSON.parse(raw.toString()) as CliReply;
- settle(() => resolve(parsed));
- } catch (e) {
- settle(() => reject(e instanceof Error ? e : new Error(String(e))));
- }
- });
- ws.on("close", () =>
- settle(() => reject(new Error("App disconnected before replying"))),
- );
- ws.on("error", (err) => settle(() => reject(err)));
- ws.send(JSON.stringify(request));
- });
-
- wss.once("listening", () => {
- const { port } = wss.address() as AddressInfo;
- requestConnection({ port, token }, timeoutMs).catch((err) =>
- settle(() => reject(err instanceof Error ? err : new Error(String(err)))),
- );
- });
+ await client.connect(new SocketTransport(await openSocket()));
+ const result = await client.callTool({ name, arguments: input as Record }, undefined, {
+ timeout: TIMEOUTS[name] ?? 60_000,
});
+ if (result.isError) {
+ const text = (result.content as Array<{ type: string; text?: string }>)
+ .filter((block) => block.type === "text")
+ .map((block) => block.text)
+ .join("\n");
+ throw new Error(text || `${name} failed`);
+ }
+ return result.structuredContent as ToolOutput;
+ } finally {
+ await client.close().catch(() => {});
+ }
+}
- if (reply.ok) return reply.data;
- throw new Error(reply.error);
+/** Liveness: a round-trip through the app's MCP server. */
+export async function ping(): Promise {
+ const client = new Client({ name: "dapi", version });
+ try {
+ await client.connect(new SocketTransport(await openSocket()));
+ await client.ping();
} finally {
- // Tear down explicitly so no lingering handles keep the Node event loop
- // alive past `console.log(result)` and block the CLI from exiting.
- for (const client of wss.clients) client.terminate();
- wss.close();
+ await client.close().catch(() => {});
}
}
-// Terminating link: each operation runs over its own short-lived WebSocket
-// server that the renderer dials in to. Long-running procedures pass
-// { context: { timeoutMs } } at the call site.
-const cliLink: TRPCLink =
- () =>
- ({ op }) =>
- observable((observer) => {
- const timeoutMs =
- typeof op.context.timeoutMs === "number" ? op.context.timeoutMs : DEFAULT_TIMEOUT_MS;
- transport({ path: op.path, input: op.input }, timeoutMs)
- .then((data) => {
- observer.next({ result: { data } });
- observer.complete();
- })
- .catch((err) => observer.error(TRPCClientError.from(err as Error)));
- // No cancellation: the CLI process exits when the command settles.
- return () => {};
+// Connecting is where "the app is not running" shows up, as ENOENT (no
+// socket file) or ECONNREFUSED (a stale one); see `isAppDown`.
+export function openSocket(): Promise {
+ return new Promise((resolve, reject) => {
+ const socket = connect(SOCKET_PATH);
+ socket.once("connect", () => {
+ socket.off("error", reject);
+ resolve(socket);
});
+ socket.once("error", reject);
+ });
+}
-export const editor = createTRPCClient({ links: [cliLink] });
+export function isAppDown(e: unknown): boolean {
+ const code = (e as NodeJS.ErrnoException | undefined)?.code;
+ return code === "ENOENT" || code === "ECONNREFUSED";
+}
-// Transport failures surface as TRPCClientError wrapping the socket error;
-// unwrap to reach errno codes like ENOENT/ECONNREFUSED.
-export function errnoCode(e: unknown): string | undefined {
- if (!(e instanceof TRPCClientError)) return undefined;
- return (e.cause as NodeJS.ErrnoException | undefined)?.code;
+/**
+ * Launches the app, or surfaces the running instance: `open -a` on a running
+ * app only activates it, so this is safe to always run. macOS only; elsewhere
+ * it resolves false and the caller falls through to the socket.
+ */
+export function launchApp(background: boolean): Promise {
+ if (process.platform !== "darwin") return Promise.resolve(false);
+ const args = background ? ["-g", "-a", APP_NAME, "--args", "--hidden"] : ["-a", APP_NAME];
+ return new Promise((res) => execFile("open", args, (err) => res(!err)));
}
-// Bridges the cold-start gap after launching the app. Main only delivers the
-// handshake once the renderer has finished loading, and `ping` is answered
-// by the always-mounted app router, so a single round-trip proves the app is
-// fully up. The retry loop only handles the brief window before the handshake
-// socket itself binds (ENOENT/ECONNREFUSED).
-export async function waitForCliSocket(timeoutMs = 30000): Promise {
+/**
+ * Bridges the cold-start gap after launching the app: retries while the app
+ * looks down, until it answers a ping (a cold app binds the socket before
+ * its session is ready, so connecting alone proves nothing).
+ */
+export async function waitForApp(timeoutMs = 30000): Promise {
const start = Date.now();
let lastError: unknown = null;
while (Date.now() - start < timeoutMs) {
try {
- await editor.ping.query();
- return;
+ return await ping();
} catch (e) {
+ if (!isAppDown(e)) throw e;
lastError = e;
- const code = errnoCode(e);
- if (code !== "ENOENT" && code !== "ECONNREFUSED") throw e;
await new Promise((r) => setTimeout(r, 200));
}
}
- throw lastError instanceof Error
- ? lastError
- : new Error("Timed out waiting for the app to start");
+ const detail = lastError instanceof Error ? ` (${lastError.message})` : "";
+ throw new Error(`${APP_NAME} did not answer within ${Math.round(timeoutMs / 1000)}s of launching${detail}`);
}
diff --git a/apps/cli/src/cli-socket-path.ts b/apps/cli/src/cli-socket-path.ts
deleted file mode 100644
index c0e535e6..00000000
--- a/apps/cli/src/cli-socket-path.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-/* This Source Code Form is subject to the terms of the Mozilla Public
- * License, v. 2.0. If a copy of the MPL was not distributed with this
- * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
-
-import { platform, tmpdir } from "node:os";
-import { join } from "node:path";
-
-// One socket / named pipe per host. On macOS tmpdir is per-user; on Linux /tmp
-// is global but the socket file's owner-only mode 0600 keeps it isolated.
-//
-// Kept separate from cli-channels so the renderer can import the channel
-// registry and envelope types without pulling in node:os / node:path.
-export const SOCKET_PATH =
- platform() === "win32"
- ? "\\\\.\\pipe\\diffusion-studio"
- : join(tmpdir(), "diffusion-studio.sock");
diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts
index 184dac9b..167c6cf9 100644
--- a/apps/cli/src/index.ts
+++ b/apps/cli/src/index.ts
@@ -3,602 +3,68 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
-import { execFile } from "node:child_process";
-import { randomUUID } from "node:crypto";
-import { existsSync, mkdirSync, writeFileSync } from "node:fs";
-import { tmpdir } from "node:os";
-import { dirname, isAbsolute, join, resolve } from "node:path";
+import { existsSync } from "node:fs";
+import { isAbsolute, resolve } from "node:path";
import { Command } from "commander";
+import { z } from "zod";
import { version } from "../../../package.json";
-import { parseTime, TIME_FPS } from "@diffusionstudio/jsx";
-import { editor, errnoCode, EXPORT_TIMEOUT_MS, GENERATE_TIMEOUT_MS, waitForCliSocket } from "./cli-client";
-import { listLocalFonts } from "./fonts";
-import { buildIssueBody, createIssue } from "./report";
-import { fetchVideo } from "./ytdlp";
-import { MAX_FRAMES_PER_SHEET } from "./protocol";
-import type { AssetRef, FrameQuality, LogEntry, LogLevel, TimecodedImage } from "./protocol";
-
-// Long-running commands (renders, AI generation) override the default 60s.
-const GENERATE = { context: { timeoutMs: GENERATE_TIMEOUT_MS } };
-const EXPORT = { context: { timeoutMs: EXPORT_TIMEOUT_MS } };
-
-const APP_NAME = "Diffusion Studio";
-
-function handleSocketError(e: unknown): never {
- const code = errnoCode(e);
- if (code === "ENOENT" || code === "ECONNREFUSED") {
- console.error(`${APP_NAME} is not running. Launch the app first, then retry.`);
- } else {
- console.error((e as Error).message);
- }
- process.exit(1);
-}
-
-const FRAME_QUALITIES: FrameQuality[] = ["small", "medium", "large", "fullres"];
-
-// Guardrail against accidentally decoding a huge number of frames; --uncapped lifts it.
-const FRAME_CAP = 100;
-
-type MediaFrameOptions = {
- time?: string[];
- count?: string;
- start?: string;
- end?: string;
- quality?: string;
- uncapped?: boolean;
- output?: string;
- auto?: boolean;
- separate?: boolean;
- perSheet?: string;
-};
-
-async function mediaFrame(ref: string, opts: MediaFrameOptions): Promise {
- if (opts.time !== undefined && opts.count !== undefined) {
- console.error("Pass either --time or --count, not both.");
- process.exit(1);
- }
- if (opts.auto && opts.time !== undefined) {
- console.error("--auto picks its own timestamps; it cannot be combined with --time.");
- process.exit(1);
- }
-
- let times: number[] | undefined;
- if (opts.time !== undefined) {
- times = opts.time.map((t) => parseTimeArg(t, "--time", true));
- }
-
- let count: number | undefined;
- if (opts.count !== undefined) {
- count = Number(opts.count);
- if (!Number.isInteger(count) || count < 1) {
- console.error(`--count must be a positive integer (got "${opts.count}")`);
- process.exit(1);
- }
- }
-
- const start = opts.start !== undefined ? parseTimeArg(opts.start, "--start") : undefined;
- const end = opts.end !== undefined ? parseTimeArg(opts.end, "--end") : undefined;
- if (start !== undefined && end !== undefined && start >= end) {
- console.error(`--start (${start}s) must be less than --end (${end}s).`);
- process.exit(1);
- }
- if ((start !== undefined || end !== undefined) && count === undefined && !opts.auto) {
- console.error("--start and --end only apply together with --count or --auto.");
- process.exit(1);
- }
-
- const requested = count ?? times?.length ?? 1;
- if (!opts.uncapped && requested > FRAME_CAP) {
- console.error(`Grabbing ${requested} frames exceeds the ${FRAME_CAP}-frame cap; pass --uncapped to override.`);
- process.exit(1);
- }
-
- let quality: FrameQuality | undefined;
- if (opts.quality !== undefined) {
- if (!FRAME_QUALITIES.includes(opts.quality as FrameQuality)) {
- console.error(`--quality must be one of ${FRAME_QUALITIES.join(", ")} (got "${opts.quality}")`);
- process.exit(1);
- }
- quality = opts.quality as FrameQuality;
- }
-
- const perSheet = parsePerSheet(opts.perSheet, opts.separate);
- const target = resolveAssetRef(ref);
- const dir = opts.output ?? join(tmpdir(), `dapi-grab-${randomUUID().slice(0, 8)}`);
- mkdirSync(dir, { recursive: true });
- try {
- const images = await editor.media.frame.query({
- ...target,
- times,
- count,
- start,
- end,
- quality,
- auto: opts.auto,
- combine: !opts.separate,
- perSheet,
- });
- writeImages(images, dir);
- } catch (e) {
- handleSocketError(e);
- }
-}
-
-/**
- * A local file (or frames folder) that exists is sent as its absolute path;
- * anything else — a URL, or a library path (`b-roll/clip.mp4`) — is passed
- * through for the app to resolve. Library paths need an open project.
- */
-function resolveAssetRef(ref: string): AssetRef {
- const absPath = isAbsolute(ref) ? ref : resolve(process.cwd(), ref);
- if (existsSync(absPath)) return { path: absPath };
- if (isAbsolute(ref)) {
- console.error(`File not found: ${absPath}`);
- process.exit(1);
- }
- return { path: ref };
-}
-
-async function mediaProbe(ref: string): Promise {
- const target = resolveAssetRef(ref);
- const stop = startSpinner("Probing asset");
- try {
- const result = await editor.media.probe.query(target);
- stop();
- console.log(JSON.stringify(result));
- } catch (e) {
- stop();
- handleSocketError(e);
- }
-}
-
-async function mediaTranscribe(ref: string): Promise {
- const target = resolveAssetRef(ref);
- const stop = startSpinner("Transcribing asset");
- try {
- const result = await editor.media.transcribe.query(target, GENERATE);
- stop();
- console.log(JSON.stringify(result));
- } catch (e) {
- stop();
- handleSocketError(e);
- }
-}
-
-type MediaListenOptions = { prompt?: string; start?: string; end?: string; keepVideo?: boolean };
-
-async function mediaListen(ref: string, opts: MediaListenOptions): Promise {
- const start = opts.start !== undefined ? parseTimeArg(opts.start, "--start") : undefined;
- const end = opts.end !== undefined ? parseTimeArg(opts.end, "--end") : undefined;
- if (start !== undefined && end !== undefined && start >= end) {
- console.error(`--start (${start}s) must be less than --end (${end}s).`);
- process.exit(1);
- }
-
- const target = resolveAssetRef(ref);
- const stop = startSpinner("Analyzing asset");
- try {
- const result = await editor.media.listen.query(
- { ...target, prompt: opts.prompt, start, end, stripVideo: !opts.keepVideo },
- GENERATE,
- );
- stop();
- console.log(JSON.stringify(result));
- } catch (e) {
- stop();
- handleSocketError(e);
- }
-}
-
-type MediaPreviewOptions = { start?: string; end?: string; scale?: string; output?: string };
-
-function parseTimeArg(value: string, flag: string, allowNegative = false): number {
- const seconds = parseTime(value);
- if (seconds === undefined || (!allowNegative && seconds < 0)) {
- console.error(
- `${flag} must be a ${allowNegative ? "" : "non-negative "}Time — seconds ("1.5"), frames ("45f"), or "MM:SS" (got "${value}")`,
- );
- process.exit(1);
- }
- return seconds;
-}
-
-// Frames and contact sheets arrive in the same shape: the app stamps each
-// image with its timecode (`08s10f`, or `0f-08s10f` for a sheet), which is the
-// filename too.
-function writeImages(images: TimecodedImage[], dir: string): void {
- for (const { timecode, base64 } of images) {
- const path = join(dir, `${timecode}.png`);
- writeFileSync(path, Buffer.from(base64, "base64"));
- console.log(JSON.stringify({ timecode, path }));
- }
-}
-
-function parsePerSheet(value: string | undefined, separate?: boolean): number | undefined {
- if (value === undefined) return undefined;
- if (separate) {
- console.error("--per-sheet lays out contact sheets; it cannot be combined with --separate.");
- process.exit(1);
- }
- const n = Number(value);
- if (!Number.isInteger(n) || n < 1 || n > MAX_FRAMES_PER_SHEET) {
- console.error(`--per-sheet must be an integer between 1 and ${MAX_FRAMES_PER_SHEET} (got "${value}")`);
- process.exit(1);
- }
- return n;
-}
-
-// Parse the window/scale flags shared by `filmstrip` and `waveform`.
-function parsePreviewWindow(opts: MediaPreviewOptions): { start?: number; end?: number; scale?: number } {
- const start = opts.start !== undefined ? parseTimeArg(opts.start, "--start") : undefined;
- const end = opts.end !== undefined ? parseTimeArg(opts.end, "--end") : undefined;
- if (start !== undefined && end !== undefined && start >= end) {
- console.error(`--start (${start}s) must be less than --end (${end}s).`);
- process.exit(1);
- }
-
- let scale: number | undefined;
- if (opts.scale !== undefined) {
- scale = Number(opts.scale);
- if (!Number.isFinite(scale) || scale <= 0) {
- console.error(`--scale must be a positive number (got "${opts.scale}")`);
- process.exit(1);
- }
- }
-
- return { start, end, scale };
-}
-
-async function mediaFilmstrip(ref: string, opts: MediaPreviewOptions): Promise {
- const { start, end, scale } = parsePreviewWindow(opts);
- const target = resolveAssetRef(ref);
- const path = opts.output ?? join(tmpdir(), `${randomUUID()}.png`);
- mkdirSync(dirname(resolve(path)), { recursive: true });
- const stop = startSpinner("Rendering filmstrip");
- try {
- const { base64, ...rest } = await editor.media.filmstrip.query({ ...target, start, end, scale });
- stop();
- writeFileSync(path, Buffer.from(base64, "base64"));
- console.log(JSON.stringify({ path, ...rest }));
- } catch (e) {
- stop();
- handleSocketError(e);
- }
-}
+import { toolByName } from "@diffusionstudio/dapi";
+import { MCP_URL } from "@diffusionstudio/dapi/socket";
+import { APP_NAME, call, isAppDown, launchApp, ping, waitForApp } from "./cli-client";
+import { runProxy } from "./mcp-proxy";
-async function mediaWaveform(ref: string, opts: MediaPreviewOptions): Promise {
- const { start, end, scale } = parsePreviewWindow(opts);
- const target = resolveAssetRef(ref);
- const path = opts.output ?? join(tmpdir(), `${randomUUID()}.png`);
- mkdirSync(dirname(resolve(path)), { recursive: true });
- const stop = startSpinner("Rendering waveform");
- try {
- const { base64, ...rest } = await editor.media.waveform.query({ ...target, start, end, scale });
- stop();
- writeFileSync(path, Buffer.from(base64, "base64"));
- console.log(JSON.stringify({ path, ...rest }));
- } catch (e) {
- stop();
- handleSocketError(e);
- }
-}
-
-type CaptureOptions = { time?: string[]; output?: string; separate?: boolean; perSheet?: string };
-
-async function captureNode(id: string, opts: CaptureOptions): Promise {
- const times = (opts.time ?? ["0"]).map((t) => parseTimeArg(t, "--time"));
- const frames = times.map((t) => Math.round(t * TIME_FPS));
- const perSheet = parsePerSheet(opts.perSheet, opts.separate);
-
- const dir = opts.output ?? join(tmpdir(), `dapi-capture-${randomUUID().slice(0, 8)}`);
- mkdirSync(dir, { recursive: true });
- try {
- const images = await editor.capture.query(
- { id, frames, combine: !opts.separate, perSheet },
- GENERATE,
- );
- writeImages(images, dir);
- } catch (e) {
- handleSocketError(e);
- }
-}
-
-async function exportScene(id: string, output: string | undefined): Promise {
- // The app owns everything else: settings come from the project's
- // package.json, the extension check and the default output path need the
- // config and project folder, which live on its side of the socket.
- const path = output !== undefined ? resolve(process.cwd(), output) : undefined;
- const stop = startSpinner("Exporting scene");
- try {
- const result = await editor.export.mutate({ id, path }, EXPORT);
- stop();
- console.log(JSON.stringify(result));
- } catch (e) {
- stop();
- handleSocketError(e);
- }
-}
-
-async function checkNode(id: string): Promise {
- try {
- const result = await editor.check.query({ id });
- console.log(JSON.stringify(result));
- // Linter convention: issues found is a different failure than "could not run".
- if (result.issues.some((issue) => issue.severity === "error")) process.exitCode = 1;
- } catch (e) {
- handleSocketError(e);
- }
-}
-
-type OpenOptions = { background?: boolean };
-
-/** `open -a` on a running app only activates it, so this is safe to always run. */
-function launchApp(background: boolean): Promise {
- const args = background ? ["-g", "-a", APP_NAME, "--args", "--hidden"] : ["-a", APP_NAME];
- return new Promise((res) => execFile("open", args, (err) => res(!err)));
-}
-
-async function openProject(path: string | undefined, opts: OpenOptions): Promise {
- // Launching is macOS's job; elsewhere (and when the app is not installed,
- // e.g. a dev checkout run from the terminal) fall through to the socket,
- // which answers if the app is running and errors usefully if not.
- const launched = process.platform === "darwin" && (await launchApp(opts.background ?? false));
-
- try {
- // A cold launch needs the renderer up before the app can answer; when
- // nothing was launched there is nothing to wait for, so fail fast.
- if (launched) await waitForCliSocket();
- else await editor.ping.query();
-
- if (path !== undefined) {
- const result = await editor.open.mutate({ dir: resolve(path) });
- console.log(JSON.stringify(result));
- }
- } catch (e) {
- handleSocketError(e);
- }
-}
-
-async function context(): Promise {
- try {
- const result = await editor.context.query();
- console.log(JSON.stringify(result));
- } catch (e) {
- handleSocketError(e);
- }
-}
+import type { GenericTool, ToolInput, ToolName } from "@diffusionstudio/dapi";
-async function whoami(): Promise {
- try {
- const result = await editor.whoami.query();
- console.log(JSON.stringify(result));
- } catch (e) {
- handleSocketError(e);
- }
-}
-
-const LOG_LEVELS = ["debug", "info", "warning", "error"] as const;
-
-type LogsOptions = { tail?: string; level?: string };
-
-async function showLogs(opts: LogsOptions): Promise {
- if (opts.level !== undefined && !LOG_LEVELS.includes(opts.level as LogLevel)) {
- console.error(`--level must be one of ${LOG_LEVELS.join(", ")} (got "${opts.level}")`);
- process.exit(1);
- }
- let tail: number | undefined;
- if (opts.tail !== undefined) {
- const n = Number(opts.tail);
- if (!Number.isInteger(n) || n <= 0) {
- console.error(`--tail must be a positive integer (got "${opts.tail}")`);
- process.exit(1);
- }
- tail = n;
- }
-
- try {
- const entries = await editor.logs.query({ tail, level: opts.level as LogLevel | undefined });
- for (const entry of entries) console.log(formatLogEntry(entry));
- } catch (e) {
- handleSocketError(e);
- }
-}
-
-function formatLogEntry(entry: LogEntry): string {
- const pad = (n: number, w = 2) => String(n).padStart(w, "0");
- const d = new Date(entry.ts);
- const time = `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}`;
- const source = entry.source ? ` (${entry.source})` : "";
- return `${time} [${entry.level}] ${entry.message}${source}`;
-}
-
-type ScreenshotOptions = { output?: string };
-
-// `diffusion-studio_2026-07-31_08-55-12.png`
-function screenshotFilename(taken: Date, attempt: number): string {
- const pad = (value: number) => String(value).padStart(2, "0");
- const date = [taken.getFullYear(), pad(taken.getMonth() + 1), pad(taken.getDate())].join("-");
- const time = [pad(taken.getHours()), pad(taken.getMinutes()), pad(taken.getSeconds())].join("-");
- const slug = APP_NAME.toLowerCase().replace(/[^a-z0-9]+/g, "-");
- return `${slug}_${date}_${time}${attempt > 1 ? `-${attempt}` : ""}.png`;
-}
-
-async function appScreenshot(opts: ScreenshotOptions): Promise {
- const dir = opts.output ?? tmpdir();
- mkdirSync(dir, { recursive: true });
- try {
- const { base64, width, height } = await editor.screenshot.query();
- const taken = new Date();
- let attempt = 1;
- let path = join(dir, screenshotFilename(taken, attempt));
- while (existsSync(path)) {
- path = join(dir, screenshotFilename(taken, ++attempt));
- }
- writeFileSync(path, Buffer.from(base64, "base64"));
- console.log(JSON.stringify({ path, width, height }));
- } catch (e) {
- handleSocketError(e);
- }
+function fail(message: string): never {
+ console.error(message);
+ process.exit(1);
}
-type IssueOptions = { body?: string; command?: string[]; logs?: string };
-
-const ISSUE_LOG_TAIL = 50;
-
-async function reportIssue(title: string, opts: IssueOptions): Promise {
- const summary = title.trim();
- if (!summary) {
- console.error("A one-line title is required.");
- process.exit(1);
- }
-
- let tail = ISSUE_LOG_TAIL;
- if (opts.logs !== undefined) {
- const n = Number(opts.logs);
- if (!Number.isInteger(n) || n < 0) {
- console.error(`--logs must be a non-negative integer (got "${opts.logs}")`);
- process.exit(1);
- }
- tail = n;
- }
-
- // The app being broken (or down) is exactly what gets reported, so a failed
- // log read is recorded in the report rather than failing the command.
- let logs: string[] | undefined;
- let appStatus = "not checked";
- if (tail > 0) {
- try {
- logs = (await editor.logs.query({ tail })).map(formatLogEntry);
- appStatus = "running";
- } catch (e) {
- const code = errnoCode(e);
- appStatus = code === "ENOENT" || code === "ECONNREFUSED"
- ? "not running"
- : `unreachable (${(e as Error).message})`;
- }
- }
-
- const body = buildIssueBody({
- title: summary,
- body: opts.body,
- commands: opts.command,
- logs,
- appStatus,
- version,
- });
-
- let url: string;
- try {
- url = await createIssue(summary, body);
- } catch (e) {
- console.error((e as Error).message);
- process.exit(1);
- }
-
- console.log(JSON.stringify({ url }));
+function appError(e: unknown): never {
+ if (isAppDown(e)) fail(`${APP_NAME} is not running. Launch the app first, then retry.`);
+ fail((e as Error).message);
}
-function startSpinner(label: string): () => void {
- if (!process.stderr.isTTY) {
- process.stderr.write(`${label}…\n`);
- return () => { };
- }
- const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
- const start = Date.now();
- let i = 0;
- const render = () => {
- const secs = Math.floor((Date.now() - start) / 1000);
- process.stderr.write(`\r${frames[i]} ${label}… ${secs}s`);
- i = (i + 1) % frames.length;
- };
- render();
- const timer = setInterval(render, 80);
- return () => {
- clearInterval(timer);
- process.stderr.write("\r\x1b[K"); // carriage return + clear to end of line
- };
+/** The tool's description, verbatim. */
+function describe(name: ToolName): string {
+ return toolByName(name).description;
}
-async function listModels(type: string | undefined): Promise {
- if (type !== undefined && type !== "image" && type !== "video" && type !== "audio") {
- console.error(`[type] must be one of "image", "video", "audio" (got "${type}")`);
- process.exit(1);
- }
- try {
- const models = await editor.models.query({ type: type as "image" | "video" | "audio" | undefined });
- for (const model of models) console.log(JSON.stringify(model));
- } catch (e) {
- handleSocketError(e);
- }
+/** An input field's description, verbatim, for the option that maps onto it. */
+function field(name: ToolName, key: string): string {
+ const tool: GenericTool = toolByName(name);
+ const schema = tool.input.shape[key];
+ if (schema === undefined) throw new Error(`tool ${name} has no input field "${key}"`);
+ return schema.description ?? "";
}
-async function listVoices(): Promise {
- try {
- const voices = await editor.voices.query();
- for (const voice of voices) console.log(JSON.stringify(voice));
- } catch (e) {
- handleSocketError(e);
- }
+/**
+ * Checks the input against the tool's schema, calls the tool, and prints
+ * what the app returns — its structured content, as one JSON object, the
+ * same thing an agent receives. Strings stay strings: times like "45f" are
+ * parsed by the schema on both sides.
+ */
+async function run(name: N, input: ToolInput): Promise {
+ const parsed = toolByName(name).input.safeParse(input);
+ if (!parsed.success) fail(z.prettifyError(parsed.error));
+ const output = await call(name, input).catch(appError);
+ console.log(JSON.stringify(output));
}
-type ListFontsOptions = {
- family?: string;
- weight?: string[];
- style?: string;
- limit?: string;
- namesOnly?: boolean;
-};
-
-function listFonts(opts: ListFontsOptions): void {
- let style: "normal" | "italic" | undefined;
- if (opts.style !== undefined) {
- if (opts.style !== "normal" && opts.style !== "italic") {
- console.error(`--style must be "normal" or "italic" (got "${opts.style}")`);
- process.exit(1);
- }
- style = opts.style;
- }
-
- let limit: number | undefined;
- if (opts.limit !== undefined) {
- const n = Number(opts.limit);
- if (!Number.isInteger(n) || n <= 0) {
- console.error(`--limit must be a positive integer (got "${opts.limit}")`);
- process.exit(1);
- }
- limit = n;
- }
-
- try {
- const families = listLocalFonts({
- familyPattern: opts.family,
- weights: opts.weight,
- style,
- limit,
- });
- if (opts.namesOnly) {
- for (const family of families) console.log(family.family);
- } else {
- for (const family of families) console.log(JSON.stringify(family));
- }
- } catch (e) {
- console.error((e as Error).message);
- process.exit(1);
- }
-}
+// Numbers are converted so the schema can check them as numbers; an empty or
+// non-numeric string becomes NaN, which the schema rejects with its own message.
+const numeric = (value: string): number => (value.trim() === "" ? NaN : Number(value));
-type FetchCliOptions = { output?: string; format?: string; audio?: boolean };
-
-// `raw` is every operand after `url` — the yt-dlp passthrough placed after `--`.
-// No spinner here: yt-dlp renders its own progress to the inherited stderr.
-async function fetch(url: string, opts: FetchCliOptions, raw: string[]): Promise {
- try {
- const paths = await fetchVideo(url, { ...opts, raw });
- for (const path of paths) console.log(JSON.stringify({ path }));
- } catch (e) {
- console.error((e as Error).message);
- process.exit(1);
- }
+/**
+ * A local file (or frames folder) that exists is sent as its absolute path;
+ * anything else — a URL, or a library path (`b-roll/clip.mp4`) — is passed
+ * through for the app to resolve. Library paths need an open project.
+ */
+function assetPath(ref: string): string {
+ const abs = resolve(ref);
+ if (existsSync(abs)) return abs;
+ if (isAbsolute(ref)) fail(`File not found: ${abs}`);
+ return ref;
}
const program = new Command();
@@ -608,58 +74,66 @@ program
.description(
`The Diffusion Studio CLI: understand, generate, and edit footage.
Analyze video/audio/images, generate them with AI, and compose assets.
-Use for any media analysis, media generation, or video editing task. No ffmpeg needed.`,
+Use for any media analysis, media generation, or video editing task. No ffmpeg needed.
+
+Every command wraps one tool of the running app's MCP server, which agents reach at ${MCP_URL} once the app runs (\`dapi open\`).`,
)
.version(version);
+program
+ .command("mcp")
+ .description(
+ `Serve the app's MCP server on stdio, for agents that cannot connect over HTTP (Claude Desktop). Every other agent should register the URL ${MCP_URL} instead, which the running app serves — \`dapi open\` starts it. Register this with \`claude mcp add dapi -- dapi mcp\` (or the equivalent entry in the agent's MCP config) and the agent gets every command below as a tool, with the same descriptions. Launches ${APP_NAME} in the background if it is not running (macOS).`,
+ )
+ .action(() => runProxy().catch((e: Error) => fail(e.message)));
+
program
.command("open")
.description(
- `Launch ${APP_NAME} (or surface the running instance) and, given a path, open that folder as a project.`,
+ `Launch ${APP_NAME} (or surface the running instance) and, given a path, open that folder as a project, creating the project files if the folder is not one yet. Prints the project's id, display name, and folder. Run this once before commands that need an open project (capture, check, export, context, and library paths in media commands).`,
)
- .argument("[path]", "project folder to open or create (default: none — just launch the app)")
+ .argument("[path]", `${field("open", "dir")} (default: none — just launch the app)`)
.option("-b, --background", "launch or keep the app in the background, without raising a window")
- .action((path: string | undefined, opts: OpenOptions) => openProject(path, opts));
+ .action(async (path: string | undefined, opts: { background?: boolean }) => {
+ const launched = await launchApp(opts.background ?? false);
+ await (launched ? waitForApp() : ping()).catch(appError);
+ if (path !== undefined) await run("open", { dir: resolve(path) });
+ });
program
.command("context")
.alias("ctx")
- .description(
- `Print the current app context: the application root folder (always reported), the folder of the project the app has open (null when none is), where its playhead sits, in seconds, the registered font families, and where its generations stand.`,
- )
- .action(() => context());
+ .description(describe("context"))
+ .action(() => run("context", {}));
program
.command("capture")
- .description(
- `Render single frames of a scene to PNGs — each frame is the frame an export of that scene would encode, drawn offscreen at the scene's own size. By default the positions are merged into contact sheets: up to 12 per image, each cell labelled with its timecode (\`08s10f\`, zero segments dropped) and rendered as large as fits, so a few positions arrive as one high-resolution picture instead of a directory to open one by one (\`--separate\` writes a PNG per position, at 720p height). The tool for checking composition ("what plays at time T": layout, overlaps, text, timing) and for verifying frames before an export. Scenes only — a single element renders inside its scene, so capture the scene at the times it plays. For a video asset's own full-resolution pixels use \`media grab\`.`,
- )
- .argument("", 'scene id to capture or `file:id` when two files use the same id')
- .option("-t, --time ", `one or more positions to capture, relative to the export's first frame, the workarea's start (0 = the export's frame 0) — seconds ("1.5"), frames ("45f"), or "MM:SS" (default: 0)`)
- .option("-S, --separate", "write one PNG per position instead of merging them into contact sheets")
- .option("--per-sheet ", "positions per contact sheet, 1-12; fewer means a larger cell each (default: as many as fit)")
- .option("-o, --output ", "directory to write the PNGs into (default: a fresh dir in the system temp dir)")
- .action((id: string, opts: CaptureOptions) => captureNode(id, opts));
+ .description(describe("capture"))
+ .argument("", field("capture", "id"))
+ .option("-t, --times ", field("capture", "times"))
+ .option("-S, --separate", field("capture", "separate"))
+ .option("--per-sheet ", field("capture", "perSheet"), numeric)
+ .option("-o, --output ", field("capture", "output"))
+ .action((id: string, opts: Omit, "id">) =>
+ run("capture", { id, ...opts, output: opts.output && resolve(opts.output) }),
+ );
program
.command("export")
- .description(
- `Encode a scene to a video file — the same render the app's export runs, covering the scene's workarea. Settings come from the scene's \`diffusion.export.\` entry in the project's package.json (the entry the app's export panel writes); a scene without one exports with the defaults (1080p H.264 MP4, AAC audio). The [output] extension picks the container, overriding the configured format. Prints one JSON object with the written path and the settings used. One export runs at a time; progress shows in the app.`,
- )
- .argument("", 'scene id to export or `file:id` when two files use the same id')
- .argument(
- "[output]",
- 'output file path, ffmpeg-style; its extension picks the container (default: "exports/." in the project folder)',
- )
- .action((id: string, output: string | undefined) => exportScene(id, output));
+ .description(describe("export"))
+ .argument("", field("export", "id"))
+ .argument("[output]", field("export", "path"))
+ .action((id: string, output: string | undefined) => run("export", { id, path: output && resolve(output) }));
program
.command("check")
- .description(
- `Check a node's subtree for obvious structural mistakes, without rendering (local analysis, no credits): spans where no visual is scheduled (likely black frames), children that never become visible, zero-duration or fully transparent nodes, and assets that failed to load or generate — plus subtree stats (node count by kind, nesting depth, played duration). Prints one JSON object; times in issue ranges are seconds relative to the node's start — for a scene whose workarea starts at 0, the same clock \`capture --time\` uses. Exits 1 when an error-severity issue is found. Structural only: a scheduled clip can still render black (dark footage, content smaller than the canvas), so confirm suspicious spans visually with \`capture\`.`,
- )
- .argument("", 'node id to check or `file:id` when two files use the same id')
- .action((id: string) => checkNode(id));
+ .description(`${describe("check")} Exits 1 when an error-severity issue is found.`)
+ .argument("", field("check", "id"))
+ .action(async (id: string) => {
+ const output = await call("check", { id }).catch(appError);
+ console.log(JSON.stringify(output));
+ if (output.issues.some((issue) => issue.severity === "error")) process.exitCode = 1;
+ });
const media = program
.command("media")
@@ -670,148 +144,131 @@ const media = program
media
.command("probe")
- .description(
- `Read the container and per-track technical metadata of a media file (local read, no credits): container format, duration, tags, and each track's codec params, without decoding. Commonly useful for a quick technical read, e.g. checking codec compatibility or duration before cutting. Packet stats (fps, bitrate) are estimated from a leading sample; images and transcripts report file-level info only.`,
- )
- .argument("", "local file path")
- .action((ref: string) => mediaProbe(ref));
+ .description(describe("media_probe"))
+ .argument("", field("media_probe", "path"))
+ .action((ref: string) => run("media_probe", { path: assetPath(ref) }));
media
.command("transcribe")
- .description(
- `Transcribe the speech in a video or audio file and print the timed transcript, with word-level start/end times in seconds. Commonly useful for footage with speakers (talking head, interview), where the word times let you cut on a line. A transcript marks only speech; the gaps are not necessarily silent (music, score, applause).`,
- )
- .argument("", "local video or audio file path")
- .action((ref: string) => mediaTranscribe(ref));
+ .description(describe("media_transcribe"))
+ .argument("", field("media_transcribe", "path"))
+ .action((ref: string) => run("media_transcribe", { path: assetPath(ref) }));
media
.command("grab")
.alias("sample")
- .description(
- `Decode frames of a video file and write them as PNGs (local render, no credits). By default the frames are merged into contact sheets: up to 12 per image, each cell labelled with its timecode (\`08s10f\`, zero segments dropped) and drawn as large as fits, so a handful of frames arrives as one high-resolution picture instead of a directory to open one by one (\`--separate\` writes a PNG per frame). Grabs the asset's own pixels, unlike \`capture\` which renders the composited node. The recommended tool for understanding a video at the frame level; past ~12 frames prefer \`media filmstrip\`.`,
- )
- .argument("", "local video file path to grab frames from")
- .option("-t, --time ", `one or more timestamps to grab — seconds ("1.5"), frames ("45f"), or "MM:SS"; negatives count back from the end, so -1 is one second before the end and -1f one frame before it (default: 0)`)
- .option("-c, --count ", "instead of --time, grab this many frames evenly spaced across the clip (or across the --start/--end window)")
- .option("-a, --auto", "scan the clip at 2fps and keep a frame each time the footage settles into a new visual state (transitions are waited out, so picks stay sharp); returns at most --count frames (default cap: 30), static footage like screen recordings returns far fewer; requires WebGPU")
- .option("-s, --start