reelkit-cli 0.1.0

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.
Files changed (65) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +91 -0
  3. package/bin/reelkit.mjs +5 -0
  4. package/package.json +89 -0
  5. package/skill/SKILL.md +101 -0
  6. package/skill/THIRD_PARTY.md +20 -0
  7. package/skill/command.md +5 -0
  8. package/skill/reference/asset-reuse.md +31 -0
  9. package/skill/reference/captions.md +57 -0
  10. package/skill/reference/component-authoring.md +26 -0
  11. package/skill/reference/kit.md +135 -0
  12. package/skill/reference/motion-design.md +62 -0
  13. package/skill/reference/remotion-composition.md +58 -0
  14. package/skill/reference/scene-treatments.md +35 -0
  15. package/skill/reference/scriptwriting.md +44 -0
  16. package/skill/reference/sound-design.md +43 -0
  17. package/src/agents.ts +103 -0
  18. package/src/api/client.ts +58 -0
  19. package/src/cli.ts +91 -0
  20. package/src/commands/assets.ts +196 -0
  21. package/src/commands/auth.ts +118 -0
  22. package/src/commands/build.ts +109 -0
  23. package/src/commands/init.ts +32 -0
  24. package/src/commands/install.ts +24 -0
  25. package/src/commands/plan.ts +45 -0
  26. package/src/context.ts +21 -0
  27. package/src/contract/index.ts +157 -0
  28. package/src/credentials.ts +55 -0
  29. package/src/open.ts +48 -0
  30. package/src/pipeline/review.ts +30 -0
  31. package/src/pipeline/schema.ts +110 -0
  32. package/src/pipeline/timing.ts +30 -0
  33. package/src/project/manifest.ts +71 -0
  34. package/src/project/probe.ts +54 -0
  35. package/src/project/project.ts +58 -0
  36. package/src/project/serve.ts +81 -0
  37. package/src/remotion/Root.tsx +29 -0
  38. package/src/remotion/kit/Captions.tsx +77 -0
  39. package/src/remotion/kit/Counter.tsx +18 -0
  40. package/src/remotion/kit/Entrance.tsx +53 -0
  41. package/src/remotion/kit/FootageLayer.tsx +10 -0
  42. package/src/remotion/kit/Icon.tsx +35 -0
  43. package/src/remotion/kit/KenBurnsImage.tsx +18 -0
  44. package/src/remotion/kit/Layers.tsx +46 -0
  45. package/src/remotion/kit/LowerThird.tsx +17 -0
  46. package/src/remotion/kit/SceneFrame.tsx +19 -0
  47. package/src/remotion/kit/ScreenOverlay.tsx +15 -0
  48. package/src/remotion/kit/Sfx.tsx +11 -0
  49. package/src/remotion/kit/TitleCard.tsx +23 -0
  50. package/src/remotion/kit/Voiceover.tsx +5 -0
  51. package/src/remotion/kit/brand-icons.ts +37 -0
  52. package/src/remotion/kit/docs.ts +98 -0
  53. package/src/remotion/kit/index.ts +20 -0
  54. package/src/remotion/kit/media.ts +10 -0
  55. package/src/remotion/kit/theme.ts +131 -0
  56. package/src/remotion/types.ts +2 -0
  57. package/src/render/component-preview.ts +65 -0
  58. package/src/render/deps.ts +8 -0
  59. package/src/render/render.ts +67 -0
  60. package/src/render/validate.ts +191 -0
  61. package/src/testing/conformance.ts +396 -0
  62. package/src/testing/fake-api.ts +228 -0
  63. package/src/testing/fixtures.ts +44 -0
  64. package/src/ui/CommandView.tsx +18 -0
  65. package/src/ui/run.tsx +20 -0
@@ -0,0 +1,62 @@
1
+ ---
2
+ name: motion-design
3
+ description: Use when deciding how a scene looks and moves - layout, typography, colour, springs, pacing to speech, continuity between scenes, and the rules that stop a video looking AI-generated.
4
+ ---
5
+
6
+ # Motion design guidelines
7
+
8
+ ## The user's uploads
9
+ `assets/index.json` lists the user's own files with a description of each. Show each one only in the scenes whose userAssetKeys include it, sized and framed for what it is: a logo small and clean on a plain area; a screenshot framed in a device or browser mockup (search the library with `reelkit assets search "phone mockup" --kind component`, then `reelkit assets pull <id>`, which puts it in `src/`; if none fits, build a simple rounded framed card); a product photo large, with KenBurnsImage or a rounded card. Never stretch a logo, never crop a screenshot's important part, and never invent a screen when a real one was supplied.
10
+
11
+ ## One system, start to end
12
+ - One base colour, one text colour and **one hero colour**, kept for the whole video. Roughly 60 percent base, 30 percent secondary surfaces, 10 percent hero.
13
+ - The hero colour appears on **at most one element per frame**: it tells the eye where to look. Highlight one word per headline, not the whole line.
14
+ - A soft glow is allowed on the hero element only. More than one glowing element per frame looks cheap.
15
+ - One typeface family, two weights. One icon style with one stroke weight.
16
+ - Carry one visual thread through every scene: the same badge style, accent shape or background treatment. Scenes should feel like one piece transforming, not slides.
17
+ - Where two scenes share an idea, carry an element across: the number becomes the badge, the word stays while its container changes. A shared element beats a fade.
18
+
19
+ ## Layout
20
+ - One focal point per scene. Everything else supports it.
21
+ - Keep important content inside the middle 80 percent of the frame horizontally. On vertical video keep critical text in the middle 75 percent vertically: the platform's own buttons cover the top and bottom. Captions sit just above the bottom of that zone.
22
+ - Reuse the same margins in every scene.
23
+ - Vary composition between scenes (centred, top-aligned, split) so the video is not one slide repeated.
24
+
25
+ ## Typography
26
+ - Headlines 7 to 10 percent of the frame width; supporting text 3.5 to 5 percent. Never smaller than 3 percent: it must read on a phone.
27
+ - If any on-screen text is Hebrew, set it in one of the kit's Hebrew faces and give its container `direction: "rtl"`. The Latin faces have no Hebrew letters. Good pairings: `heebo` or `assistant` for everything; `secularOne` or `karantina` headlines over `assistant` body; `suezOne` or `frankRuhlLibre` for an editorial feel; `varelaRound` or `fredoka` for a friendly one.
28
+ - Short lines, broken by meaning, not by width. A title should fit on one line where it can.
29
+ - Reveal text with a masked rise (translate up from behind an overflow-hidden box), word by word, about 2 frames apart. Avoid plain opacity fades for headlines.
30
+
31
+ ## Motion
32
+ - **Springs, never linear moves and never cartoon bounce.** Use `spring()` with a damping high enough for a tiny overshoot at most. Presets (stiffness / damping): snappy UI 320 / 30, default containers 170 / 26, heavy type and logos 120 / 24.
33
+ - **One thing moves at a time.** Stagger related items by 4 to 8 frames rather than moving everything at once.
34
+ - **Pace to speech.** One visible change per spoken beat, roughly every 0.4 to 1.2 seconds. Use the word timings in `s.words` to bring an element in on the frame its word starts, never before.
35
+ - Something new should happen every 2 to 4 seconds, and something must move within the first half second of the video. No dead stretches.
36
+ - **Rhythm is hit, hold, build.** A fast move, then a hold of about half a second where nothing new enters, then the next move. Constant motion reads amateur; contrast reads expensive. During a hold, settled elements keep their slow breathe.
37
+ - **Nothing sits perfectly frozen for more than about a second.** After an element settles, keep a very slow drift or scale (1 to 2 percent over the scene) alive so the frame breathes.
38
+ - Let an element finish animating and stay readable for at least 1.5 seconds before it leaves.
39
+ - Counters, progress bars and drawn lines finish before the scene's midpoint.
40
+ - Text that swaps inside a container that is also changing size needs its own exit and enter timing, or old and new text overlap.
41
+
42
+ ## Legibility over images and footage
43
+ - Text over an image or footage needs help: a dim layer of 0.2 to 0.45 on the media, or a solid plate behind the text, plus a soft shadow.
44
+ - In footage mode keep overlays to one graphic at a time, clear of the subject.
45
+
46
+ ## Truth on screen
47
+ - Never show a number, quote, price, statistic or result that is not in the narration or on-screen text of the plan.
48
+ - A chart or gauge with no real data behind it uses relative shapes with no figures, or is labelled "Example".
49
+ - Do not invent product screens or features. A recreated interface that is not the user's real one is illustrative and should look generic.
50
+
51
+ ## Determinism
52
+ - Never use `Math.random()` or the clock. A frame must render the same every time. Use `random(seed)` from remotion for any variation.
53
+ - Everything is a function of `useCurrentFrame()`. No CSS transitions or animations, no timers.
54
+
55
+ ## Shape of a 30 second video
56
+ Hook (first 1.5 s: the boldest visual and claim) → context (one line, one visual) → body (three or four beats) → payoff (the result or number, the biggest animation of the video) → close (one action, calm).
57
+
58
+ ## Banned, because they read as AI-made or cheap
59
+ Flat solid backgrounds, opacity-only fades, everything entering at once, rainbow or multi-stop gradients on text and UI, particles and sparkles, glows on interface chrome, emoji as graphics, bouncy easing, gratuitous 3D flips, a centred title on a plain gradient, mixed icon styles, lorem ipsum, and anything that looks like a stock template.
60
+
61
+ ## Check your own frames
62
+ Run `reelkit preview`, then look at the frames in `out/preview/` as a harsh director would. Hunt for: text overlapping during a swap, text cut by the frame edge, type too small for a phone, a frame with nothing in it, and anything that looks like a template. Fix what is real, then check again.
@@ -0,0 +1,58 @@
1
+ ---
2
+ name: remotion-composition
3
+ description: Use when writing Video.tsx - the file rules, timing model and media rules every composition must follow.
4
+ ---
5
+
6
+ # Writing the composition
7
+
8
+ ## Files
9
+ - `Video.tsx` is required and must contain `export const Video: React.FC<VideoProps> = ({ manifest, urls }) => ...`.
10
+ - Extra component files are flat siblings, PascalCase, one component per file: `NumberBadge.tsx` exports `NumberBadge`. Import them in Video.tsx as `./NumberBadge`.
11
+ - Video.tsx may import only `react`, `remotion`, `reelkit/kit` and sibling components. No other packages, no network, no file access, no `process`.
12
+
13
+ ## Timing
14
+ - All timing comes from the manifest. Each scene has `startFrame` and `durationFrames`; wrap each scene's content in `<SceneFrame from={s.startFrame} durationInFrames={s.durationFrames}>`.
15
+ - Inside a SceneFrame, `useCurrentFrame()` starts at 0 for that scene.
16
+ - Never hard-code a duration that the manifest already gives. Look scenes up by id when a scene needs bespoke content.
17
+
18
+ ## Media
19
+ - Reference media only as `urls[path]`, where `path` is the file's project path, using the paths in the manifest: `s.voiceoverKey`, `s.imageKey`, `s.userAssetKeys`, `manifest.footageKey`. Never hard-code a URL.
20
+ - A scene with narration has `s.voiceoverKey`: give it exactly one `<Voiceover src={urls[s.voiceoverKey]} />` inside its SceneFrame, and `<Captions words={s.words} />` unless the notes say otherwise.
21
+ - Footage mode (`manifest.footageKey` is set): one `<FootageLayer>` at the bottom, outside the scenes.
22
+ - User images: `<Img src={urls[key]} />` from remotion.
23
+
24
+ ## Layer stack (every video, bottom to top)
25
+ 1. Background: `<BgMesh>` from the kit, or `<FootageLayer>` in footage mode. Never a flat solid colour.
26
+ 2. Images: `<KenBurnsImage>` inside its scene. Every still image moves; alternate `direction` between consecutive image scenes.
27
+ 3. Graphics and type, inside each scene's SceneFrame.
28
+ 4. `<Grade>`, then `<Grain>`, then `<Vignette>`, once, at the very top of the video outside the scenes. In footage mode skip Grade.
29
+
30
+ ## Shared overlays (optional)
31
+ - The library has ready-made overlay clips (`reelkit assets search "<description>" --kind overlay`): film marks, HUD frames, corner marks. They are white on black and are laid over the video with `<ScreenOverlay>`.
32
+ - Most videos need none. Use one only when the topic or look calls for it (film, tech, editorial), on a dark theme, at low opacity (0.3 to 0.6).
33
+ - At most one overlay per video, placed above the scenes and below Grade, kept clear of captions and headlines.
34
+ - Only use kind "frame". Kind "badge" overlays are labels and ratings (4K, HDR, age ratings, "Coming Soon", "Directed by"): they make a claim about the video, so use one only if the narration literally says it.
35
+ - Run `reelkit assets pull <id>` before referencing an overlay. It lands in `assets/lib/<id>/`; reference it as `urls["assets/lib/<id>/<file>"]`, and read its duration from `assets/library.json`.
36
+
37
+ ## Animation rules
38
+ - **No linear motion.** Every `interpolate()` takes an `easing` from the kit's `ease` and both `extrapolateLeft: "clamp"` and `extrapolateRight: "clamp"`. Entrances use `spring()` with a kit `springs` preset.
39
+ - **Entrances move two or three properties together** (opacity, position, scale). Wrap content in `<Entrance>` rather than fading alone.
40
+ - **Stagger, never simultaneous.** Words 3 frames apart, list items and cards 4 to 5, big blocks 6.
41
+ - **Exits exist and are faster than entrances** (about 10 frames against 20). Use `<Entrance exitAt={...}>` or the SceneFrame fade.
42
+ - **A missing clamp shows elements before their entrance or after their exit.** Check for it.
43
+ - Derive timing from `fps` in `useVideoConfig()`, not bare frame counts, when you express a duration in seconds.
44
+
45
+ ## Theme
46
+ - Declare one palette object at the top of Video.tsx (a kit `palettes` entry or your own `Palette`) and take every colour from it. No stray hex values inside components; components receive colours as props.
47
+ - Use `fonts.display` at weight 600 to 800 for headlines and numbers and `fonts.body` for supporting text.
48
+ - Use pixel values, not `em`, for gaps and margins around large type: `em` resolves against the parent's font size and collapses next to big text.
49
+ - No emoji as icons. They ignore the palette. For a platform logo use the kit's `<Icon name="instagram" ... />`; never draw a logo yourself or stand a letter in for it. For anything else draw a simple glyph with SVG in palette colours, or use one of the Icon glyphs.
50
+
51
+ ## Content
52
+ - The plan is not available at render time. Bake each scene's on-screen text and direction into the code.
53
+ - Size everything relative to `useVideoConfig()` width and height, never fixed pixels, so it works at any aspect.
54
+
55
+ ## Workflow
56
+ 1. `reelkit assets search "<description>" --kind component`, and `reelkit assets pull <id>` for any you plan to use (it lands in `src/`).
57
+ 2. Write each new component in `src/`, then `src/Video.tsx`.
58
+ 3. `reelkit check`. Fix every error it reports and check again until it passes.
@@ -0,0 +1,35 @@
1
+ ---
2
+ name: scene-treatments
3
+ description: Use when choosing each scene's visual treatment and writing image prompts, shareable flags and direction notes.
4
+ ---
5
+
6
+ # Choosing scene treatments
7
+
8
+ ## The three treatments
9
+ - **motion-graphic** - animated text, numbers, shapes and icons drawn in code. The default. Best for hooks, lists, statistics, steps and calls to action.
10
+ - **illustration** - one generated picture behind the text. Use when a concrete image carries the point better than type (an object, a place, a mood). At most half the scenes; each one costs money.
11
+ - **footage-overlay** - text and graphics over the user's own video. Required for every scene when footage was supplied, and never used otherwise.
12
+
13
+ ## Image prompts (illustration scenes only)
14
+ - Describe subject, setting, style and mood in one or two sentences. Keep one consistent style across the video.
15
+ - Never ask for text, letters, logos or UI inside the picture.
16
+ - Leave clear space where on-screen text will sit.
17
+ - Before writing a new prompt, run `reelkit assets search "<description>" --kind image`: if a suitable shared image exists, word the prompt to match it so you can reuse it.
18
+
19
+ ## shareable and imageTags
20
+ - shareable is true only when the picture is generic stock-style imagery any other video could use. Write shareable prompts in generic terms.
21
+ - shareable is false when the prompt depends on the user's brand, product, name, place or uploads, and whenever imagePrompt is null.
22
+ - imageTags: 3 to 6 short lowercase tags (subject, style, mood). Empty when imagePrompt is null.
23
+
24
+ ## User assets
25
+ - Only reference assets listed in `assets/index.json`, by id, in userAssetIds.
26
+ - Put a logo in the first or last scene. Put a screenshot in the scene that talks about what it shows.
27
+
28
+ ## One idea, one look
29
+ - One idea per scene, and one visual per idea. If a scene needs two visuals, it is two scenes.
30
+ - Describe the real object the narration is about (the phone, the receipt, the glass, the chart), not an abstract shape.
31
+ - Plan a visual thread that runs through every scene, and say what it is in the first scene's notes.
32
+
33
+ ## Notes for the composition
34
+ - One or two sentences per scene: layout, what animates, emphasis, colour mood.
35
+ - Name library components found with `reelkit assets search "<description>" --kind component` when one fits.
@@ -0,0 +1,44 @@
1
+ ---
2
+ name: scriptwriting
3
+ description: Use when writing the spoken script for a short social video - hooks, pacing, structure and length.
4
+ ---
5
+
6
+ # Scriptwriting for short video
7
+
8
+ ## Length and pace
9
+ - Narration is spoken at about 2.5 words per second. A 30 second video is about 75 words; 60 seconds is about 150.
10
+ - Without footage, aim for 30 to 60 seconds in total. With footage, never exceed the word limit that `reelkit plan check` reports.
11
+ - One idea per scene. A scene is one or two sentences, 8 to 30 words.
12
+
13
+ ## Structure
14
+ 1. **Hook (scene 1):** a question, a surprising claim or a named pain. No greetings, no "in this video".
15
+ 2. **Body (1 to 6 scenes):** one point per scene, in an order that builds. Number the points when the idea is a list.
16
+ 3. **Takeaway (last scene):** one concrete action or line to remember.
17
+
18
+ ## Voice
19
+ - Write for the ear: short sentences, contractions, plain words. Read it aloud in your head.
20
+ - Write numbers as they are spoken ("seventy-five percent"), since the text goes to a voice model.
21
+ - No emoji, no hashtags, no stage directions in narration.
22
+ - Write in the language of the idea. If the idea is in Hebrew, write the script in Hebrew.
23
+
24
+ ## Using the user's uploads
25
+ Each of the user's files in `assets/index.json` has a description of what it shows. Use an asset in the scene where it is relevant to what is being said, and leave out any that fit nowhere: a logo in the opening or closing scene, a screenshot where the narration talks about that screen, a product photo where the product is named. Put its id in that scene's userAssetIds and say in the notes how to show it.
26
+
27
+ ## Choosing the voice
28
+ `reelkit assets voices` lists the available voices with a description, gender, accent and language (`--json` also gives each voice's kind). Pick one `voiceId` for the whole video and a `pace`.
29
+
30
+ - **Language first.** For Hebrew narration choose a voice whose language is Hebrew if there is one. For English, choose an accent that suits the audience; default to a neutral one.
31
+ - **Match the energy of the idea.** Punchy tips, lists and promos: an energetic, social-media style voice. Explainers and how-tos: a clear, friendly educator. Finance, health, legal and B2B: a steady, trustworthy voice. Stories and reflective pieces: a warm storyteller.
32
+ - **Match the speaker the script implies.** If the script speaks as the brand or founder ("we build..."), prefer the owner's own cloned voice when one is listed (kind "cloned"). Otherwise choose freely; do not pick a gender by stereotype of the topic.
33
+ - **Avoid character voices** (described as a villain, warrior, trickster, cartoon or similar) unless the idea is playful and calls for one.
34
+ - **Pace:** "normal" by default, "fast" for high-energy hooks and lists, "slow" for calm, emotional or complex material. A fast pace fits more words per second; keep the word limit either way.
35
+ - Vary your choice across videos: do not default to the first voice in the list.
36
+
37
+ ## Truthfulness
38
+ - Never invent numbers, statistics, quotes, prices, testimonials or results. Use a figure only when the idea supplies it or it is common knowledge you are sure of; otherwise make the point without a number.
39
+ - Do not claim features or facts about the user's product that the idea did not state.
40
+ - No fake urgency or made-up social proof.
41
+
42
+ ## On-screen text
43
+ - onScreenText is what appears on screen, not the narration. Two to five words per item, at most three items per scene.
44
+ - It should reinforce the spoken point (a keyword, a number, a label), never repeat the sentence.
@@ -0,0 +1,43 @@
1
+ ---
2
+ name: sound-design
3
+ description: Use when adding sound effects to a video - which moments get a sound, how to search the shared sound library, timing against the visual, and volume under the voiceover.
4
+ ---
5
+
6
+ # Sound design
7
+
8
+ A few well-placed sounds make motion feel real. Too many make a video tiring. The voiceover is always the most important sound.
9
+
10
+ ## What gets a sound
11
+ - **Entrances that matter:** the hook's first hit, each numbered point arriving, the payoff. A whoosh, swipe or soft impact.
12
+ - **Scene changes:** a short transition sound across the cut.
13
+ - **Interface moments:** a notification arriving, a button tap, a tick appearing. A click, pop or chime.
14
+ - **Build-ups:** a riser into the payoff or the final call to action.
15
+ - Not every element. Two to four sounds per scene at most, and usually one.
16
+
17
+ ## Finding sounds
18
+ 1. `reelkit assets search "<description>" --kind sfx` with what you want to hear: "whoosh", "soft click", "notification pop", "riser", "camera shutter", "bass impact". Add "one-shot" or "short" to the query for one-shots.
19
+ 2. Prefer short sounds (under two seconds) for motion. Use longer ones for risers and long transitions. Use an ambience or drone bed only when the video has no other background and the mood calls for it, at very low volume.
20
+ 3. `reelkit assets pull <id>` with the id you picked. It lands in `assets/lib/<id>/` and the command prints the `urls[...]` path to use; place it with `<Sfx>`.
21
+ 4. Reuse one whoosh and one click across the video rather than a different sound each time. Consistency sounds designed.
22
+
23
+ ## Timing
24
+ - Start a whoosh or swipe **2 to 3 frames before** the visual lands. Early feels in sync; late feels broken.
25
+ - Put impacts and clicks on the exact frame the visual hits.
26
+ - Start a riser so that it **ends** on the reveal: `at = revealFrame - durationSec * fps`.
27
+ - Use the word timings in `s.words` when a sound belongs to a spoken word.
28
+
29
+ ## Volume
30
+ - The voiceover plays at 1. Sound effects sit between 0.2 and 0.45. Impacts on the hook can go to 0.6.
31
+ - Ambience and drone beds: 0.06 to 0.12.
32
+ - Never let two loud sounds overlap. Stagger them.
33
+
34
+ ## Fit
35
+ - Match the sound to the look: soft pops and gentle whooshes for calm, friendly videos; glitches and bass hits for tech and high energy; camera and tape sounds for retro.
36
+ - Skip anything startling (gunshots, screams, alarms) unless the topic is literally about it.
37
+ - Do not use a sound that names a brand or device the video is not about.
38
+
39
+ ## Placing
40
+ ```tsx
41
+ <Sfx src={urls["assets/lib/<id>/clip.mp3"]} at={12} volume={0.35} />
42
+ ```
43
+ Inside a SceneFrame, `at` counts from the start of that scene.
package/src/agents.ts ADDED
@@ -0,0 +1,103 @@
1
+ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { basename, dirname, join } from "node:path";
4
+ import type { Ctx } from "./context";
5
+ import { PKG_ROOT } from "./render/validate";
6
+
7
+ type Env = Record<string, string | undefined>;
8
+
9
+ // Every path is relative to the agents home. Skill folders: the vercel-labs/skills README. Command folders: each agent's own docs.
10
+ export const AGENTS: { id: string; name: string; home: string; skillDir: string; commandFile?: string }[] = [
11
+ { id: "claude", name: "Claude Code", home: ".claude", skillDir: ".claude/skills/reelkit", commandFile: ".claude/commands/reelkit-video.md" },
12
+ { id: "codex", name: "Codex", home: ".codex", skillDir: ".codex/skills/reelkit", commandFile: ".codex/prompts/reelkit-video.md" },
13
+ { id: "cursor", name: "Cursor", home: ".cursor", skillDir: ".cursor/skills/reelkit" },
14
+ { id: "gemini", name: "Gemini CLI", home: ".gemini", skillDir: ".gemini/skills/reelkit" },
15
+ { id: "agents", name: "Shared agents folder", home: ".agents", skillDir: ".agents/skills/reelkit" },
16
+ ];
17
+
18
+ // The only place the home directory is read. Tests and smoke runs point REELKIT_AGENTS_HOME at a temp folder.
19
+ export const agentsHome = (env: Env): string => env.REELKIT_AGENTS_HOME || homedir();
20
+
21
+ const SKILL_SRC = join(PKG_ROOT, "skill");
22
+ const VERSION_FILE = ".reelkit-version";
23
+ const packageVersion = (): string => JSON.parse(readFileSync(join(PKG_ROOT, "package.json"), "utf8")).version;
24
+
25
+ // A killed run can leave staged or backup folders. Put back the newest backup if the install itself is gone, then clear the rest.
26
+ function recoverLeftovers(dest: string): void {
27
+ const parent = dirname(dest);
28
+ if (!existsSync(parent)) return;
29
+ const left = readdirSync(parent).filter((n) => /^\.reelkit\.(tmp|old)-/.test(n)).map((n) => join(parent, n));
30
+ const olds = left.filter((p) => basename(p).startsWith(".reelkit.old-")).sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs);
31
+ if (!existsSync(dest) && olds[0]) renameSync(olds[0], dest);
32
+ for (const p of left) rmSync(p, { recursive: true, force: true });
33
+ }
34
+
35
+ export type InstallReport = { installed: { agent: string; path: string }[]; skipped: { agent: string; reason: string }[] };
36
+
37
+ export function installSkill(env: Env, opts: { agents?: string[]; force?: boolean; source?: string; rename?: (from: string, to: string) => void }): InstallReport {
38
+ const source = opts.source ?? SKILL_SRC;
39
+ if (!existsSync(join(source, "SKILL.md")) || !existsSync(join(source, "command.md"))) {
40
+ throw new Error("Reelkit's skill files are missing from this installation. Reinstall reelkit and try again.");
41
+ }
42
+ const rename = opts.rename ?? renameSync;
43
+ const home = agentsHome(env);
44
+ const version = packageVersion();
45
+ const report: InstallReport = { installed: [], skipped: [] };
46
+ const named = opts.agents !== undefined;
47
+ for (const agent of AGENTS.filter((a) => !named || opts.agents!.includes(a.id))) {
48
+ if (!named && !existsSync(join(home, agent.home))) {
49
+ report.skipped.push({ agent: agent.id, reason: "not installed on this machine" });
50
+ continue;
51
+ }
52
+ const dest = join(home, agent.skillDir);
53
+ recoverLeftovers(dest);
54
+ const current = existsSync(join(dest, VERSION_FILE)) ? readFileSync(join(dest, VERSION_FILE), "utf8").trim() : undefined;
55
+ const commandPath = agent.commandFile && join(home, agent.commandFile);
56
+ if (!opts.force && current === version && (!commandPath || existsSync(commandPath))) {
57
+ report.skipped.push({ agent: agent.id, reason: "already up to date" });
58
+ continue;
59
+ }
60
+ // Stage next to the destination, then swap by renames only, so no step deletes the old install before the new one is in place.
61
+ const staged = join(dirname(dest), `.reelkit.tmp-${process.pid}`);
62
+ const backup = join(dirname(dest), `.reelkit.old-${process.pid}`);
63
+ try {
64
+ mkdirSync(dirname(dest), { recursive: true });
65
+ cpSync(source, staged, { recursive: true, filter: (src) => src !== join(source, "command.md") });
66
+ writeFileSync(join(staged, VERSION_FILE), `${version}\n`);
67
+ const hadOld = existsSync(dest);
68
+ if (hadOld) rename(dest, backup);
69
+ try {
70
+ rename(staged, dest);
71
+ } catch (e) {
72
+ if (hadOld) rename(backup, dest);
73
+ throw e;
74
+ }
75
+ } finally {
76
+ rmSync(staged, { recursive: true, force: true });
77
+ if (existsSync(dest)) rmSync(backup, { recursive: true, force: true }); // never delete the backup while nothing is in place
78
+ }
79
+ if (commandPath) {
80
+ mkdirSync(dirname(commandPath), { recursive: true });
81
+ cpSync(join(source, "command.md"), commandPath);
82
+ }
83
+ report.installed.push({ agent: agent.id, path: dest });
84
+ }
85
+ return report;
86
+ }
87
+
88
+ // The quiet form that rides on init and auth login: detected agents only, never fails, silent when current.
89
+ export function ensureSkill(ctx: Ctx): void {
90
+ if (ctx.env.REELKIT_NO_AUTO_INSTALL === "1") return;
91
+ try {
92
+ const home = agentsHome(ctx.env);
93
+ for (const agent of AGENTS.filter((a) => existsSync(join(home, a.home)))) {
94
+ try {
95
+ if (installSkill(ctx.env, { agents: [agent.id] }).installed.length) ctx.log(`Installed the Reelkit skill for ${agent.name}`);
96
+ } catch (e) {
97
+ ctx.log(`Could not install the Reelkit skill for ${agent.name}: ${(e instanceof Error ? e.message : String(e)).split("\n")[0]}`);
98
+ }
99
+ }
100
+ } catch (e) {
101
+ ctx.log(`Could not install the Reelkit skill: ${(e instanceof Error ? e.message : String(e)).split("\n")[0]}`);
102
+ }
103
+ }
@@ -0,0 +1,58 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { mkdir, writeFile } from "node:fs/promises";
3
+ import { dirname } from "node:path";
4
+ import type { z } from "zod";
5
+ import { ApiErrorSchema, ErrorCodeSchema, routes, type ErrorCode, type RouteName, type Routes } from "../contract";
6
+
7
+ export class ApiFailure extends Error {
8
+ constructor(public code: ErrorCode, message: string) { super(message); }
9
+ }
10
+
11
+ const VERSION = (JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")) as { version: string }).version;
12
+
13
+ export type Api = <K extends RouteName>(name: K, input: z.input<Routes[K]["req"]>) => Promise<z.infer<Routes[K]["res"]>>;
14
+
15
+ export function createClient(opts: { baseUrl: string; token?: string }): Api {
16
+ return async (name, input) => {
17
+ const r = routes[name];
18
+ if (r.auth && !opts.token) throw new ApiFailure("unauthenticated", "Not logged in. Run `reelkit auth login`.");
19
+ const url = new URL(opts.baseUrl.replace(/\/$/, "") + r.path);
20
+ const headers: Record<string, string> = { "user-agent": `reelkit/${VERSION}` };
21
+ if (opts.token) headers.authorization = `Bearer ${opts.token}`;
22
+ let body: string | undefined;
23
+ if (r.method === "GET") {
24
+ for (const [k, v] of Object.entries(input as Record<string, unknown>)) if (v !== undefined) url.searchParams.set(k, String(v));
25
+ } else {
26
+ headers["content-type"] = "application/json";
27
+ body = JSON.stringify(input);
28
+ }
29
+ let res: Response;
30
+ try {
31
+ res = await fetch(url, { method: r.method, headers, body });
32
+ } catch {
33
+ throw new ApiFailure("server_error", `Cannot reach the Reelkit API at ${opts.baseUrl}. Check your connection or REELKIT_API_URL.`);
34
+ }
35
+ const json: unknown = await res.json().catch(() => undefined);
36
+ if (!res.ok) {
37
+ const err = ApiErrorSchema.safeParse(json);
38
+ // A code this version does not know is a server_error, but the server's own message is kept.
39
+ const known = err.success ? ErrorCodeSchema.safeParse(err.data.error.code) : undefined;
40
+ throw err.success ? new ApiFailure(known?.success ? known.data : "server_error", err.data.error.message) : new ApiFailure("server_error", `The Reelkit API returned ${res.status}.`);
41
+ }
42
+ const parsed = r.res.safeParse(json);
43
+ if (!parsed.success) throw new ApiFailure("server_error", "The Reelkit API sent a response this version of reelkit does not understand. Update reelkit and try again.");
44
+ return parsed.data as never;
45
+ };
46
+ }
47
+
48
+ export async function download(url: string, destPath: string): Promise<void> {
49
+ const res = await fetch(url);
50
+ if (!res.ok) throw new ApiFailure("server_error", `Download failed with ${res.status}. Run the command again.`);
51
+ await mkdir(dirname(destPath), { recursive: true });
52
+ await writeFile(destPath, new Uint8Array(await res.arrayBuffer()));
53
+ }
54
+
55
+ export async function uploadTo(url: string, bytes: Uint8Array, contentType: string): Promise<void> {
56
+ const res = await fetch(url, { method: "PUT", headers: { "content-type": contentType }, body: bytes as BodyInit });
57
+ if (!res.ok) throw new ApiFailure("server_error", `Upload failed with ${res.status}. Run the command again.`);
58
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,91 @@
1
+ import { Command } from "commander";
2
+ import { ApiFailure } from "./api/client";
3
+ import { assetsGenImage, assetsPull, assetsSearch, assetsUpload, assetsVoiceover, assetsVoices } from "./commands/assets";
4
+ import { authLogin, authLogout, whoami } from "./commands/auth";
5
+ import { check, preview, render } from "./commands/build";
6
+ import { init } from "./commands/init";
7
+ import { install } from "./commands/install";
8
+ import { openInBrowser } from "./open";
9
+ import { planCheck } from "./commands/plan";
10
+ import type { Ctx, Result } from "./context";
11
+
12
+ const ctx: Ctx = {
13
+ cwd: process.cwd(),
14
+ env: process.env,
15
+ log: (line) => console.error(line),
16
+ sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
17
+ // Read when used, not when this module loads.
18
+ get isTTY() { return Boolean(process.stdout.isTTY); },
19
+ openUrl: openInBrowser,
20
+ };
21
+
22
+ // "reelkit assets pull" from the Command object commander passes as the last action argument.
23
+ export function titleOf(args: unknown[]): string {
24
+ const names: string[] = [];
25
+ const last = args.at(-1);
26
+ for (let c = last instanceof Command ? last : undefined; c?.parent; c = c.parent) names.unshift(c.name());
27
+ return `reelkit ${names.join(" ")}`.trim();
28
+ }
29
+
30
+ // Runs a command, prints its result, and sets the exit code. Progress goes to stderr so --json output stays clean.
31
+ export function run<A extends unknown[]>(fn: (ctx: Ctx, ...args: A) => Promise<Result>) {
32
+ return async (...args: A) => {
33
+ const json = program.opts().json as boolean | undefined;
34
+ if (process.stdout.isTTY && !json) {
35
+ // Loaded only when a person is watching, so scripted runs never pay for it.
36
+ const { runInk } = await import("./ui/run");
37
+ const res = await runInk(titleOf(args), (log) => fn({ ...ctx, log }, ...args));
38
+ if (!res.ok) process.exitCode = 1;
39
+ return;
40
+ }
41
+ try {
42
+ const res = await fn(ctx, ...args);
43
+ console.log(json ? JSON.stringify({ ok: res.ok, summary: res.summary, data: res.data ?? null }, null, 2) : res.summary);
44
+ if (!res.ok) process.exitCode = 1;
45
+ } catch (e) {
46
+ const message = e instanceof Error ? e.message : String(e);
47
+ const code = e instanceof ApiFailure ? e.code : "error";
48
+ if (json) console.log(JSON.stringify({ ok: false, summary: message, data: { code } }, null, 2));
49
+ else console.error(message);
50
+ process.exitCode = 1;
51
+ }
52
+ };
53
+ }
54
+
55
+ export const program = new Command().name("reelkit").description("Make short-form video with a shared asset library.").option("--json", "print the result as JSON");
56
+
57
+ const auth = program.command("auth").description("Log in and out");
58
+ auth.command("login").description("Log in to Reelkit: opens the login page in your browser").option("--start", "begin a login and return the link at once, without opening a browser (for an agent)").option("--finish", "finish a login begun with --start")
59
+ .option("--no-browser", "print the link but do not open it (also: REELKIT_NO_BROWSER=1)")
60
+ .action(run((ctx, opts) => authLogin(ctx, { ...opts, json: Boolean(program.opts().json) })));
61
+ auth.command("logout").description("Log out and remove the stored token").action(run(authLogout));
62
+ program.command("whoami").description("Show your account, quota and contributions").action(run(whoami));
63
+
64
+ program.command("init [name]").description("Set up a video project (in a new folder named after it, or in this folder)").option("--aspect <aspect>", "9:16, 16:9 or 1:1").action(run(init));
65
+
66
+ const assets = program.command("assets").description("Find, add and generate the files a video needs");
67
+ assets.command("upload <file>").description("Add one of your own files to this project (private unless --share)")
68
+ .option("--describe <text>", "what the file shows").option("--footage", "use this video as the footage to overlay")
69
+ .option("--share", "also send it to the shared library").option("--kind <kind>", "library kind when sharing").option("--tags <tags>", "comma-separated tags when sharing")
70
+ .action(run(assetsUpload));
71
+ assets.command("search <query>").description("Search the shared library by meaning; each result shows how well it fits").option("--kind <kind>", "image, overlay, sfx, music, component or clip").option("--limit <n>", "how many results")
72
+ .action(run(assetsSearch));
73
+ assets.command("pull <id>").description("Download a library item into this project").option("--scene <sceneId>", "use it as this scene's image").option("--force", "replace a component file that already exists")
74
+ .action(run(assetsPull));
75
+ assets.command("voices").description("List the narration voices").action(run(assetsVoices));
76
+ assets.command("voiceover").description("Record the narration from plan.json").option("--scene <sceneId>", "one scene").option("--all", "every scene").option("--redo", "record again even if it exists")
77
+ .action(run(assetsVoiceover));
78
+ assets.command("gen").description("Generate an asset").command("image [prompt]").description("Generate a scene's illustration; the prompt defaults to the plan's")
79
+ .requiredOption("--scene <sceneId>", "the scene it is for").option("--redo", "generate a new image even if the scene has one").action(run(assetsGenImage));
80
+
81
+ program.command("plan").description("Work with plan.json").command("check").description("Validate plan.json and list what to fix or improve").action(run(planCheck));
82
+
83
+ program.command("check").description("Check the composition in src/ without rendering").action(run(check));
84
+ program.command("install").description("Install the Reelkit skill into your coding agents").option("--agent <id>", "claude, codex, cursor, gemini, agents or all").option("--force", "reinstall even when up to date").action(run((ctx, opts) => install(ctx, opts)));
85
+ program.command("preview").description("Render one test frame per scene into out/preview/").action(run((ctx) => preview(ctx)));
86
+ program.command("render").description("Render the video to out/video.mp4").action(run(render));
87
+
88
+ // Parses argv and runs the chosen command. Importing this module parses nothing; bin/reelkit.mjs calls main.
89
+ export async function main(argv: string[]): Promise<void> {
90
+ await program.parseAsync(argv).catch((e) => { console.error(e instanceof Error ? e.message : String(e)); process.exitCode = 1; });
91
+ }