diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dd3fce8..9d3ec53 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,3 +35,6 @@ jobs: - run: pnpm test - run: pnpm build + + # The Claude Desktop extension: validates the manifest and packs it. + - run: pnpm build:mcpb diff --git a/.gitignore b/.gitignore index 872d5f6..0bf3c31 100644 --- a/.gitignore +++ b/.gitignore @@ -141,3 +141,7 @@ dist vite.config.js.timestamp-* vite.config.ts.timestamp-* .vite/ + +# Claude Desktop extension build (pnpm build:mcpb) and release assets +/build/ +/release/ diff --git a/.releaserc.json b/.releaserc.json index 66b0e03..dc78703 100644 --- a/.releaserc.json +++ b/.releaserc.json @@ -14,7 +14,18 @@ "@semantic-release/release-notes-generator", ["@semantic-release/changelog", { "changelogFile": "CHANGELOG.md" }], ["@semantic-release/npm", { "tarballDir": "release", "npmPublish": false }], - ["@semantic-release/exec", { "publishCmd": "npm publish --provenance --access public" }], - "@semantic-release/github" + [ + "@semantic-release/exec", + { + "prepareCmd": "pnpm build:mcpb", + "publishCmd": "npm publish --provenance --access public" + } + ], + [ + "@semantic-release/github", + { + "assets": [{ "path": "release/*.mcpb", "label": "Claude Desktop extension (.mcpb)" }] + } + ] ] } diff --git a/README.md b/README.md index b067427..8f61cce 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,13 @@ clip --help clip ui # opens http://127.0.0.1:5573 ``` +**As a Claude Desktop extension** (one click, no Node or config file): + +1. Install FFmpeg: `brew install ffmpeg` on macOS, `winget install Gyan.FFmpeg` on Windows. +2. Download [`makemyclip-editor.mcpb`](https://github.com/MakeMyClip/editor/releases/latest/download/makemyclip-editor.mcpb) and double-click it, or drag it into Claude Desktop. + +Put your videos in the extension's **Media folder** (by default `Documents/MakeMyClip`); the editor reads and writes only there. The extension doesn't include FFmpeg (see [License](#license)): it finds the one Homebrew, MacPorts, winget, Scoop or Chocolatey installed, or you pick it in the extension's settings, along with the optional whisper model. + **As an MCP server** (drive it from Claude Desktop / Cursor / any MCP client — no API key, on your existing Claude): ```jsonc @@ -266,7 +273,7 @@ Every edit is undoable (`clip timeline undo` / `redo`, `clip timeline log`), and - **Language:** TypeScript (Node 24+) and React 19 (browser UI) - **Timeline schema:** [Zod](https://zod.dev/) (shared with the [MakeMyClip.com](https://makemyclip.com) web app) - **Subprocess:** [execa](https://github.com/sindresorhus/execa) — args as an array, no shell injection -- **FFmpeg:** bundled via `ffmpeg-static`, with `MAKEMYCLIP_FFMPEG_PATH` override or system-binary fallback +- **FFmpeg:** downloaded by `ffmpeg-static` when the npm package installs, with a `MAKEMYCLIP_FFMPEG_PATH` override and an installed FFmpeg as the fallback (the Claude Desktop extension always uses an installed one) - **Transcription:** optional, user-installed [whisper.cpp](https://github.com/ggml-org/whisper.cpp), run as a subprocess - **MCP:** [`@modelcontextprotocol/sdk`](https://modelcontextprotocol.io/) — the `clip mcp` server (Claude Desktop / any MCP client) - **UI:** Hono server + Vite + React, plain CSS @@ -279,7 +286,7 @@ Every edit is undoable (`clip timeline undo` / `redo`, `clip timeline log`), and ### Setup & installation **Do I need FFmpeg installed separately?** -No. MakeMyClip Editor bundles FFmpeg via `ffmpeg-static`, so installing the npm package or running `npx skills add MakeMyClip/editor` is all you need. For your own FFmpeg build (LGPL-only, custom codecs, hardware acceleration), set `MAKEMYCLIP_FFMPEG_PATH` to its path and the bundled binary is ignored. +Not for the npm package, the CLI or the Claude Code skill: `ffmpeg-static` downloads an FFmpeg build for your platform when the package installs. The Claude Desktop extension is the exception: it doesn't include FFmpeg, so install it first (`brew install ffmpeg` or `winget install Gyan.FFmpeg`). For your own FFmpeg build (LGPL-only, custom codecs, hardware acceleration), set `MAKEMYCLIP_FFMPEG_PATH` to its path and the downloaded binary is ignored. `clip doctor` shows which FFmpeg runs. **What platforms does it run on?** macOS, Linux, and Windows — anywhere Node 24+ runs. The browser UI binds to `127.0.0.1` and needs no internet connection. @@ -325,8 +332,8 @@ Every edit appends to `session.json` and produces a new output file in the works **Is it free?** Yes — MIT-licensed, free forever for local editing. No paid tier, no signup, no upsell in the editor. Paid AI-generation features (voice synthesis, music, stock footage) will live on [MakeMyClip.com](https://makemyclip.com) when they ship, behind a separate `@makemyclip/generation` package. -**What's the license situation with the bundled FFmpeg?** -The MakeMyClip Editor source code is [MIT](./LICENSE). The bundled FFmpeg binary (via `ffmpeg-static`) is GPL-licensed because it includes codecs like libx264 and libx265. Subprocess invocation keeps GPL terms confined to the binary, not your application code. For LGPL-only or custom-build requirements (e.g. iOS App Store), set `MAKEMYCLIP_FFMPEG_PATH` to your own binary. +**What's the license situation with FFmpeg?** +The MakeMyClip Editor source code is [MIT](./LICENSE). FFmpeg is a separate program the editor runs as a subprocess, so its license covers the binary, not your application code. The build `ffmpeg-static` downloads is GPLv3 on Intel Macs, Windows and Linux. On Apple Silicon it is a build with nonfree parts that FFmpeg marks as not redistributable, so the Claude Desktop extension ships no FFmpeg at all. For LGPL-only or custom-build requirements (e.g. iOS App Store), set `MAKEMYCLIP_FFMPEG_PATH` to your own binary. **How is this different from running FFmpeg directly?** FFmpeg is one binary with a complex CLI; MakeMyClip Editor is 19 named tools with typed inputs, a session log, snapshot/undo, a browser UI, and an agent-callable API. You're still running FFmpeg under the hood — but with a catalog any LLM can call, input validation that prevents bad commands, and a log you can inspect or version-control. @@ -415,4 +422,9 @@ Built on the work of [FFmpeg](https://ffmpeg.org/), [whisper.cpp](https://github ## License -[MIT](./LICENSE) for the editor source; GPL for the bundled FFmpeg binary (subprocess-isolated). Use it, fork it, ship it. +[MIT](./LICENSE) for the editor source. FFmpeg is a separate program, run as a subprocess under its own license: + +- **npm package, CLI, Claude Code skill:** `ffmpeg-static` downloads an FFmpeg build from its GitHub releases when the package installs. The Intel macOS, Windows and Linux builds are GPLv3. The Apple Silicon build includes nonfree parts and isn't redistributable. +- **Claude Desktop extension (`.mcpb`):** includes no FFmpeg. It runs the one you install. + +Set `MAKEMYCLIP_FFMPEG_PATH` to use an FFmpeg of your choice. Use it, fork it, ship it. diff --git a/biome.json b/biome.json index a05be5e..74e9184 100644 --- a/biome.json +++ b/biome.json @@ -10,6 +10,8 @@ "includes": [ "src/**/*", "tests/**/*", + "scripts/**/*", + "mcpb/**/*", "*.json", "*.md", "!dist", diff --git a/docs/backlog/apple-silicon-ffmpeg-build.md b/docs/backlog/apple-silicon-ffmpeg-build.md new file mode 100644 index 0000000..e3527ee --- /dev/null +++ b/docs/backlog/apple-silicon-ffmpeg-build.md @@ -0,0 +1,3 @@ +# Redistributable FFmpeg for Apple Silicon + +`ffmpeg-static`'s darwin-arm64 binary (FFmpeg 6.0 from osxexperts) is built with `--enable-nonfree`, and `ffmpeg -L` says it is not legally redistributable. npm installs on Apple Silicon download it, and the Claude Desktop extension ships no FFmpeg because of it. Find or build a GPL arm64 FFmpeg with drawtext and vidstab (a pinned CI build from source, with its corresponding source published) so Apple Silicon users get a redistributable build, and so the `.mcpb` could include FFmpeg for one-click setup. diff --git a/llms.txt b/llms.txt index 00ced9f..b016df2 100644 --- a/llms.txt +++ b/llms.txt @@ -6,11 +6,11 @@ - **Name:** MakeMyClip Editor (npm `@makemyclip/editor`, current release v0.4.0) - **Type:** Agent-driven video editor (not generative AI) -- **Backend:** FFmpeg (bundled via `ffmpeg-static`); optional user-installed whisper.cpp for transcription +- **Backend:** FFmpeg (downloaded by `ffmpeg-static` at npm install; the Claude Desktop extension uses an installed FFmpeg); optional user-installed whisper.cpp for transcription - **Surfaces:** Claude Code skill · `clip` CLI · browser UI at `127.0.0.1:5573` · MCP server (`clip mcp`) - **Agent access:** Claude Code skill + MCP server (`clip mcp`); no API key — the editor embeds no language model, it runs on whichever Claude you already use - **Tests:** 741 passing -- **License:** MIT (code) + GPL (bundled FFmpeg binary, subprocess-isolated) +- **License:** MIT (code); FFmpeg runs as a subprocess under its own license (GPLv3 builds from `ffmpeg-static`, except a nonfree Apple Silicon build; the `.mcpb` ships no FFmpeg) ## What v0.4.0 does @@ -29,6 +29,7 @@ - CLI: `npm i -g @makemyclip/editor` then `clip --help` - Browser UI: `clip ui` (opens http://127.0.0.1:5573) - MCP server: `npx -y @makemyclip/editor mcp` (stdio), with `MAKEMYCLIP_WORKSPACE` set to the media folder +- Claude Desktop extension: install FFmpeg, then open https://github.com/MakeMyClip/editor/releases/latest/download/makemyclip-editor.mcpb - Transcription (optional): install whisper.cpp (macOS: `brew install whisper-cpp`), download a model such as `ggml-base.en.bin`, set `MAKEMYCLIP_WHISPER_MODEL` to its path - Setup check: `npx -y @makemyclip/editor doctor` (or the MCP `doctor` tool) lists what's missing and how to fix it diff --git a/mcpb/manifest.json b/mcpb/manifest.json new file mode 100644 index 0000000..1295d2a --- /dev/null +++ b/mcpb/manifest.json @@ -0,0 +1,119 @@ +{ + "$schema": "https://raw.githubusercontent.com/modelcontextprotocol/mcpb/main/dist/mcpb-manifest-v0.3.schema.json", + "manifest_version": "0.3", + "name": "makemyclip-editor", + "display_name": "MakeMyClip Editor", + "version": "0.0.0-development", + "description": "Local video editor: remove silence, edit by transcript, add captions, export to Final Cut Pro, Resolve or Premiere.", + "long_description": "MakeMyClip Editor is an open-source (MIT) video editor that Claude drives through a timeline you can undo. Ask Claude to cut the pauses or repeated takes out of a recording, cut words using its transcript, add captions, and render an mp4 or export a project for Final Cut Pro, DaVinci Resolve or Premiere Pro. Everything runs on your computer, with no account and no API key.\n\n**Needs FFmpeg.** This extension doesn't include FFmpeg. Install it with `brew install ffmpeg` (macOS) or `winget install Gyan.FFmpeg` (Windows); the extension finds it on its own, or pick the program in the FFmpeg setting. Transcripts and captions also need a local whisper.cpp and a model file. Ask Claude \"is my setup OK?\" and it runs the editor's setup check, with the fix for anything missing.\n\nThe editor only reads and writes files inside the media folder you choose.", + "author": { + "name": "MakeMyClip", + "url": "https://makemyclip.com" + }, + "repository": { + "type": "git", + "url": "https://github.com/MakeMyClip/editor" + }, + "homepage": "https://github.com/MakeMyClip/editor", + "documentation": "https://github.com/MakeMyClip/editor#readme", + "support": "https://github.com/MakeMyClip/editor/issues", + "icon": "icon.png", + "server": { + "type": "node", + "entry_point": "dist/cli.js", + "mcp_config": { + "command": "node", + "args": ["${__dirname}/dist/cli.js", "mcp"], + "env": { + "MAKEMYCLIP_BUNDLE": "mcpb", + "MAKEMYCLIP_WORKSPACE": "${user_config.workspace}", + "MAKEMYCLIP_FFMPEG_PATH": "${user_config.ffmpeg_path}", + "MAKEMYCLIP_WHISPER_MODEL": "${user_config.whisper_model}", + "MAKEMYCLIP_WHISPER_PATH": "${user_config.whisper_path}" + } + } + }, + "tools": [ + { + "name": "timeline_show", + "description": "Read the timeline (tracks, clips, timings, whether it can render) and optionally the frame at a time." + }, + { + "name": "timeline_edit", + "description": "Apply editing verbs as one undoable change: add media, text or color, trim, move, split, remove, remove silence, cut words or retakes, caption speech, transition." + }, + { + "name": "timeline_check", + "description": "Review every hard cut for clipped words or clicks, with a contact sheet of the frames around each cut." + }, + { + "name": "timeline_transcript", + "description": "Transcribe a clip with the local whisper.cpp and return its words with timeline times." + }, + { + "name": "timeline_undo", + "description": "Undo the most recent timeline edit." + }, + { + "name": "timeline_redo", + "description": "Redo the most recently undone timeline edit." + }, + { + "name": "timeline_export", + "description": "Export the timeline as an mp4, an FCPXML project (Final Cut Pro, DaVinci Resolve) or FCP7 XML (Premiere Pro)." + }, + { + "name": "doctor", + "description": "Check the setup (FFmpeg and its filters, the media folder, a caption font, the optional whisper.cpp) and say how to fix what's missing." + } + ], + "keywords": [ + "video", + "video-editing", + "ffmpeg", + "captions", + "transcription", + "silence-removal", + "fcpxml", + "final-cut-pro", + "davinci-resolve", + "premiere-pro" + ], + "license": "MIT", + "compatibility": { + "platforms": ["darwin", "win32"], + "runtimes": { + "node": ">=24.0.0" + } + }, + "user_config": { + "workspace": { + "type": "directory", + "title": "Media folder", + "description": "The editor reads and writes files only inside this folder: put the videos you want to edit here, and exports land here too. Leave blank for a MakeMyClip folder in your Documents.", + "required": false, + "default": "" + }, + "ffmpeg_path": { + "type": "file", + "title": "FFmpeg", + "description": "Optional: the ffmpeg program to use. Leave blank to use an installed FFmpeg (Homebrew, MacPorts, winget, Scoop, Chocolatey, or on PATH).", + "required": false, + "default": "" + }, + "whisper_model": { + "type": "file", + "title": "Whisper model", + "description": "Optional, for transcripts, retake removal and captions: a whisper.cpp model file such as ggml-base.en.bin.", + "required": false, + "default": "" + }, + "whisper_path": { + "type": "file", + "title": "whisper-cli", + "description": "Optional: the whisper.cpp whisper-cli program. Leave blank to use an installed one (Homebrew, or on PATH).", + "required": false, + "default": "" + } + } +} diff --git a/package.json b/package.json index aaa83a1..d9f7f70 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "scripts": { "build": "tsup && pnpm build:ui", "build:ui": "vite build --config src/ui/web/vite.config.ts", + "build:mcpb": "tsx scripts/build-mcpb.ts", "dev": "tsx src/cli.ts", "dev:ui": "vite --config src/ui/web/vite.config.ts", "start": "node dist/cli.js", @@ -79,6 +80,7 @@ "zod": "^4.6.7" }, "devDependencies": { + "@anthropic-ai/mcpb": "2.1.2", "@biomejs/biome": "^2.6.4", "@semantic-release/changelog": "^7.0.0", "@semantic-release/commit-analyzer": "^13.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a63b252..c8ea798 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -191,6 +191,9 @@ importers: specifier: ^4.6.7 version: 4.6.7 devDependencies: + '@anthropic-ai/mcpb': + specifier: 2.1.2 + version: 2.1.2(supports-color@7.2.0) '@biomejs/biome': specifier: ^2.6.4 version: 2.6.4 @@ -266,6 +269,10 @@ packages: '@actions/io@3.0.2': resolution: {integrity: sha512-nRBchcMM+QK1pdjO7/idu86rbJI5YHUKCvKs0KxnSYbVe3F51UfGxuZX4Qy/fWlp6l7gWFwIkrOzN+oUK03kfw==} + '@anthropic-ai/mcpb@2.1.2': + resolution: {integrity: sha512-goRbBC8ySo7SWb7tRzr+tL6FxDc4JPTRCdgfD2omba7freofvjq5rom1lBnYHZHo6Mizs1jAHJeN53aZbDoy8A==} + hasBin: true + '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -513,6 +520,62 @@ packages: peerDependencies: hono: ^4 || ^5.0.0-0 + '@inquirer/checkbox@3.0.1': + resolution: {integrity: sha512-0hm2nrToWUdD6/UHnel/UKGdk1//ke5zGUpHIvk5ZWmaKezlGxZkOJXNSWsdxO/rEqTkbB3lNC2J6nBElV2aAQ==} + engines: {node: '>=18'} + + '@inquirer/confirm@4.0.1': + resolution: {integrity: sha512-46yL28o2NJ9doViqOy0VDcoTzng7rAb6yPQKU7VDLqkmbCaH4JqK4yk4XqlzNWy9PVC5pG1ZUXPBQv+VqnYs2w==} + engines: {node: '>=18'} + + '@inquirer/core@9.2.1': + resolution: {integrity: sha512-F2VBt7W/mwqEU4bL0RnHNZmC/OxzNx9cOYxHqnXX3MP6ruYvZUZAW9imgN9+h/uBT/oP8Gh888J2OZSbjSeWcg==} + engines: {node: '>=18'} + + '@inquirer/editor@3.0.1': + resolution: {integrity: sha512-VA96GPFaSOVudjKFraokEEmUQg/Lub6OXvbIEZU1SDCmBzRkHGhxoFAVaF30nyiB4m5cEbDgiI2QRacXZ2hw9Q==} + engines: {node: '>=18'} + + '@inquirer/expand@3.0.1': + resolution: {integrity: sha512-ToG8d6RIbnVpbdPdiN7BCxZGiHOTomOX94C2FaT5KOHupV40tKEDozp12res6cMIfRKrXLJyexAZhWVHgbALSQ==} + engines: {node: '>=18'} + + '@inquirer/figures@1.0.15': + resolution: {integrity: sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==} + engines: {node: '>=18'} + + '@inquirer/input@3.0.1': + resolution: {integrity: sha512-BDuPBmpvi8eMCxqC5iacloWqv+5tQSJlUafYWUe31ow1BVXjW2a5qe3dh4X/Z25Wp22RwvcaLCc2siHobEOfzg==} + engines: {node: '>=18'} + + '@inquirer/number@2.0.1': + resolution: {integrity: sha512-QpR8jPhRjSmlr/mD2cw3IR8HRO7lSVOnqUvQa8scv1Lsr3xoAMMworcYW3J13z3ppjBFBD2ef1Ci6AE5Qn8goQ==} + engines: {node: '>=18'} + + '@inquirer/password@3.0.1': + resolution: {integrity: sha512-haoeEPUisD1NeE2IanLOiFr4wcTXGWrBOyAyPZi1FfLJuXOzNmxCJPgUrGYKVh+Y8hfGJenIfz5Wb/DkE9KkMQ==} + engines: {node: '>=18'} + + '@inquirer/prompts@6.0.1': + resolution: {integrity: sha512-yl43JD/86CIj3Mz5mvvLJqAOfIup7ncxfJ0Btnl0/v5TouVUyeEdcpknfgc+yMevS/48oH9WAkkw93m7otLb/A==} + engines: {node: '>=18'} + + '@inquirer/rawlist@3.0.1': + resolution: {integrity: sha512-VgRtFIwZInUzTiPLSfDXK5jLrnpkuSOh1ctfaoygKAdPqjcjKYmGh6sCY1pb0aGnCGsmhUxoqLDUAU0ud+lGXQ==} + engines: {node: '>=18'} + + '@inquirer/search@2.0.1': + resolution: {integrity: sha512-r5hBKZk3g5MkIzLVoSgE4evypGqtOannnB3PKTG9NRZxyFRKcfzrdxXXPcoJQsxJPzvdSU2Rn7pB7lw0GCmGAg==} + engines: {node: '>=18'} + + '@inquirer/select@3.0.1': + resolution: {integrity: sha512-lUDGUxPhdWMkN/fHy1Lk7pF3nK1fh/gqeyWXmctefhxLYxlDsc7vsPBEpxrfVGDsVdyYJsiJoD4bJ1b623cV1Q==} + engines: {node: '>=18'} + + '@inquirer/type@2.0.0': + resolution: {integrity: sha512-XvJRx+2KR3YXyYtPUUy+qd9i7p+GO9Ko6VIIpWlBrpWwXDv8WLFeHTxz35CfQFUiBMLXlGHhGzys7lqit9gWag==} + engines: {node: '>=18'} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -913,9 +976,15 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/mute-stream@0.0.4': + resolution: {integrity: sha512-CPM9nzrCPPJHQNA9keH9CVkVI+WR5kMa+7XEs5jcGQ0VoAGnLv242w8lIVgwAEfmE4oufJRaTc9PNLQl0ioAow==} + '@types/node@10.17.60': resolution: {integrity: sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==} + '@types/node@22.20.5': + resolution: {integrity: sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==} + '@types/node@24.19.2': resolution: {integrity: sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==} @@ -930,6 +999,9 @@ packages: '@types/react@19.3.0': resolution: {integrity: sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==} + '@types/wrap-ansi@3.0.0': + resolution: {integrity: sha512-ltIpx+kM7g/MLRZfkbL7EsCEjfzCcScLpkg37eXEtx5kmrAKBkTJwd1GIAjDSL8wTpM6Hzn5YO4pSb91BEwu1g==} + '@vitejs/plugin-react@6.1.2': resolution: {integrity: sha512-fhJNGUdscQa4HkI0xFxwonWyhnVReCPDbXI7FH7jmzpnVCrFQI7PRrhWecO263610vOrs9tr2Y4xcf8uAdFxbw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1000,6 +1072,10 @@ packages: ajv@8.20.0: resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + ansi-escapes@4.3.2: + resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} + engines: {node: '>=8'} + ansi-escapes@7.3.0: resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} engines: {node: '>=18'} @@ -1110,6 +1186,9 @@ packages: resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==} engines: {node: '>=10'} + chardet@0.7.0: + resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==} + chokidar@5.0.0: resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} engines: {node: '>= 20.19.0'} @@ -1131,6 +1210,10 @@ packages: resolution: {integrity: sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==} engines: {node: 10.* || >= 12.*} + cli-width@4.1.0: + resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} + engines: {node: '>= 12'} + cliui@7.0.4: resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} @@ -1151,6 +1234,10 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + commander@13.1.0: + resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} + engines: {node: '>=18'} + commander@4.1.1: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} @@ -1399,6 +1486,10 @@ packages: resolution: {integrity: sha512-qCqy1xSoaugxOD4IVHb1ZWdkvCF2W3aE2okKouWj8VwJ+ztskSiXoBnCSJqfKT+cVPJhuJqe9YLbY425+Zh00g==} engines: {node: '>= 18'} + external-editor@3.1.0: + resolution: {integrity: sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==} + engines: {node: '>=4'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -1414,6 +1505,9 @@ packages: picomatch: optional: true + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + ffmpeg-static@5.3.0: resolution: {integrity: sha512-H+K6sW6TiIX6VGend0KQwthe+kaceeH/luE8dIZyOP35ik7ahYojDuqlTV1bOrtEwl01sy2HFNGQfi5IDJvotg==} engines: {node: '>=16'} @@ -1449,6 +1543,10 @@ packages: fix-dts-default-cjs-exports@1.0.1: resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} + flora-colossus@2.0.0: + resolution: {integrity: sha512-dz4HxH6pOvbUzZpZ/yXhafjbR2I8cenK5xL0KtBFb7U2ADsR+OwXifnxZjij/pZWF775uSCMzWVd+jDik2H2IA==} + engines: {node: '>= 12'} + forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} @@ -1457,6 +1555,10 @@ packages: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} + fs-extra@10.1.0: + resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} + engines: {node: '>=12'} + fs-extra@11.4.1: resolution: {integrity: sha512-KYAb4c9BJQI6QqGKthV68OHe0badztdXJWKo0WtBA9IuCFPTKvE5ZdUBglP833aMjhaSPNO4A5j/EkzZtGlKjA==} engines: {node: '>=14.14'} @@ -1473,6 +1575,10 @@ packages: resolution: {integrity: sha512-939eZS4gJ3htTHAldmyyuzlrD58P03fHG49v2JfFXbV6OhvZKRC9j2yAtdHw/zrp2zXHuv05zMIy40F0ge7spA==} engines: {node: '>=18'} + galactus@1.0.0: + resolution: {integrity: sha512-R1fam6D4CyKQGNlvJne4dkNF+PvUUl7TAJInvTGa9fti9qAv95quQz29GXapA4d8Ec266mJJxFVh82M4GIIGDQ==} + engines: {node: '>= 12'} + get-caller-file@2.0.5: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} @@ -1585,10 +1691,18 @@ packages: resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} engines: {node: '>=18.18.0'} + iconv-lite@0.4.24: + resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} + engines: {node: '>=0.10.0'} + iconv-lite@0.7.3: resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} engines: {node: '>=0.10.0'} + ignore@7.0.14: + resolution: {integrity: sha512-wCBiZ77u1tuWde6G/nfOtLaCTYcFDU5yCzARzK10y+rLhzYoL7a7yEemcopcc2Mzow7gZOIjnV2ym1NYmdiJOQ==} + engines: {node: '>= 4'} + import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} @@ -1912,6 +2026,10 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + mute-stream@1.0.0: + resolution: {integrity: sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} @@ -1934,6 +2052,10 @@ packages: resolution: {integrity: sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw==} engines: {node: '>=18'} + node-forge@1.4.3: + resolution: {integrity: sha512-P6wzfh6t70avmV8FrVEAd/36sOPJQ+hkaJVX1Laap9J/77m7M0eyozDvc8SwnDf4P7MP94gwbAAo0rg3YVXT+g==} + engines: {node: '>= 6.13.0'} + normalize-package-data@6.0.2: resolution: {integrity: sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==} engines: {node: ^16.14.0 || >=18.0.0} @@ -2052,6 +2174,10 @@ packages: resolution: {integrity: sha512-++Zlftm0kVLPmzC06t6epuWmcRMDbI4z5P3NNX979WA/k23+NtSOynEGzsVfZwguKw2mi5umVgnBlJQMwRz4Pg==} engines: {node: '>=20'} + os-tmpdir@1.0.2: + resolution: {integrity: sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==} + engines: {node: '>=0.10.0'} + p-each-series@3.0.0: resolution: {integrity: sha512-lastgtAdoH9YaLyDa5i5z64q+kzOcQHsQ5SsZJD3q0VEyI8mq872S3geuNbRUQLVAE9siMfgKrpj7MloKFHruw==} engines: {node: '>=12'} @@ -2202,6 +2328,10 @@ packages: resolution: {integrity: sha512-C+y9x90UElAddDZmV4qOx9W53B61PO7cIqWz2dQsWlwswuq4mr8NEwytdGKboYbQlGZ3awrkTeNvcZiZNHnQ8A==} engines: {node: '>=20'} + pretty-bytes@5.6.0: + resolution: {integrity: sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==} + engines: {node: '>=6'} + pretty-ms@9.3.1: resolution: {integrity: sha512-HzMy3Geq23nVALD/M2LliU+F+M+gVNsvkQWWqeBZ8HDiCgzo6YPJ/Omrmtq24EFrIsk0a3EkQGEd7bDOo+IhGA==} engines: {node: '>=18'} @@ -2529,6 +2659,10 @@ packages: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} + tmp@0.0.33: + resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==} + engines: {node: '>=0.6.0'} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -2576,6 +2710,10 @@ packages: resolution: {integrity: sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==} engines: {node: '>=0.6.11 <=0.7.0 || >=0.7.3'} + type-fest@0.21.3: + resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} + engines: {node: '>=10'} + type-fest@1.4.0: resolution: {integrity: sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==} engines: {node: '>=10'} @@ -2612,6 +2750,9 @@ packages: engines: {node: '>=0.8.0'} hasBin: true + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici-types@7.24.6: resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} @@ -2773,6 +2914,10 @@ packages: wordwrap@1.0.0: resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -2812,6 +2957,10 @@ packages: resolution: {integrity: sha512-9OpKOLeaoNFecEp7P6iYbzze/5CqWoH7N3SMs/6y1XF4nMvFMspEGZzJ6uFi9MmQwXhyzqYoSEKO3tvA3Z/o2w==} engines: {node: ^20.19.0 || ^22.12.0 || >=23} + yoctocolors-cjs@2.1.3: + resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} + engines: {node: '>=18'} + yoctocolors@2.2.0: resolution: {integrity: sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==} engines: {node: '>=18'} @@ -2821,6 +2970,9 @@ packages: peerDependencies: zod: ^3.25.28 || ^4 + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zod@4.6.7: resolution: {integrity: sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==} @@ -2842,6 +2994,20 @@ snapshots: '@actions/io@3.0.2': {} + '@anthropic-ai/mcpb@2.1.2(supports-color@7.2.0)': + dependencies: + '@inquirer/prompts': 6.0.1 + commander: 13.1.0 + fflate: 0.8.3 + galactus: 1.0.0(supports-color@7.2.0) + ignore: 7.0.14 + node-forge: 1.4.3 + pretty-bytes: 5.6.0 + zod: 3.25.76 + zod-to-json-schema: 3.25.2(zod@3.25.76) + transitivePeerDependencies: + - supports-color + '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -2985,6 +3151,102 @@ snapshots: dependencies: hono: 4.13.14 + '@inquirer/checkbox@3.0.1': + dependencies: + '@inquirer/core': 9.2.1 + '@inquirer/figures': 1.0.15 + '@inquirer/type': 2.0.0 + ansi-escapes: 4.3.2 + yoctocolors-cjs: 2.1.3 + + '@inquirer/confirm@4.0.1': + dependencies: + '@inquirer/core': 9.2.1 + '@inquirer/type': 2.0.0 + + '@inquirer/core@9.2.1': + dependencies: + '@inquirer/figures': 1.0.15 + '@inquirer/type': 2.0.0 + '@types/mute-stream': 0.0.4 + '@types/node': 22.20.5 + '@types/wrap-ansi': 3.0.0 + ansi-escapes: 4.3.2 + cli-width: 4.1.0 + mute-stream: 1.0.0 + signal-exit: 4.1.0 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + yoctocolors-cjs: 2.1.3 + + '@inquirer/editor@3.0.1': + dependencies: + '@inquirer/core': 9.2.1 + '@inquirer/type': 2.0.0 + external-editor: 3.1.0 + + '@inquirer/expand@3.0.1': + dependencies: + '@inquirer/core': 9.2.1 + '@inquirer/type': 2.0.0 + yoctocolors-cjs: 2.1.3 + + '@inquirer/figures@1.0.15': {} + + '@inquirer/input@3.0.1': + dependencies: + '@inquirer/core': 9.2.1 + '@inquirer/type': 2.0.0 + + '@inquirer/number@2.0.1': + dependencies: + '@inquirer/core': 9.2.1 + '@inquirer/type': 2.0.0 + + '@inquirer/password@3.0.1': + dependencies: + '@inquirer/core': 9.2.1 + '@inquirer/type': 2.0.0 + ansi-escapes: 4.3.2 + + '@inquirer/prompts@6.0.1': + dependencies: + '@inquirer/checkbox': 3.0.1 + '@inquirer/confirm': 4.0.1 + '@inquirer/editor': 3.0.1 + '@inquirer/expand': 3.0.1 + '@inquirer/input': 3.0.1 + '@inquirer/number': 2.0.1 + '@inquirer/password': 3.0.1 + '@inquirer/rawlist': 3.0.1 + '@inquirer/search': 2.0.1 + '@inquirer/select': 3.0.1 + + '@inquirer/rawlist@3.0.1': + dependencies: + '@inquirer/core': 9.2.1 + '@inquirer/type': 2.0.0 + yoctocolors-cjs: 2.1.3 + + '@inquirer/search@2.0.1': + dependencies: + '@inquirer/core': 9.2.1 + '@inquirer/figures': 1.0.15 + '@inquirer/type': 2.0.0 + yoctocolors-cjs: 2.1.3 + + '@inquirer/select@3.0.1': + dependencies: + '@inquirer/core': 9.2.1 + '@inquirer/figures': 1.0.15 + '@inquirer/type': 2.0.0 + ansi-escapes: 4.3.2 + yoctocolors-cjs: 2.1.3 + + '@inquirer/type@2.0.0': + dependencies: + mute-stream: 1.0.0 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.6.0 @@ -3335,8 +3597,16 @@ snapshots: '@types/estree@1.0.9': {} + '@types/mute-stream@0.0.4': + dependencies: + '@types/node': 24.19.2 + '@types/node@10.17.60': {} + '@types/node@22.20.5': + dependencies: + undici-types: 6.21.0 + '@types/node@24.19.2': dependencies: undici-types: 7.24.6 @@ -3351,6 +3621,8 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/wrap-ansi@3.0.0': {} + '@vitejs/plugin-react@6.1.2(vite@8.3.5(@types/node@24.19.2)(esbuild@0.28.2)(tsx@4.23.15))': dependencies: '@rolldown/pluginutils': 1.0.1 @@ -3405,6 +3677,10 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 + ansi-escapes@4.3.2: + dependencies: + type-fest: 0.21.3 + ansi-escapes@7.3.0: dependencies: environment: 1.1.0 @@ -3501,6 +3777,8 @@ snapshots: char-regex@1.0.2: {} + chardet@0.7.0: {} + chokidar@5.0.0: dependencies: readdirp: 5.1.1 @@ -3526,6 +3804,8 @@ snapshots: optionalDependencies: '@colors/colors': 1.5.0 + cli-width@4.1.0: {} + cliui@7.0.4: dependencies: string-width: 4.2.3 @@ -3550,6 +3830,8 @@ snapshots: color-name@1.1.4: {} + commander@13.1.0: {} + commander@4.1.1: {} compare-func@2.0.0: @@ -3840,6 +4122,12 @@ snapshots: transitivePeerDependencies: - supports-color + external-editor@3.1.0: + dependencies: + chardet: 0.7.0 + iconv-lite: 0.4.24 + tmp: 0.0.33 + fast-deep-equal@3.1.3: {} fast-uri@3.1.9: {} @@ -3848,6 +4136,8 @@ snapshots: optionalDependencies: picomatch: 4.0.7 + fflate@0.8.3: {} + ffmpeg-static@5.3.0(supports-color@7.2.0): dependencies: '@derhuerst/http-basic': 8.2.4 @@ -3897,10 +4187,23 @@ snapshots: mlly: 1.8.2 rollup: 4.65.1 + flora-colossus@2.0.0(supports-color@7.2.0): + dependencies: + debug: 4.4.3(supports-color@7.2.0) + fs-extra: 10.1.0 + transitivePeerDependencies: + - supports-color + forwarded@0.2.0: {} fresh@2.0.0: {} + fs-extra@10.1.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + fs-extra@11.4.1: dependencies: graceful-fs: 4.2.11 @@ -3914,6 +4217,14 @@ snapshots: function-timeout@1.0.2: {} + galactus@1.0.0(supports-color@7.2.0): + dependencies: + debug: 4.4.3(supports-color@7.2.0) + flora-colossus: 2.0.0(supports-color@7.2.0) + fs-extra: 10.1.0 + transitivePeerDependencies: + - supports-color + get-caller-file@2.0.5: {} get-east-asian-width@1.7.0: {} @@ -4036,10 +4347,16 @@ snapshots: human-signals@8.0.1: {} + iconv-lite@0.4.24: + dependencies: + safer-buffer: 2.1.2 + iconv-lite@0.7.3: dependencies: safer-buffer: 2.1.2 + ignore@7.0.14: {} + import-fresh@3.3.1: dependencies: parent-module: 1.0.1 @@ -4284,6 +4601,8 @@ snapshots: ms@2.1.3: {} + mute-stream@1.0.0: {} + mz@2.7.0: dependencies: any-promise: 1.3.0 @@ -4307,6 +4626,8 @@ snapshots: emojilib: 2.4.0 skin-tone: 2.0.0 + node-forge@1.4.3: {} + normalize-package-data@6.0.2: dependencies: hosted-git-info: 7.0.2 @@ -4359,6 +4680,8 @@ snapshots: powershell-utils: 0.2.1 wsl-utils: 1.0.1 + os-tmpdir@1.0.2: {} + p-each-series@3.0.0: {} p-event@6.0.1: @@ -4471,6 +4794,8 @@ snapshots: powershell-utils@0.2.1: {} + pretty-bytes@5.6.0: {} + pretty-ms@9.3.1: dependencies: parse-ms: 4.0.0 @@ -4899,6 +5224,10 @@ snapshots: fdir: 6.5.0(picomatch@4.0.7) picomatch: 4.0.7 + tmp@0.0.33: + dependencies: + os-tmpdir: 1.0.2 + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -4946,6 +5275,8 @@ snapshots: tunnel@0.0.6: {} + type-fest@0.21.3: {} + type-fest@1.4.0: {} type-fest@2.19.0: {} @@ -4971,6 +5302,8 @@ snapshots: uglify-js@3.19.3: optional: true + undici-types@6.21.0: {} + undici-types@7.24.6: {} undici@6.29.1: {} @@ -5052,6 +5385,12 @@ snapshots: wordwrap@1.0.0: {} + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 @@ -5098,10 +5437,18 @@ snapshots: y18n: 5.0.8 yargs-parser: 22.0.0 + yoctocolors-cjs@2.1.3: {} + yoctocolors@2.2.0: {} + zod-to-json-schema@3.25.2(zod@3.25.76): + dependencies: + zod: 3.25.76 + zod-to-json-schema@3.25.2(zod@4.6.7): dependencies: zod: 4.6.7 + zod@3.25.76: {} + zod@4.6.7: {} diff --git a/scripts/build-mcpb.ts b/scripts/build-mcpb.ts new file mode 100644 index 0000000..d33502f --- /dev/null +++ b/scripts/build-mcpb.ts @@ -0,0 +1,87 @@ +/** + * Build the Claude Desktop extension, `release/makemyclip-editor.mcpb`: + * the built CLI and its production dependencies, without FFmpeg. The Apple + * Silicon build ffmpeg-static downloads isn't redistributable, so the extension + * runs the FFmpeg the user installs (see the README's License section). + * Run `pnpm build` first. + */ +import { existsSync } from 'node:fs'; +import { cp, mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; +import { execa } from 'execa'; + +const root = resolve(import.meta.dirname, '..'); +const stage = join(root, 'build', 'mcpb'); +const outDir = join(root, 'release'); + +async function main(): Promise { + if (!existsSync(join(root, 'dist', 'cli.js'))) { + throw new Error('dist/cli.js is missing: run `pnpm build` first.'); + } + const { version } = JSON.parse(await readFile(join(root, 'package.json'), 'utf8')) as { + version: string; + }; + + await rm(stage, { recursive: true, force: true }); + await mkdir(stage, { recursive: true }); + for (const file of [ + 'package.json', + 'pnpm-lock.yaml', + 'pnpm-workspace.yaml', + 'README.md', + 'LICENSE', + ]) { + await cp(join(root, file), join(stage, file)); + } + await cp(join(root, 'dist'), join(stage, 'dist'), { + recursive: true, + filter: (src) => !src.endsWith('.d.ts'), + }); + await cp(join(root, 'logo.png'), join(stage, 'icon.png')); + + // A flat production install, the layout npm makes: `mcpb pack` follows + // symlinks, so pnpm's default layout would pack its whole store. + // --ignore-scripts keeps ffmpeg-static from downloading FFmpeg. + await execa( + 'pnpm', + ['install', '--prod', '--frozen-lockfile', '--ignore-scripts', '--config.node-linker=hoisted'], + { cwd: stage, stdio: 'inherit' }, + ); + const ffmpegStaticDir = join(stage, 'node_modules', 'ffmpeg-static'); + const binaries = (await readdir(ffmpegStaticDir)).filter((f) => /^ffmpeg(\.exe)?$/.test(f)); + if (binaries.length > 0) { + throw new Error( + `FFmpeg must not ship in the extension, but ${ffmpegStaticDir} has ${binaries}.`, + ); + } + + const manifest = JSON.parse(await readFile(join(root, 'mcpb', 'manifest.json'), 'utf8')) as { + version: string; + }; + manifest.version = version; + await writeFile(join(stage, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`); + await writeFile( + join(stage, '.mcpbignore'), + [ + 'pnpm-lock.yaml', + 'pnpm-workspace.yaml', + 'node_modules/.pnpm', + 'node_modules/.modules.yaml', + '', + ].join('\n'), + ); + + await mkdir(outDir, { recursive: true }); + // No version in the name, so `releases/latest/download/makemyclip-editor.mcpb` + // is a stable link to the newest one. + const output = join(outDir, 'makemyclip-editor.mcpb'); + const mcpb = { cwd: root, preferLocal: true, stdio: 'inherit' } as const; + await execa('mcpb', ['validate', join(stage, 'manifest.json')], mcpb); + await execa('mcpb', ['pack', stage, output], mcpb); + process.stdout.write(`${output}\n`); +} + +main().catch((err: unknown) => { + process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`); + process.exit(1); +}); diff --git a/src/ffmpeg/probe.ts b/src/ffmpeg/probe.ts index 25244bf..592da83 100644 --- a/src/ffmpeg/probe.ts +++ b/src/ffmpeg/probe.ts @@ -1,7 +1,5 @@ import { execa } from 'execa'; -import ffmpegStatic from 'ffmpeg-static'; - -const ffmpegPath = process.env.MAKEMYCLIP_FFMPEG_PATH ?? ffmpegStatic ?? 'ffmpeg'; +import { resolveFfmpeg } from './run.js'; export interface VideoStream { codec: string; @@ -32,7 +30,7 @@ export interface MediaProbe { */ export async function probe(inputPath: string): Promise { const { stderr, exitCode } = await execa( - ffmpegPath, + resolveFfmpeg().path, ['-hide_banner', '-i', inputPath, '-f', 'null', '-'], { reject: false, @@ -114,7 +112,7 @@ function parseAudioStream(stderr: string): AudioStream | null { * no decode, unlike `probe`. */ export async function probeTimecode(inputPath: string): Promise { - const { stderr } = await execa(ffmpegPath, ['-hide_banner', '-i', inputPath], { + const { stderr } = await execa(resolveFfmpeg().path, ['-hide_banner', '-i', inputPath], { reject: false, env: { ...process.env, LC_ALL: 'C' }, }); diff --git a/src/ffmpeg/run.ts b/src/ffmpeg/run.ts index 57d8545..c24d4bd 100644 --- a/src/ffmpeg/run.ts +++ b/src/ffmpeg/run.ts @@ -1,31 +1,38 @@ import { execa } from 'execa'; import ffmpegStatic from 'ffmpeg-static'; +import { envSetting, findSystemBinary, isExecutable, type SystemLookup } from '../system-binary.js'; export const FFMPEG_PATH_ENV = 'MAKEMYCLIP_FFMPEG_PATH'; export interface FfmpegResolution { path: string; - /** `env`: MAKEMYCLIP_FFMPEG_PATH; `bundled`: ffmpeg-static; `system`: `ffmpeg` on PATH. */ + /** `env`: MAKEMYCLIP_FFMPEG_PATH; `bundled`: ffmpeg-static; `system`: an installed `ffmpeg`. */ source: 'env' | 'bundled' | 'system'; } +export interface FfmpegLookup extends SystemLookup { + /** ffmpeg-static's binary path; null on a platform it has no build for. */ + bundled?: string | null; +} + /** - * Resolution order: explicit override → bundled binary → system PATH. The - * override exists so legally-cautious users can point at an LGPL-only or - * system-managed FFmpeg; see the License section in the README. + * Resolution order: explicit override → bundled binary → an installed FFmpeg. + * The override exists so legally-cautious users can point at an LGPL-only or + * system-managed FFmpeg; see the License section in the README. The bundled + * binary is skipped when it isn't on disk: ffmpeg-static downloads it in an + * install script, which `--ignore-scripts` skips and the Claude Desktop + * extension leaves out on purpose. */ -export function resolveFfmpeg( - env: Record = process.env, - bundled: string | null = ffmpegStatic, -): FfmpegResolution { - const override = env[FFMPEG_PATH_ENV]; +export function resolveFfmpeg(lookup: FfmpegLookup = {}): FfmpegResolution { + const override = envSetting(FFMPEG_PATH_ENV, lookup.env); if (override !== undefined) return { path: override, source: 'env' }; - if (bundled !== null) return { path: bundled, source: 'bundled' }; - return { path: 'ffmpeg', source: 'system' }; + const bundled = lookup.bundled !== undefined ? lookup.bundled : ffmpegStatic; + if (bundled !== null && (lookup.isExecutable ?? isExecutable)(bundled)) { + return { path: bundled, source: 'bundled' }; + } + return { path: findSystemBinary('ffmpeg', lookup), source: 'system' }; } -const ffmpegPath = resolveFfmpeg().path; - export interface FfmpegResult { stdout: string; stderr: string; @@ -33,13 +40,38 @@ export interface FfmpegResult { } export async function runFfmpeg(args: string[]): Promise { + // Resolved per run, so an FFmpeg installed while `clip mcp` or `clip ui` + // runs is picked up without a restart. + return runFfmpegAt(resolveFfmpeg().path, args); +} + +async function runFfmpegAt(path: string, args: string[]): Promise { const start = Date.now(); - const { stdout, stderr } = await execa(ffmpegPath, args, { - stdio: ['ignore', 'pipe', 'pipe'], - }); + const { stdout, stderr } = await execa(path, args, { stdio: ['ignore', 'pipe', 'pipe'] }); return { stdout, stderr, durationMs: Date.now() - start }; } +/** + * Memoize a per-binary answer, keyed by the FFmpeg it came from, so a newly + * resolved FFmpeg is asked again. A failure is retried on the next call, not + * cached for the life of a long-running `clip ui` or `clip mcp` process. + */ +function perBinary(ask: (path: string) => Promise): () => Promise { + const answers = new Map>(); + return () => { + const path = resolveFfmpeg().path; + let answer = answers.get(path); + if (!answer) { + answer = ask(path).catch((err: unknown) => { + answers.delete(path); + throw err; + }); + answers.set(path, answer); + } + return answer; + }; +} + /** Thrown before a run when the resolved FFmpeg lacks a filter the edit needs. */ export class MissingFilterError extends Error { readonly filter: string; @@ -58,21 +90,10 @@ export function parseFilterList(stdout: string): Set { return new Set([...stdout.matchAll(/^ \S+ (\S+)\s+\S*->\S*\s/gm)].map((m) => m[1] as string)); } -let filterList: Promise> | undefined; - -/** The resolved FFmpeg's filters, listed once per process. */ -export function listFilters(): Promise> { - filterList ??= runFfmpeg(['-hide_banner', '-filters']).then( - ({ stdout }) => parseFilterList(stdout), - (err: unknown) => { - // A failed listing is retried on the next call, not cached for the - // life of a long-running `clip ui` or `clip mcp` process. - filterList = undefined; - throw err; - }, - ); - return filterList; -} +/** The resolved FFmpeg's filters, listed once per binary. */ +export const listFilters: () => Promise> = perBinary(async (path) => + parseFilterList((await runFfmpegAt(path, ['-hide_banner', '-filters'])).stdout), +); /** The major version from `ffmpeg -version` ("ffmpeg version 6.0", "n7.0.2"), * or null for a build that doesn't say (a git snapshot). */ @@ -81,26 +102,18 @@ export function parseFfmpegMajor(stdout: string): number | null { return match ? Number(match[1]) : null; } -let graphOption: Promise | undefined; - /** * The option that reads a filtergraph from a file: `-/filter_complex` since * FFmpeg 7 (which deprecates the old one), `-filter_complex_script` before. A * build that doesn't name its version (a git snapshot) gets the old option * while its help still lists it. */ -export function filterGraphFileOption(): Promise { - graphOption ??= (async () => { - const major = parseFfmpegMajor((await runFfmpeg(['-hide_banner', '-version'])).stdout); - if (major !== null) return major < 7 ? '-filter_complex_script' : '-/filter_complex'; - const help = (await runFfmpeg(['-hide_banner', '-h', 'full'])).stdout; - return help.includes('-filter_complex_script') ? '-filter_complex_script' : '-/filter_complex'; - })().catch((err: unknown) => { - graphOption = undefined; - throw err; - }); - return graphOption; -} +export const filterGraphFileOption: () => Promise = perBinary(async (path) => { + const major = parseFfmpegMajor((await runFfmpegAt(path, ['-hide_banner', '-version'])).stdout); + if (major !== null) return major < 7 ? '-filter_complex_script' : '-/filter_complex'; + const help = (await runFfmpegAt(path, ['-hide_banner', '-h', 'full'])).stdout; + return help.includes('-filter_complex_script') ? '-filter_complex_script' : '-/filter_complex'; +}); /** * Fail before rendering text when FFmpeg can't draw it. Since FFmpeg 6.1 @@ -110,6 +123,7 @@ export function filterGraphFileOption(): Promise { */ export async function requireDrawtext(): Promise { if ((await listFilters()).has('drawtext')) return; + const ffmpegPath = resolveFfmpeg().path; throw new MissingFilterError( 'drawtext', ffmpegPath, diff --git a/src/index.ts b/src/index.ts index ffbb158..2d03181 100644 --- a/src/index.ts +++ b/src/index.ts @@ -55,6 +55,7 @@ export { } from './ffmpeg/probe.js'; export { FFMPEG_PATH_ENV, + type FfmpegLookup, type FfmpegResolution, MissingFilterError, resolveFfmpeg, @@ -91,6 +92,11 @@ export { type RevisionedStore, type RevisionedStoreConfig, } from './storage/revisioned-store.js'; +export { + envSetting, + findSystemBinary, + type SystemLookup, +} from './system-binary.js'; export { buildFrameAtPlan, type CompileContext, @@ -362,4 +368,5 @@ export { TranscriptionUnavailableError, WHISPER_MODEL_ENV, WHISPER_PATH_ENV, + whisperBinary, } from './transcribe/whisper.js'; diff --git a/src/mcp/server.ts b/src/mcp/server.ts index bd4af9f..2f39962 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -300,7 +300,7 @@ export function buildMcpServer(): McpServer { { title: 'Check the setup', description: - 'Check that this computer has what the editor needs: Node, FFmpeg and its text and stabilization filters, a writable workspace and a caption font, plus the optional local whisper.cpp and model for transcription. Each problem comes with its fix; settings named MAKEMYCLIP_* go in the env of this server in the MCP client config. Run it when the user asks whether their setup works, or when a tool fails with a setup error.', + 'Check that this computer has what the editor needs: Node, FFmpeg and its text and stabilization filters, a writable workspace and a caption font, plus the optional local whisper.cpp and model for transcription. Each problem comes with its fix, and howToSet says where the MAKEMYCLIP_* settings it names are changed. Run it when the user asks whether their setup works, or when a tool fails with a setup error.', }, async () => jsonResult(await doctor()), ); diff --git a/src/system-binary.ts b/src/system-binary.ts new file mode 100644 index 0000000..2022f61 --- /dev/null +++ b/src/system-binary.ts @@ -0,0 +1,75 @@ +import { accessSync, constants, statSync } from 'node:fs'; +import { posix, win32 } from 'node:path'; + +/** Where to look; tests pass stubs, everything else defaults to this process. */ +export interface SystemLookup { + env?: Record; + platform?: NodeJS.Platform; + isExecutable?: (path: string) => boolean; +} + +/** + * A setting read from the environment. Empty counts as unset: the Claude + * Desktop extension passes "" for a setting the user left blank. + */ +export function envSetting( + name: string, + env: Record = process.env, +): string | undefined { + const value = env[name]; + return value === undefined || value === '' ? undefined : value; +} + +export function isFile(path: string): boolean { + try { + return statSync(path).isFile(); + } catch { + return false; + } +} + +/** A file this process may run. Windows has no execute bit, so a file is enough there. */ +export function isExecutable(path: string): boolean { + if (!isFile(path)) return false; + if (process.platform === 'win32') return true; + try { + accessSync(path, constants.X_OK); + return true; + } catch { + return false; + } +} + +/** Package-manager folders that an app opened from the Dock or the Start menu + * often lacks on PATH, which is how Claude Desktop starts the extension. */ +function installDirs(platform: NodeJS.Platform, env: Record): string[] { + if (platform === 'darwin') return ['/opt/homebrew/bin', '/usr/local/bin', '/opt/local/bin']; + if (platform === 'win32') { + const dirs: string[] = []; + if (env.LOCALAPPDATA) dirs.push(win32.join(env.LOCALAPPDATA, 'Microsoft', 'WinGet', 'Links')); + if (env.USERPROFILE) dirs.push(win32.join(env.USERPROFILE, 'scoop', 'shims')); + dirs.push(win32.join(env.ProgramData ?? 'C:\\ProgramData', 'chocolatey', 'bin')); + return dirs; + } + return ['/usr/local/bin', '/usr/bin']; +} + +/** + * The full path of an installed `name` executable: the first one on PATH, then + * in Homebrew's, MacPorts', winget's, Scoop's or Chocolatey's folder. Returns + * the bare name when none is found, so the OS lookup (and its error) applies. + */ +export function findSystemBinary(name: string, lookup: SystemLookup = {}): string { + const env = lookup.env ?? process.env; + const platform = lookup.platform ?? process.platform; + const runnable = lookup.isExecutable ?? isExecutable; + const path = platform === 'win32' ? win32 : posix; + const pathVar = (platform === 'win32' ? (env.Path ?? env.PATH) : env.PATH) ?? ''; + const file = platform === 'win32' ? `${name}.exe` : name; + const dirs = [...pathVar.split(path.delimiter).filter(Boolean), ...installDirs(platform, env)]; + for (const dir of dirs) { + const candidate = path.join(dir, file); + if (runnable(candidate)) return candidate; + } + return name; +} diff --git a/src/tools/doctor.ts b/src/tools/doctor.ts index aef2312..2c6e08f 100644 --- a/src/tools/doctor.ts +++ b/src/tools/doctor.ts @@ -1,5 +1,4 @@ import { randomBytes } from 'node:crypto'; -import { statSync } from 'node:fs'; import { mkdir, unlink, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { execa } from 'execa'; @@ -7,7 +6,8 @@ import ffmpegStatic from 'ffmpeg-static'; import { z } from 'zod'; import { fontFileFor } from '../ffmpeg/font.js'; import { FFMPEG_PATH_ENV, parseFilterList, resolveFfmpeg } from '../ffmpeg/run.js'; -import { WHISPER_MODEL_ENV, WHISPER_PATH_ENV } from '../transcribe/whisper.js'; +import { envSetting, isExecutable, isFile } from '../system-binary.js'; +import { WHISPER_MODEL_ENV, WHISPER_PATH_ENV, whisperBinary } from '../transcribe/whisper.js'; import { getWorkspace } from '../workspace.js'; /** @@ -48,6 +48,8 @@ export interface DoctorResult { /** False when any check failed. Warnings leave it true. */ ok: boolean; checks: DoctorCheck[]; + /** Where the MAKEMYCLIP_* settings named in the fixes are changed. */ + howToSet: string; } /** What the checks read from the machine; tests pass stubs. */ @@ -63,6 +65,7 @@ export interface DoctorDeps { * isn't found; `exitCode`, `stdout` and `stderr` set when it exited non-zero. */ run(file: string, args: string[]): Promise<{ stdout: string }>; isFile(path: string): boolean; + isExecutable(path: string): boolean; /** Create the folder if needed, then write and remove a file in it. */ probeWritable(dir: string): Promise; /** The font file captions draw Latin text with; undefined means FFmpeg's default. */ @@ -73,6 +76,8 @@ export interface DoctorDeps { const MIN_NODE_MAJOR = 24; const WORKSPACE_ENV = 'MAKEMYCLIP_WORKSPACE'; const FALLBACK_FONT_ENV = 'MAKEMYCLIP_FALLBACK_FONT'; +/** Set to `mcpb` by the Claude Desktop extension's manifest. */ +const BUNDLE_ENV = 'MAKEMYCLIP_BUNDLE'; export function defaultDoctorDeps(): DoctorDeps { return { @@ -88,13 +93,8 @@ export function defaultDoctorDeps(): DoctorDeps { }); return { stdout }; }, - isFile: (path) => { - try { - return statSync(path).isFile(); - } catch { - return false; - } - }, + isFile, + isExecutable, probeWritable: async (dir) => { await mkdir(dir, { recursive: true }); const probe = join(dir, `.doctor-${randomBytes(4).toString('hex')}`); @@ -120,7 +120,17 @@ export async function doctor( await checkWhisper(deps), checkWhisperModel(deps), ]; - return { ok: checks.every((c) => c.status !== 'fail'), checks }; + return { + ok: checks.every((c) => c.status !== 'fail'), + checks, + howToSet: inExtension(deps) + ? "Change these in Claude Desktop, in the MakeMyClip Editor extension's settings: Media folder is MAKEMYCLIP_WORKSPACE, FFmpeg is MAKEMYCLIP_FFMPEG_PATH, Whisper model is MAKEMYCLIP_WHISPER_MODEL and whisper-cli is MAKEMYCLIP_WHISPER_PATH." + : 'Set environment variables in your shell profile; for `clip mcp`, set them in the MCP client config for the server.', + }; +} + +function inExtension(deps: DoctorDeps): boolean { + return deps.env[BUNDLE_ENV] === 'mcpb'; } function checkNode(version: string): DoctorCheck { @@ -138,7 +148,7 @@ function checkNode(version: string): DoctorCheck { const SOURCE_NAMES = { env: FFMPEG_PATH_ENV, bundled: 'bundled ffmpeg-static', - system: 'ffmpeg on PATH', + system: 'system', } as const; function installFfmpegHint(platform: NodeJS.Platform): string { @@ -150,7 +160,12 @@ function installFfmpegHint(platform: NodeJS.Platform): string { async function checkFfmpeg( deps: DoctorDeps, ): Promise<{ check: DoctorCheck; filters: Set | null }> { - const { path, source } = resolveFfmpeg(deps.env, deps.bundledFfmpeg); + const { path, source } = resolveFfmpeg({ + env: deps.env, + platform: deps.platform, + isExecutable: deps.isExecutable, + bundled: deps.bundledFfmpeg, + }); const base = { id: 'ffmpeg', label: 'FFmpeg' } as const; try { const { stdout } = await deps.run(path, ['-hide_banner', '-version']); @@ -163,17 +178,20 @@ async function checkFfmpeg( } catch (err) { const install = installFfmpegHint(deps.platform); let fix: string; + const found = `It is found on PATH and in Homebrew's, MacPorts' and winget's folders; or set ${FFMPEG_PATH_ENV} to its path.`; if (source === 'env') { - fix = `Point ${FFMPEG_PATH_ENV} at a working ffmpeg binary, or unset it to use the bundled one.`; - } else if (source === 'bundled' && !deps.isFile(path)) { - fix = - 'The bundled FFmpeg was never downloaded: ffmpeg-static fetches it in an install script, ' + - "which was skipped (npm's --ignore-scripts, or a package manager that blocks build scripts). " + - `Reinstall with install scripts allowed, or install FFmpeg (${install}) and set ${FFMPEG_PATH_ENV} to its path.`; + fix = `Point ${FFMPEG_PATH_ENV} at a working ffmpeg binary, or unset it.`; } else if (source === 'bundled') { fix = `Install FFmpeg (${install}) and set ${FFMPEG_PATH_ENV} to its path.`; + } else if (inExtension(deps)) { + fix = `The Claude Desktop extension doesn't include FFmpeg: install it (${install}). ${found}`; + } else if (deps.bundledFfmpeg !== null) { + fix = + "ffmpeg-static's FFmpeg isn't installed: it downloads in an install script, which was skipped " + + "(npm's --ignore-scripts, or a package manager that blocks build scripts). " + + `Reinstall with install scripts allowed, or install FFmpeg (${install}). ${found}`; } else { - fix = `ffmpeg-static has no build for this platform. Install FFmpeg (${install}) so \`ffmpeg\` is on PATH, or set ${FFMPEG_PATH_ENV} to its path.`; + fix = `ffmpeg-static has no build for this platform. Install FFmpeg (${install}). ${found}`; } return { check: { @@ -220,7 +238,7 @@ function checkVidstab(filters: Set | null): DoctorCheck { async function checkWorkspace(deps: DoctorDeps): Promise { const base = { id: 'workspace', label: 'Workspace' } as const; - const where = deps.env[WORKSPACE_ENV] === undefined ? 'default' : WORKSPACE_ENV; + const where = envSetting(WORKSPACE_ENV, deps.env) === undefined ? 'default' : WORKSPACE_ENV; try { await deps.probeWritable(deps.workspace); return { ...base, status: 'ok', detail: `${deps.workspace} (${where})` }; @@ -264,9 +282,12 @@ const WHISPER_PURPOSE = 'Optional: transcript cuts, retake removal and captions async function checkWhisper(deps: DoctorDeps): Promise { const base = { id: 'whisper', label: 'whisper.cpp (optional)' } as const; - const explicit = deps.env[WHISPER_PATH_ENV]; - const binary = explicit ?? 'whisper-cli'; - const where = explicit === undefined ? 'on PATH' : WHISPER_PATH_ENV; + const { path: binary, source } = whisperBinary({ + env: deps.env, + platform: deps.platform, + isExecutable: deps.isExecutable, + }); + const where = source === 'env' ? WHISPER_PATH_ENV : 'installed'; try { await deps.run(binary, ['--help']); } catch (err) { @@ -282,9 +303,9 @@ async function checkWhisper(deps: DoctorDeps): Promise { status: 'warn', detail: `${binary} (${where}) doesn't run: ${reason(err)}. ${WHISPER_PURPOSE}`, fix: - explicit === undefined - ? `Install or reinstall whisper.cpp (${install}). If whisper-cli isn't on PATH, set ${WHISPER_PATH_ENV} to it.` - : `Point ${WHISPER_PATH_ENV} at the whisper-cli binary, or unset it to use whisper-cli on PATH.`, + source === 'system' + ? `Install or reinstall whisper.cpp (${install}). whisper-cli is found on PATH and in Homebrew's folder; or set ${WHISPER_PATH_ENV} to it.` + : `Point ${WHISPER_PATH_ENV} at the whisper-cli binary, or unset it.`, }; } } @@ -353,11 +374,7 @@ export function formatDoctorReport(result: DoctorResult): string { else if (warns > 0) lines.push(`Ready. ${plural(warns, 'warning')}: those features stay off until fixed.`); else lines.push('Ready. Everything is set up.'); - if (fails + warns > 0) { - lines.push( - 'Set environment variables in your shell profile; for `clip mcp`, set them in the MCP client config for the server.', - ); - } + if (fails + warns > 0) lines.push(result.howToSet); return `${lines.join('\n')}\n`; } diff --git a/src/transcribe/whisper.ts b/src/transcribe/whisper.ts index 3704d7a..09d309c 100644 --- a/src/transcribe/whisper.ts +++ b/src/transcribe/whisper.ts @@ -1,6 +1,7 @@ import { existsSync } from 'node:fs'; import { execa } from 'execa'; import { z } from 'zod'; +import { envSetting, findSystemBinary, type SystemLookup } from '../system-binary.js'; /** * Local speech-to-text through whisper.cpp, run as a subprocess (args as an @@ -38,6 +39,16 @@ export interface WhisperSetup { model: string; } +/** The whisper.cpp CLI: `MAKEMYCLIP_WHISPER_PATH`, else an installed `whisper-cli`. */ +export function whisperBinary(lookup: SystemLookup = {}): { + path: string; + source: 'env' | 'system'; +} { + const explicit = envSetting(WHISPER_PATH_ENV, lookup.env); + if (explicit !== undefined) return { path: explicit, source: 'env' }; + return { path: findSystemBinary('whisper-cli', lookup), source: 'system' }; +} + /** Locate whisper.cpp and a model: an explicit model path, then the env vars. */ export function resolveWhisper(model?: string): WhisperSetup { const modelPath = model ?? process.env[WHISPER_MODEL_ENV]; @@ -45,7 +56,7 @@ export function resolveWhisper(model?: string): WhisperSetup { if (!existsSync(modelPath)) { throw new TranscriptionUnavailableError(`The whisper model ${modelPath} does not exist.`); } - return { binary: process.env[WHISPER_PATH_ENV] ?? 'whisper-cli', model: modelPath }; + return { binary: whisperBinary().path, model: modelPath }; } export interface WhisperArgs { diff --git a/src/workspace.ts b/src/workspace.ts index ed62e46..f15808a 100644 --- a/src/workspace.ts +++ b/src/workspace.ts @@ -1,13 +1,20 @@ import { randomBytes } from 'node:crypto'; import { lstatSync, readlinkSync, realpathSync } from 'node:fs'; import { mkdir } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; +import { homedir, tmpdir } from 'node:os'; import { basename, dirname, isAbsolute, relative, resolve, sep } from 'node:path'; +import { envSetting } from './system-binary.js'; const WORKSPACE_ENV = 'MAKEMYCLIP_WORKSPACE'; export function getWorkspace(): string { - return process.env[WORKSPACE_ENV] ?? resolve(tmpdir(), 'makemyclip-editor'); + const explicit = envSetting(WORKSPACE_ENV); + if (explicit !== undefined) return explicit; + // Claude Desktop extension users drop their videos into this folder + // themselves, so it goes where they can find it rather than the temp dir. + if (process.env.MAKEMYCLIP_BUNDLE === 'mcpb') + return resolve(homedir(), 'Documents', 'MakeMyClip'); + return resolve(tmpdir(), 'makemyclip-editor'); } export async function ensureWorkspace(): Promise { diff --git a/tests/doctor.test.ts b/tests/doctor.test.ts index 62b7909..1fbde32 100644 --- a/tests/doctor.test.ts +++ b/tests/doctor.test.ts @@ -47,7 +47,8 @@ function deps(overrides: Partial = {}): DoctorDeps & { calls: string if (args.includes('-filters')) return { stdout: FILTERS }; return { stdout: 'usage: whisper-cli [options] file0 file1 ...\n' }; }, - isFile: () => true, + isFile: (path) => path.endsWith('.bin'), + isExecutable: (path) => path === BUNDLED, probeWritable: async () => undefined, captionFont: () => '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', ...overrides, @@ -61,13 +62,38 @@ function check(checks: DoctorCheck[], id: DoctorCheckId): DoctorCheck { } describe('resolveFfmpeg', () => { - it('prefers the env override, then the bundled binary, then PATH', () => { - expect(resolveFfmpeg({ MAKEMYCLIP_FFMPEG_PATH: '/opt/ffmpeg' }, BUNDLED)).toEqual({ + const lookup = { + env: { PATH: '/usr/bin' }, + platform: 'darwin' as const, + bundled: BUNDLED, + isExecutable: (path: string) => path === BUNDLED || path === '/opt/homebrew/bin/ffmpeg', + }; + + it('prefers the env override, then the bundled binary, then an installed FFmpeg', () => { + expect(resolveFfmpeg({ ...lookup, env: { MAKEMYCLIP_FFMPEG_PATH: '/opt/ffmpeg' } })).toEqual({ path: '/opt/ffmpeg', source: 'env', }); - expect(resolveFfmpeg({}, BUNDLED)).toEqual({ path: BUNDLED, source: 'bundled' }); - expect(resolveFfmpeg({}, null)).toEqual({ path: 'ffmpeg', source: 'system' }); + expect(resolveFfmpeg(lookup)).toEqual({ path: BUNDLED, source: 'bundled' }); + expect(resolveFfmpeg({ ...lookup, bundled: null })).toEqual({ + path: '/opt/homebrew/bin/ffmpeg', + source: 'system', + }); + }); + + it('skips a bundled binary that is not on disk, and an empty override', () => { + const missing = { + ...lookup, + isExecutable: (path: string) => path === '/opt/homebrew/bin/ffmpeg', + }; + expect(resolveFfmpeg(missing)).toEqual({ path: '/opt/homebrew/bin/ffmpeg', source: 'system' }); + expect(resolveFfmpeg({ ...lookup, env: { MAKEMYCLIP_FFMPEG_PATH: '' } }).source).toBe( + 'bundled', + ); + expect(resolveFfmpeg({ ...missing, isExecutable: () => false })).toEqual({ + path: 'ffmpeg', + source: 'system', + }); }); }); @@ -122,7 +148,7 @@ describe('doctor', () => { expect(check(result.checks, 'ffmpeg')).toMatchObject({ status: 'fail', detail: "/nope/ffmpeg (MAKEMYCLIP_FFMPEG_PATH) doesn't run: not found.", - fix: expect.stringContaining('unset it to use the bundled one'), + fix: 'Point MAKEMYCLIP_FFMPEG_PATH at a working ffmpeg binary, or unset it.', }); expect(check(result.checks, 'drawtext').status).toBe('skip'); expect(check(result.checks, 'vidstab').status).toBe('skip'); @@ -132,7 +158,7 @@ describe('doctor', () => { const result = await doctor( {}, deps({ - isFile: (path) => path !== BUNDLED, + isExecutable: () => false, run: async () => { throw spawnError('ENOENT'); }, @@ -159,11 +185,49 @@ describe('doctor', () => { expect(check(result.checks, 'ffmpeg')).toMatchObject({ status: 'fail', - detail: "ffmpeg (ffmpeg on PATH) doesn't run: not found.", + detail: "ffmpeg (system) doesn't run: not found.", fix: expect.stringContaining('`brew install ffmpeg`'), }); }); + it("finds Homebrew's FFmpeg and whisper-cli for the Claude Desktop extension", async () => { + const homebrew = ['/opt/homebrew/bin/ffmpeg', '/opt/homebrew/bin/whisper-cli']; + const d = deps({ + env: { MAKEMYCLIP_BUNDLE: 'mcpb', PATH: '/usr/bin:/bin', MAKEMYCLIP_WHISPER_MODEL: '' }, + platform: 'darwin', + isExecutable: (path) => homebrew.includes(path), + }); + const result = await doctor({}, d); + + expect(check(result.checks, 'ffmpeg').detail).toBe('7.1.1 (system) /opt/homebrew/bin/ffmpeg'); + expect(check(result.checks, 'whisper').detail).toBe( + '/opt/homebrew/bin/whisper-cli (installed)', + ); + expect(check(result.checks, 'whisper-model').detail).toContain('is not set'); + expect(result.howToSet).toContain("MakeMyClip Editor extension's settings"); + expect(d.calls.map((c) => c[0])).toEqual([homebrew[0], homebrew[0], homebrew[1]]); + }); + + it('tells extension users to install FFmpeg, which the bundle leaves out', async () => { + const result = await doctor( + {}, + deps({ + env: { MAKEMYCLIP_BUNDLE: 'mcpb', MAKEMYCLIP_FFMPEG_PATH: '' }, + platform: 'win32', + isExecutable: () => false, + run: async () => { + throw spawnError('ENOENT'); + }, + }), + ); + + expect(check(result.checks, 'ffmpeg')).toMatchObject({ + status: 'fail', + fix: expect.stringMatching(/doesn't include FFmpeg.*winget install Gyan\.FFmpeg/), + }); + expect(formatDoctorReport(result)).toContain('FFmpeg is MAKEMYCLIP_FFMPEG_PATH'); + }); + it('warns, without failing, when FFmpeg lacks drawtext or vidstab', async () => { const result = await doctor( {}, diff --git a/tests/mcpb-manifest.test.ts b/tests/mcpb-manifest.test.ts new file mode 100644 index 0000000..42fb8ff --- /dev/null +++ b/tests/mcpb-manifest.test.ts @@ -0,0 +1,81 @@ +import { readFileSync } from 'node:fs'; +import { getMcpConfigForManifest, v0_3 } from '@anthropic-ai/mcpb'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; +import { describe, expect, it } from 'vitest'; +import { FFMPEG_PATH_ENV } from '../src/ffmpeg/run.js'; +import { buildMcpServer } from '../src/mcp/server.js'; +import { WHISPER_MODEL_ENV, WHISPER_PATH_ENV } from '../src/transcribe/whisper.js'; + +const manifest = JSON.parse( + readFileSync(new URL('../mcpb/manifest.json', import.meta.url), 'utf8'), +) as v0_3.McpbManifest; + +const systemDirs = { + HOME: '/Users/me', + DESKTOP: '/Users/me/Desktop', + DOCUMENTS: '/Users/me/Documents', + DOWNLOADS: '/Users/me/Downloads', +}; + +/** The command and env Claude Desktop starts the server with, per the reference implementation. */ +async function launchConfig(userConfig: Record) { + const config = await getMcpConfigForManifest({ + manifest, + extensionPath: '/ext', + systemDirs, + userConfig, + pathSeparator: '/', + }); + if (!config) throw new Error('no MCP config'); + return config; +} + +describe('Claude Desktop extension manifest', () => { + it('passes the mcpb 0.3 schema', () => { + expect(v0_3.McpbManifestSchema.safeParse(manifest).error).toBeUndefined(); + }); + + it("lists exactly the MCP server's tools", async () => { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const server = buildMcpServer(); + await server.connect(serverTransport); + const client = new Client({ name: 'test', version: '1.0.0' }); + await client.connect(clientTransport); + const { tools } = await client.listTools(); + await client.close(); + await server.close(); + + expect((manifest.tools ?? []).map((t) => t.name).sort()).toEqual( + tools.map((t) => t.name).sort(), + ); + }); + + it('starts `clip mcp` with blank optional settings as empty env vars', async () => { + const config = await launchConfig({}); + + expect(config.command).toBe('node'); + expect(config.args).toEqual(['/ext/dist/cli.js', 'mcp']); + expect(config.env).toEqual({ + MAKEMYCLIP_BUNDLE: 'mcpb', + MAKEMYCLIP_WORKSPACE: '', + [FFMPEG_PATH_ENV]: '', + [WHISPER_MODEL_ENV]: '', + [WHISPER_PATH_ENV]: '', + }); + }); + + it('passes the settings the user picked', async () => { + const config = await launchConfig({ + workspace: '/Users/me/Movies', + ffmpeg_path: '/opt/ffmpeg/bin/ffmpeg', + whisper_model: '/models/ggml-base.en.bin', + }); + + expect(config.env).toMatchObject({ + MAKEMYCLIP_WORKSPACE: '/Users/me/Movies', + [FFMPEG_PATH_ENV]: '/opt/ffmpeg/bin/ffmpeg', + [WHISPER_MODEL_ENV]: '/models/ggml-base.en.bin', + }); + }); +}); diff --git a/tests/require-drawtext.test.ts b/tests/require-drawtext.test.ts index b965007..88ed911 100644 --- a/tests/require-drawtext.test.ts +++ b/tests/require-drawtext.test.ts @@ -1,5 +1,5 @@ import { existsSync } from 'node:fs'; -import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -130,6 +130,18 @@ describe.skipIf(process.platform === 'win32')('requireDrawtext', () => { expect(await calls()).toEqual([noText.steps[0]?.args]); }); + it('lists filters again when a different FFmpeg resolves', async () => { + const { addText, MissingFilterError } = await load(LIST_WITHOUT_DRAWTEXT); + const hi = { input: 'in.mp4', text: 'Hi', startSec: 0, endSec: 1 }; + await expect(addText(hi)).rejects.toThrow(MissingFilterError); + + const other = join(dir, 'other'); + await mkdir(other); + process.env.MAKEMYCLIP_FFMPEG_PATH = await installFakeFfmpeg(other, LIST_WITH_DRAWTEXT); + + await expect(addText(hi)).resolves.toBeDefined(); + }); + it('renders text when the filter is there', async () => { const { addText, calls } = await load(LIST_WITH_DRAWTEXT); diff --git a/tests/system-binary.test.ts b/tests/system-binary.test.ts new file mode 100644 index 0000000..4c83a17 --- /dev/null +++ b/tests/system-binary.test.ts @@ -0,0 +1,68 @@ +import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { envSetting, findSystemBinary } from '../src/system-binary.js'; + +describe('envSetting', () => { + it('treats an empty value as unset', () => { + expect(envSetting('X', { X: '/a' })).toBe('/a'); + expect(envSetting('X', { X: '' })).toBeUndefined(); + expect(envSetting('X', {})).toBeUndefined(); + }); +}); + +describe('findSystemBinary', () => { + it('takes the first match on PATH before the install folders', () => { + const files = ['/home/me/bin/ffmpeg', '/opt/homebrew/bin/ffmpeg']; + const lookup = { + env: { PATH: '/usr/bin:/home/me/bin' }, + platform: 'darwin' as const, + isExecutable: (path: string) => files.includes(path), + }; + expect(findSystemBinary('ffmpeg', lookup)).toBe('/home/me/bin/ffmpeg'); + expect(findSystemBinary('ffmpeg', { ...lookup, env: { PATH: '/usr/bin:/bin' } })).toBe( + '/opt/homebrew/bin/ffmpeg', + ); + }); + + it("looks in winget's links folder for an .exe on Windows", () => { + const exe = 'C:\\Users\\me\\AppData\\Local\\Microsoft\\WinGet\\Links\\ffmpeg.exe'; + const found = findSystemBinary('ffmpeg', { + env: { Path: 'C:\\Windows\\system32', LOCALAPPDATA: 'C:\\Users\\me\\AppData\\Local' }, + platform: 'win32', + isExecutable: (path) => path === exe, + }); + expect(found).toBe(exe); + }); + + it('returns the bare name when nothing is installed', () => { + expect( + findSystemBinary('whisper-cli', { + env: { PATH: '/usr/bin' }, + platform: 'linux', + isExecutable: () => false, + }), + ).toBe('whisper-cli'); + }); + + it.skipIf(process.platform === 'win32')( + 'skips a file on PATH that is not executable', + async () => { + const dir = await mkdtemp(join(tmpdir(), 'mmc-which-')); + try { + const [first, second] = [join(dir, 'a'), join(dir, 'b')]; + await mkdir(first); + await mkdir(second); + await writeFile(join(first, 'ffmpeg'), ''); + await writeFile(join(second, 'ffmpeg'), '#!/bin/sh\n'); + await chmod(join(second, 'ffmpeg'), 0o755); + const env = { PATH: `${first}:${second}` }; + + expect(findSystemBinary('ffmpeg', { env, platform: 'linux' })).toBe(join(second, 'ffmpeg')); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }, + ); +}); diff --git a/tests/timeline-transcript.test.ts b/tests/timeline-transcript.test.ts index aea35d9..e4c69de 100644 --- a/tests/timeline-transcript.test.ts +++ b/tests/timeline-transcript.test.ts @@ -123,7 +123,11 @@ describe('whisper.cpp', () => { const model = join(workspace, 'ggml-tiny.en.bin'); await writeFile(model, 'x'); process.env.MAKEMYCLIP_WHISPER_MODEL = model; - expect(resolveWhisper()).toEqual({ binary: 'whisper-cli', model }); + // An installed whisper-cli resolves to its full path (Homebrew's on a Mac). + expect(resolveWhisper()).toEqual({ + binary: expect.stringMatching(/whisper-cli(\.exe)?$/), + model, + }); process.env.MAKEMYCLIP_WHISPER_PATH = '/opt/whisper/bin/whisper-cli'; expect(resolveWhisper().binary).toBe('/opt/whisper/bin/whisper-cli'); }); diff --git a/tests/workspace.test.ts b/tests/workspace.test.ts index 365cae6..7923844 100644 --- a/tests/workspace.test.ts +++ b/tests/workspace.test.ts @@ -1,5 +1,5 @@ import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; +import { homedir, tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { appendOp } from '../src/session/store.js'; @@ -8,7 +8,12 @@ import { applyOps, mediaClip, videoTrack } from '../src/timeline/ops.js'; import type { MediaId } from '../src/timeline/schema.js'; import { makeVerbContext } from '../src/ui/timeline-tools.js'; import { TOOL_REGISTRY } from '../src/ui/tool-registry.js'; -import { resolveInput, resolveInWorkspace, WorkspaceBoundaryError } from '../src/workspace.js'; +import { + getWorkspace, + resolveInput, + resolveInWorkspace, + WorkspaceBoundaryError, +} from '../src/workspace.js'; const M = 'm_aaaaaaaaaaaa' as MediaId; let workspace: string; @@ -26,6 +31,23 @@ afterEach(async () => { await rm(workspace, { recursive: true, force: true }); }); +describe('getWorkspace', () => { + it('falls back to the default for an empty MAKEMYCLIP_WORKSPACE', () => { + process.env.MAKEMYCLIP_WORKSPACE = ''; + expect(getWorkspace()).toBe(resolve(tmpdir(), 'makemyclip-editor')); + }); + + it('defaults to Documents/MakeMyClip in the Claude Desktop extension', () => { + process.env.MAKEMYCLIP_WORKSPACE = ''; + process.env.MAKEMYCLIP_BUNDLE = 'mcpb'; + try { + expect(getWorkspace()).toBe(resolve(homedir(), 'Documents', 'MakeMyClip')); + } finally { + delete process.env.MAKEMYCLIP_BUNDLE; + } + }); +}); + describe('resolveInWorkspace (untrusted-input confinement)', () => { it('accepts an absolute path inside the workspace', () => { const p = resolve(workspace, 'imports/clip.mp4');