@coppsary/motionly 1.0.2 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -210,18 +210,20 @@ Open Motionly Assistant beside Assets, enter your own provider key, and describe
210
210
 
211
211
  **Model quality matters.** The AI model you choose directly affects composition, timing, and correct preset use. Smaller models may generate valid syntax but weaker visual decisions. Prefer a current, high-capability model with strong code-generation and instruction-following performance.
212
212
 
213
- For agents working inside the repository, use these files:
213
+ For coding agents working inside a project, install the skill with `npx @coppsary/motionly skills add` (or `npx @coppsary/motionly init`, which asks which agent). This writes the `SKILL.md` contract plus a full `references/` library into your agent's folder — Codex (`.agents/`), Claude Code (`.claude/`), Gemini CLI (`.gemini/`), opencode (`.opencode/`), or Kiro (`.kiro/`):
214
214
 
215
215
  | Path | Purpose |
216
216
  |---|---|
217
- | `AGENTS.md` | Product scope and core `.motion` syntax |
218
- | `.agents/skills/write-motionly/SKILL.md` | Storyboard, timing, composition, asset, and validation workflow |
219
- | `.agents/skills/write-motionly/references/motion-syntax.md` | Supported syntax and preset reference |
220
- | `docs/agents/ai-authoring.mdx` | Prompting and project setup guide |
217
+ | `<agent>/skills/motionly/SKILL.md` | Quick `.motion` contract |
218
+ | `<agent>/skills/motionly/references/llms.txt` | Discovery index for the focused skills |
219
+ | `<agent>/skills/motionly/references/skills/*/SKILL.md` | Full library: `motion-dsl`, `svg`, `animation`, `timeline`, and more |
220
+
221
+ Point the agent at `AGENTS.md` and the installed `SKILL.md`, then let it load `references/llms.txt` and the reference skills the task needs. Working inside a clone of this repository instead? The same guidance lives at `AGENTS.md` and `.agents/skills/write-motionly/`. See the [AI Authoring Guide](docs/agents/ai-authoring.mdx) for prompting details.
221
222
 
222
223
  Use this short prompt with an LLM or agent working inside the repository:
223
224
 
224
225
  ```text
226
+ /motionly
225
227
  Read AGENTS.md and .agents/skills/write-motionly/SKILL.md first.
226
228
  Inspect my assets, storyboard the animation, then create a valid .motion project.
227
229
  Use only supported Motionly syntax and presets. Keep one focal subject per shot,
@@ -317,79 +319,29 @@ Known limitations:
317
319
 
318
320
  ---
319
321
 
320
- ## Install from npm
321
-
322
- Use the public `motionly` package from the npm registry. You do not need to clone this repository or download a tarball. Motionly requires Node.js `20.19.0` or newer.
323
-
324
- ```bash
325
- npx motionly init demo
326
- ```
327
-
328
- For the easiest project setup:
329
-
330
- 1. Enter `2` to install the skills inside the new project.
331
- 2. Enter `1` to install them for all supported agents.
332
-
333
- The complete terminal session looks like this:
334
-
335
- ```text
336
- $ npx motionly init demo
337
- Created /path/to/workspace/demo
338
-
339
- Install Motionly agent skills?
340
- 1. Skip
341
- 2. Project — inside the new project
342
- 3. Global — every project for this user
343
- Select [1]: 2
344
-
345
- Which agents should receive the Motionly skill?
346
- 1. All supported agents
347
- 2. Claude Code
348
- 3. Codex
349
- 4. Gemini CLI
350
- 5. Kiro CLI
351
- Select [1]: 1
352
-
353
- Added claude: /path/to/workspace/demo/.claude/skills/motionly/SKILL.md
354
- Added codex: /path/to/workspace/demo/.agents/skills/motionly/SKILL.md
355
- Added gemini: /path/to/workspace/demo/.gemini/skills/motionly/SKILL.md
356
- Added kiro: /path/to/workspace/demo/.kiro/skills/motionly/SKILL.md
357
-
358
- To reopen later: cd demo && npx motionly dev
359
-
360
- Motionly is running.
361
- Open this URL in your browser: http://localhost:4173/editor
362
- Project: /path/to/workspace/demo
363
- Press Ctrl+C to stop.
364
- ```
322
+ ## Install and use
365
323
 
366
- Open `http://localhost:4173/editor` in your browser. Keep the terminal running while you edit and
367
- press `Ctrl+C` when finished.
324
+ Motionly ships on npm — no clone and no global install. It needs Node.js `20.19.0` or newer, and every command runs through `npx`.
368
325
 
369
- Other wizard choices:
370
-
371
- - Choose **Skip** to create the project without agent skills.
372
- - Choose **Global** to make the selected skills available to every project for your user.
373
- - Choose one agent instead of **All supported agents** if you only use that agent.
374
-
375
- To install skills without creating a project:
326
+ ### Create a project
376
327
 
377
328
  ```bash
378
- motionly skills add
329
+ npx @coppsary/motionly init demo
379
330
  ```
380
331
 
381
- For scripts and CI, skip the wizard with flags:
332
+ `init` scaffolds the project and asks **which agent you're using**, then installs the Motionly agent skill for just that one (or choose "All supported agents"). Supported agents: **Codex, Claude Code, Gemini CLI, opencode, and Kiro**. The install is not a single file: it writes the `SKILL.md` contract plus a full `references/` library (`motion-dsl`, `svg`, `animation`, `timeline`, and more) with an `llms.txt` discovery index.
333
+
334
+ Skip the prompt with flags:
382
335
 
383
336
  ```bash
384
- motionly skills add --all --scope project
385
- motionly skills add --provider codex --scope global
337
+ npx @coppsary/motionly init demo --provider opencode # install for one agent, no prompt
338
+ npx @coppsary/motionly init demo --all # every supported agent
339
+ npx @coppsary/motionly init demo --skip-skills # no agent skills
386
340
  ```
387
341
 
388
- Supported provider names are `claude`, `codex`, `gemini`, and `kiro`.
342
+ Provider names are `codex`, `claude`, `gemini`, `opencode`, and `kiro`. Use `--scope project` (default) or `--scope global` to install for every project on your machine.
389
343
 
390
- ### 3. Project layout
391
-
392
- The project contains:
344
+ The scaffolded project:
393
345
 
394
346
  ```text
395
347
  demo/
@@ -400,29 +352,24 @@ demo/
400
352
  └── README.md
401
353
  ```
402
354
 
403
- ### 4. Reopen the project later
355
+ ### Add skills to an existing project
404
356
 
405
357
  ```bash
406
- cd demo
407
- motionly dev
358
+ npx @coppsary/motionly skills add # pick scope and agents
359
+ npx @coppsary/motionly skills add --all --scope project
360
+ npx @coppsary/motionly skills add --provider codex --scope global
408
361
  ```
409
362
 
410
- Motionly opens `http://localhost:4173/editor`, loads `project.motion`, serves files from `assets/`, and saves editor changes back to the project. Use `--port <n>` to change the port or `--no-open` to skip opening the browser.
363
+ Re-running is safe: existing skill files are kept, never overwritten.
411
364
 
412
- ### Run without a global installation
413
-
414
- You can use the public package directly through `npx` instead:
365
+ ### Open and edit
415
366
 
416
367
  ```bash
417
- npx motionly --help
418
- npx motionly init my-video
368
+ cd demo
369
+ npx @coppsary/motionly dev
419
370
  ```
420
371
 
421
- For the no-setup browser editor:
422
-
423
- ```bash
424
- npx motionly
425
- ```
372
+ Motionly opens `http://localhost:4173/editor`, loads `project.motion`, serves media from `assets/`, and saves editor changes back to the project. Add `--port <n>` to change the port or `--no-open` to skip launching the browser. For the no-setup browser editor, run `npx @coppsary/motionly`.
426
373
 
427
374
  ## Development from source
428
375
 
@@ -0,0 +1,263 @@
1
+ import { Buffer } from 'node:buffer';
2
+ import { spawn } from 'node:child_process';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { createReadStream, createWriteStream } from 'node:fs';
5
+ import { mkdtemp, rm, stat } from 'node:fs/promises';
6
+ import { tmpdir } from 'node:os';
7
+ import { join } from 'node:path';
8
+ import { Transform } from 'node:stream';
9
+ import { pipeline } from 'node:stream/promises';
10
+
11
+ const jobs = new Map();
12
+ const MAX_FRAME_BYTES = 100 * 1024 * 1024;
13
+ const MAX_AUDIO_BYTES = 1024 * 1024 * 1024;
14
+
15
+ /** Handle an export request in the standalone npm CLI server. */
16
+ export async function handleFfmpegExportRequest(request, response) {
17
+ const url = new URL(request.url ?? '/', 'http://motionly.local');
18
+ if (!url.pathname.startsWith('/api/exports')) return false;
19
+
20
+ try {
21
+ await handleExportRequest(request, response, url);
22
+ } catch (error) {
23
+ if (response.headersSent) {
24
+ response.destroy(error instanceof Error ? error : new Error(String(error)));
25
+ } else {
26
+ response.statusCode = 500;
27
+ response.setHeader('content-type', 'text/plain;charset=utf-8');
28
+ response.end(error instanceof Error ? error.message : String(error));
29
+ }
30
+ }
31
+ return true;
32
+ }
33
+
34
+ /** Connect-compatible middleware used by the Vite development and preview servers. */
35
+ export function createFfmpegExportMiddleware() {
36
+ return (request, response, next) => {
37
+ void handleFfmpegExportRequest(request, response).then((handled) => {
38
+ if (!handled) next();
39
+ }, next);
40
+ };
41
+ }
42
+
43
+ async function handleExportRequest(request, response, url) {
44
+ if (request.method === 'POST' && url.pathname === '/api/exports') {
45
+ await createJob(request, response);
46
+ return;
47
+ }
48
+ const match = /^\/api\/exports\/([a-z0-9-]+)(?:\/(frames\/(\d+)|audio|finish))?$/.exec(
49
+ url.pathname
50
+ );
51
+ if (!match) return respond(response, 404, 'Unknown export endpoint');
52
+ const id = match[1];
53
+ const action = match[2];
54
+ if (!id) return respond(response, 404, 'Unknown export job');
55
+ const job = jobs.get(id);
56
+ if (!job) return respond(response, 404, 'Export job not found');
57
+
58
+ if (request.method === 'DELETE' && !action) {
59
+ jobs.delete(id);
60
+ await rm(job.directory, { recursive: true, force: true });
61
+ respond(response, 204);
62
+ return;
63
+ }
64
+
65
+ if (request.method === 'PUT' && action?.startsWith('frames/')) {
66
+ const index = Number(match[3]);
67
+ if (!Number.isInteger(index) || index < 0 || index >= job.totalFrames) {
68
+ return respond(response, 409, `Invalid frame ${index}`);
69
+ }
70
+ if (job.receivedFrames.has(index)) return respond(response, 409, `Duplicate frame ${index}`);
71
+ if (request.headers['content-type'] !== 'image/jpeg') {
72
+ return respond(response, 415, 'Export frames must be JPEG images');
73
+ }
74
+ await writeRequest(
75
+ request,
76
+ join(job.directory, `frame-${String(index).padStart(8, '0')}.jpg`),
77
+ MAX_FRAME_BYTES
78
+ );
79
+ job.receivedFrames.add(index);
80
+ respond(response, 204);
81
+ return;
82
+ }
83
+
84
+ if (request.method === 'PUT' && action === 'audio') {
85
+ if (!job.hasAudio) return respond(response, 409, 'This export was not created with audio');
86
+ await writeRequest(request, join(job.directory, 'audio-input'), MAX_AUDIO_BYTES);
87
+ job.audioReceived = true;
88
+ respond(response, 204);
89
+ return;
90
+ }
91
+
92
+ if (request.method === 'POST' && action === 'finish') {
93
+ if (job.receivedFrames.size !== job.totalFrames) {
94
+ return respond(
95
+ response,
96
+ 409,
97
+ `Missing ${job.totalFrames - job.receivedFrames.size} export frames`
98
+ );
99
+ }
100
+ if (job.hasAudio && !job.audioReceived) {
101
+ return respond(response, 409, 'The attached audio file was not uploaded');
102
+ }
103
+ await finishJob(id, job, response);
104
+ return;
105
+ }
106
+
107
+ respond(response, 405, 'Method not allowed');
108
+ }
109
+
110
+ async function createJob(request, response) {
111
+ const input = JSON.parse((await readRequest(request, 64 * 1024)).toString('utf8'));
112
+ positiveInteger(input.width, 'width', 8192);
113
+ positiveInteger(input.height, 'height', 8192);
114
+ const fps = positiveNumber(input.fps, 'fps', 240);
115
+ const duration = positiveNumber(input.duration, 'duration', 24 * 60 * 60);
116
+ const totalFrames = positiveInteger(input.totalFrames, 'totalFrames', 24 * 60 * 60 * 240);
117
+ const audioStart = nonNegativeNumber(input.audioStart ?? 0, 'audioStart', duration);
118
+ const expectedFrames = Math.max(1, Math.ceil(duration * fps));
119
+ if (totalFrames !== expectedFrames) throw new Error(`Expected ${expectedFrames} frames`);
120
+ const id = randomUUID();
121
+ const directory = await mkdtemp(join(tmpdir(), 'motionly-export-'));
122
+ jobs.set(id, {
123
+ directory,
124
+ fps,
125
+ duration,
126
+ totalFrames,
127
+ receivedFrames: new Set(),
128
+ hasAudio: input.hasAudio === true,
129
+ audioStart,
130
+ audioReceived: false,
131
+ });
132
+ response.statusCode = 201;
133
+ response.setHeader('content-type', 'application/json');
134
+ response.end(JSON.stringify({ id }));
135
+ }
136
+
137
+ async function finishJob(id, job, response) {
138
+ const outputPath = join(job.directory, 'motionly.mp4');
139
+ const framePattern = join(job.directory, 'frame-%08d.jpg');
140
+ const args = [
141
+ '-hide_banner',
142
+ '-loglevel',
143
+ 'error',
144
+ '-y',
145
+ '-framerate',
146
+ String(job.fps),
147
+ '-start_number',
148
+ '0',
149
+ '-i',
150
+ framePattern,
151
+ ];
152
+ if (job.hasAudio) {
153
+ args.push('-itsoffset', String(job.audioStart), '-i', join(job.directory, 'audio-input'));
154
+ }
155
+ args.push('-map', '0:v:0');
156
+ if (job.hasAudio) args.push('-map', '1:a:0', '-c:a', 'aac', '-b:a', '192k');
157
+ args.push(
158
+ '-c:v',
159
+ 'libx264',
160
+ '-preset',
161
+ 'medium',
162
+ '-crf',
163
+ '18',
164
+ '-vf',
165
+ 'pad=ceil(iw/2)*2:ceil(ih/2)*2:color=black,format=yuv420p',
166
+ '-frames:v',
167
+ String(job.totalFrames),
168
+ '-t',
169
+ String(job.duration),
170
+ '-movflags',
171
+ '+faststart',
172
+ outputPath
173
+ );
174
+
175
+ jobs.delete(id);
176
+ try {
177
+ await runFfmpeg(args);
178
+ const output = await stat(outputPath);
179
+ response.statusCode = 200;
180
+ response.setHeader('content-type', 'video/mp4');
181
+ response.setHeader('content-length', String(output.size));
182
+ await pipeline(createReadStream(outputPath), response);
183
+ } finally {
184
+ await rm(job.directory, { recursive: true, force: true });
185
+ }
186
+ }
187
+
188
+ function runFfmpeg(args) {
189
+ return new Promise((resolve, reject) => {
190
+ const child = spawn('ffmpeg', args, { windowsHide: true });
191
+ let errorOutput = '';
192
+ child.stderr.setEncoding('utf8');
193
+ child.stderr.on('data', (chunk) => {
194
+ errorOutput = `${errorOutput}${chunk}`.slice(-16_384);
195
+ });
196
+ child.once('error', (error) => {
197
+ reject(
198
+ error.code === 'ENOENT'
199
+ ? new Error('ffmpeg is not installed or is not available on PATH')
200
+ : error
201
+ );
202
+ });
203
+ child.once('close', (code) => {
204
+ if (code === 0) resolve();
205
+ else reject(new Error(errorOutput.trim() || `ffmpeg exited with code ${String(code)}`));
206
+ });
207
+ });
208
+ }
209
+
210
+ async function writeRequest(request, path, maxBytes) {
211
+ let size = 0;
212
+ const limiter = new Transform({
213
+ transform(chunk, _encoding, callback) {
214
+ size += chunk.byteLength;
215
+ callback(size > maxBytes ? new Error('Export upload is too large') : null, chunk);
216
+ },
217
+ });
218
+ try {
219
+ await pipeline(request, limiter, createWriteStream(path));
220
+ } catch (error) {
221
+ await rm(path, { force: true });
222
+ throw error;
223
+ }
224
+ }
225
+
226
+ async function readRequest(request, maxBytes) {
227
+ const chunks = [];
228
+ let size = 0;
229
+ for await (const chunk of request) {
230
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
231
+ size += buffer.byteLength;
232
+ if (size > maxBytes) throw new Error('Export upload is too large');
233
+ chunks.push(buffer);
234
+ }
235
+ return Buffer.concat(chunks);
236
+ }
237
+
238
+ function positiveInteger(value, name, max) {
239
+ if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0 || value > max) {
240
+ throw new Error(`Invalid export ${name}`);
241
+ }
242
+ return value;
243
+ }
244
+
245
+ function positiveNumber(value, name, max) {
246
+ if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0 || value > max) {
247
+ throw new Error(`Invalid export ${name}`);
248
+ }
249
+ return value;
250
+ }
251
+
252
+ function nonNegativeNumber(value, name, max) {
253
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || value > max) {
254
+ throw new Error(`Invalid export ${name}`);
255
+ }
256
+ return value;
257
+ }
258
+
259
+ function respond(response, status, message) {
260
+ response.statusCode = status;
261
+ if (message) response.setHeader('content-type', 'text/plain;charset=utf-8');
262
+ response.end(message);
263
+ }
package/bin/motionly.js CHANGED
@@ -2,6 +2,7 @@
2
2
  // Zero-dependency Motionly CLI and local editor server.
3
3
 
4
4
  import { createServer } from 'node:http';
5
+ import { handleFfmpegExportRequest } from './ffmpeg-export.js';
5
6
  import { createInterface } from 'node:readline/promises';
6
7
  import { spawn } from 'node:child_process';
7
8
  import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises';
@@ -14,14 +15,27 @@ const root = normalize(join(here, '..'));
14
15
  const dist = join(root, 'dist');
15
16
  const skillTemplatePath = join(root, 'templates', 'motionly-skill', 'SKILL.md');
16
17
  const projectTemplateRoot = join(root, 'templates', 'project');
18
+ // The full, maintained skill library (llms.txt index + focused SKILL.md files).
19
+ // It ships inside the published package and is installed beside the top-level
20
+ // SKILL.md as a `references/` bundle so agents get real depth, not one file.
21
+ const skillsLibraryRoot = join(root, 'motionly-skills');
17
22
 
18
23
  const PROVIDERS = {
19
- claude: '.claude/skills/motionly/SKILL.md',
20
24
  codex: '.agents/skills/motionly/SKILL.md',
25
+ claude: '.claude/skills/motionly/SKILL.md',
21
26
  gemini: '.gemini/skills/motionly/SKILL.md',
27
+ opencode: '.opencode/skills/motionly/SKILL.md',
22
28
  kiro: '.kiro/skills/motionly/SKILL.md',
23
29
  };
24
30
 
31
+ const AGENT_LABELS = {
32
+ codex: 'Codex',
33
+ claude: 'Claude Code',
34
+ gemini: 'Gemini CLI',
35
+ opencode: 'opencode',
36
+ kiro: 'Kiro',
37
+ };
38
+
25
39
  const MIME = {
26
40
  '.html': 'text/html; charset=utf-8',
27
41
  '.js': 'text/javascript; charset=utf-8',
@@ -103,10 +117,12 @@ async function installSkills(base, providers) {
103
117
  throw new Error(`Unknown provider "${unknown}". Use: ${Object.keys(PROVIDERS).join(', ')}`);
104
118
 
105
119
  const source = await readFile(skillTemplatePath, 'utf8');
120
+ const libraryAvailable = await exists(skillsLibraryRoot);
106
121
  for (const provider of providers) {
107
122
  const relative = PROVIDERS[provider];
108
123
  const target = join(base, relative);
109
- await mkdir(dirname(target), { recursive: true });
124
+ const skillDir = dirname(target);
125
+ await mkdir(skillDir, { recursive: true });
110
126
  try {
111
127
  await writeFile(target, source, { encoding: 'utf8', flag: 'wx' });
112
128
  console.log(`Added ${provider}: ${target}`);
@@ -114,9 +130,44 @@ async function installSkills(base, providers) {
114
130
  if (error.code !== 'EEXIST') throw error;
115
131
  console.log(`Kept ${provider}: ${target} already exists`);
116
132
  }
133
+ if (libraryAvailable) {
134
+ const added = await copyReferenceLibrary(skillsLibraryRoot, join(skillDir, 'references'));
135
+ if (added) console.log(` + reference library: ${added} file${added === 1 ? '' : 's'}`);
136
+ }
117
137
  }
118
138
  }
119
139
 
140
+ /**
141
+ * Recursively copy the discovery index and focused SKILL.md files from the
142
+ * skill library into an installed `references/` folder. Provider-specific
143
+ * `agents/` metadata is skipped, and existing files are never overwritten so
144
+ * user edits survive re-running the installer. Returns the number of new files.
145
+ */
146
+ async function copyReferenceLibrary(source, destination) {
147
+ let copied = 0;
148
+ await mkdir(destination, { recursive: true });
149
+ for (const entry of await readdir(source, { withFileTypes: true })) {
150
+ if (entry.name === 'agents') continue;
151
+ const from = join(source, entry.name);
152
+ const to = join(destination, entry.name);
153
+ if (entry.isDirectory()) {
154
+ copied += await copyReferenceLibrary(from, to);
155
+ } else if (
156
+ entry.name === 'SKILL.md' ||
157
+ entry.name === 'llms.txt' ||
158
+ entry.name === 'AGENTS.md'
159
+ ) {
160
+ try {
161
+ await writeFile(to, await readFile(from), { flag: 'wx' });
162
+ copied += 1;
163
+ } catch (error) {
164
+ if (error.code !== 'EEXIST') throw error;
165
+ }
166
+ }
167
+ }
168
+ return copied;
169
+ }
170
+
120
171
  async function choose(terminal, question, options) {
121
172
  console.log(`\n${question}`);
122
173
  options.forEach((option, index) => console.log(` ${index + 1}. ${option.label}`));
@@ -141,10 +192,7 @@ async function selectSkillOptions(terminal, providers, scope) {
141
192
  if (!selectedProviders.length) {
142
193
  const provider = await choose(terminal, 'Which agents should receive the Motionly skill?', [
143
194
  { label: 'All supported agents', value: 'all' },
144
- { label: 'Claude Code', value: 'claude' },
145
- { label: 'Codex', value: 'codex' },
146
- { label: 'Gemini CLI', value: 'gemini' },
147
- { label: 'Kiro CLI', value: 'kiro' },
195
+ ...Object.keys(PROVIDERS).map((id) => ({ label: AGENT_LABELS[id], value: id })),
148
196
  ]);
149
197
  selectedProviders = provider === 'all' ? Object.keys(PROVIDERS) : [provider];
150
198
  }
@@ -194,26 +242,23 @@ async function resolveSkillOptions(argv) {
194
242
  return options;
195
243
  }
196
244
 
197
- async function promptForSkills(target) {
198
- if (!process.stdin.isTTY || !process.stdout.isTTY) return;
199
-
245
+ async function promptForAgent(scope) {
200
246
  const terminal = createInterface({ input: process.stdin, output: process.stdout });
201
247
  try {
202
- const scope = await choose(terminal, 'Install Motionly agent skills?', [
203
- { label: 'Skip', value: 'skip' },
204
- { label: 'Project inside the new project', value: 'project' },
205
- { label: 'Global — every project for this user', value: 'global' },
248
+ const choice = await choose(terminal, 'Which agent are you using?', [
249
+ ...Object.keys(PROVIDERS).map((id) => ({ label: AGENT_LABELS[id], value: id })),
250
+ { label: 'All supported agents', value: 'all' },
206
251
  ]);
207
- if (scope === 'skip') return;
208
- const options = await selectSkillOptions(terminal, [], scope);
209
- await installSkills(skillBase(options.scope, target), options.providers);
252
+ const providers = choice === 'all' ? Object.keys(PROVIDERS) : [choice];
253
+ return { providers, scope };
210
254
  } finally {
211
255
  terminal.close();
212
256
  }
213
257
  }
214
258
 
215
259
  async function initProject(name, argv = []) {
216
- if (!name || name.startsWith('-')) throw new Error('Usage: npx motionly init <project-folder>');
260
+ if (!name || name.startsWith('-'))
261
+ throw new Error('Usage: npx @coppsary/motionly init <project-folder>');
217
262
  const target = resolve(name);
218
263
  if (await exists(target)) {
219
264
  if ((await readdir(target)).length) throw new Error(`Folder is not empty: ${target}`);
@@ -231,9 +276,23 @@ async function initProject(name, argv = []) {
231
276
  }
232
277
  await mkdir(join(target, 'assets'));
233
278
  console.log(`Created ${target}`);
234
- await promptForSkills(target);
279
+ // Skills install unless opted out. Explicit --provider/--all/--scope flags win
280
+ // (non-interactive/CI). Otherwise, in a terminal, ask which agent to set up;
281
+ // with no terminal and no flags, default to every supported agent.
282
+ if (!argv.includes('--skip-skills') && !argv.includes('--no-skills')) {
283
+ const explicit = parseSkillOptions(argv);
284
+ const scope = explicit.scope ?? 'project';
285
+ let providers = explicit.providers;
286
+ if (!providers.length) {
287
+ providers =
288
+ process.stdin.isTTY && process.stdout.isTTY
289
+ ? (await promptForAgent(scope)).providers
290
+ : Object.keys(PROVIDERS);
291
+ }
292
+ if (providers.length) await installSkills(skillBase(scope, target), providers);
293
+ }
235
294
  if (process.stdin.isTTY && process.stdout.isTTY) {
236
- console.log(`\n To reopen later: cd ${name} && npx motionly dev`);
295
+ console.log(`\n To reopen later: cd ${name} && npx @coppsary/motionly dev`);
237
296
  await serveEditor(argv, target);
238
297
  }
239
298
  }
@@ -282,6 +341,8 @@ async function serveEditor(argv, projectFolder = null) {
282
341
  const url = new URL(request.url ?? '/', 'http://localhost');
283
342
  const pathname = decodeURIComponent(url.pathname);
284
343
 
344
+ if (await handleFfmpegExportRequest(request, response)) return;
345
+
285
346
  if (projectPath && pathname === '/api/motion-project') {
286
347
  if (request.method === 'GET' || request.method === 'HEAD') {
287
348
  const source = await readFile(projectPath);
@@ -355,7 +416,7 @@ async function serveEditor(argv, projectFolder = null) {
355
416
  server.on('error', (error) => {
356
417
  if (error.code === 'EADDRINUSE') {
357
418
  console.error(
358
- `Port ${port} is in use. Try: npx motionly ${projectRoot ? 'dev ' : ''}--port ${port + 1}`
419
+ `Port ${port} is in use. Try: npx @coppsary/motionly ${projectRoot ? 'dev ' : ''}--port ${port + 1}`
359
420
  );
360
421
  process.exitCode = 1;
361
422
  return;
@@ -367,12 +428,14 @@ async function serveEditor(argv, projectFolder = null) {
367
428
  function printHelp() {
368
429
  console.log(`Motionly
369
430
 
370
- npx motionly Open the browser editor
371
- npx motionly skills add Pick project/global scope and agents
372
- npx motionly skills add --provider <claude|codex|gemini|kiro>
373
- npx motionly skills add --all
374
- npx motionly init <project-folder> Create and open a local project
375
- npx motionly dev [project-folder] Open and save a local project
431
+ npx @coppsary/motionly init <project-folder> Create a project; asks which agent to set up
432
+ npx @coppsary/motionly init <folder> --provider codex Create a project; install for one agent (no prompt)
433
+ npx @coppsary/motionly init <folder> --all Create a project; install for every agent
434
+ npx @coppsary/motionly init <folder> --skip-skills Create a project without agent skills
435
+ npx @coppsary/motionly skills add Install agent skills into an existing project
436
+ npx @coppsary/motionly skills add --all
437
+ npx @coppsary/motionly skills add --provider <codex|claude|gemini|opencode|kiro>
438
+ npx @coppsary/motionly dev [project-folder] Reopen and edit a local project
376
439
 
377
440
  Options: --scope <project|global>, --port <number>, --no-open`);
378
441
  }
@@ -383,7 +446,7 @@ async function main() {
383
446
  if (command === 'skills') {
384
447
  if (subcommand !== 'add')
385
448
  throw new Error(
386
- 'Usage: npx motionly skills add [--provider <name> | --all] [--scope project|global]'
449
+ 'Usage: npx @coppsary/motionly skills add [--provider <name> | --all] [--scope project|global]'
387
450
  );
388
451
  const options = await resolveSkillOptions(argv.slice(2));
389
452
  await installSkills(skillBase(options.scope, process.cwd()), options.providers);