@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 +28 -81
- package/bin/ffmpeg-export.js +263 -0
- package/bin/motionly.js +90 -27
- package/dist/assets/{main-CrcFUwRU.js → main-Bhr-QmYm.js} +2 -2
- package/dist/assets/main-Bhr-QmYm.js.map +1 -0
- package/dist/index.html +1 -1
- package/package.json +1 -1
- package/templates/motionly-skill/SKILL.md +23 -1
- package/templates/project/AGENTS.md +21 -1
- package/templates/project/README.md +10 -2
- package/dist/assets/main-CrcFUwRU.js.map +0 -1
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
|
|
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
|
-
|
|
|
218
|
-
|
|
|
219
|
-
|
|
|
220
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
329
|
+
npx @coppsary/motionly init demo
|
|
379
330
|
```
|
|
380
331
|
|
|
381
|
-
|
|
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
|
|
385
|
-
motionly
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
355
|
+
### Add skills to an existing project
|
|
404
356
|
|
|
405
357
|
```bash
|
|
406
|
-
|
|
407
|
-
motionly
|
|
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
|
-
|
|
363
|
+
Re-running is safe: existing skill files are kept, never overwritten.
|
|
411
364
|
|
|
412
|
-
###
|
|
413
|
-
|
|
414
|
-
You can use the public package directly through `npx` instead:
|
|
365
|
+
### Open and edit
|
|
415
366
|
|
|
416
367
|
```bash
|
|
417
|
-
|
|
418
|
-
npx motionly
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
|
203
|
-
{ label:
|
|
204
|
-
{ label: '
|
|
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
|
-
|
|
208
|
-
|
|
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('-'))
|
|
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
|
-
|
|
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
|
|
371
|
-
npx motionly
|
|
372
|
-
npx motionly
|
|
373
|
-
npx motionly skills
|
|
374
|
-
npx motionly
|
|
375
|
-
npx motionly
|
|
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);
|