reelson 0.7.0 → 0.7.2

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/CHANGELOG.md CHANGED
@@ -4,6 +4,32 @@ All notable changes to reelson. The format follows [Keep a Changelog](https://ke
4
4
  versions follow [Semantic Versioning](https://semver.org/) (before 1.0, a minor version may change
5
5
  `video.json` or `demo.config.json`; the notes say how to update).
6
6
 
7
+ ## 0.7.2 — 2026-09-23
8
+
9
+ The first release published by CI (staged, then approved on npm).
10
+
11
+ ### Changed
12
+ - The README clip is re-rendered by CI on every push to main and served from the rolling `demo`
13
+ pre-release, so the npm package no longer ships it (2.6 MB → about 0.3 MB).
14
+ - Hardened publishing: actions pinned to commit SHAs (Dependabot keeps them current, npm updates
15
+ after a 7-day cooldown), npm pinned, no install scripts or cache in the publish job, no token left
16
+ in the checkout, and the publish runs in the `npm` environment (approval before each release).
17
+ CI only **stages** a release (`npm stage publish`); it goes live once approved on npm with 2FA.
18
+ - README: a pipeline diagram that renders the same in every font, commands with their notes above them.
19
+
20
+ ## 0.7.1 — 2026-09-23
21
+
22
+ ### Changed
23
+ - **Works with any agent that reads Agent Skills**, not only Claude Code: `reelson install` links the
24
+ skills into `.agents/skills/` (Codex and others) and links `.claude/skills/` to them (nothing to do
25
+ when the whole `.claude/skills` folder already links to `.agents/skills`); `--global` does the same
26
+ in the home folder. New `demo.config.json` `$schema` paths and scenario imports go through
27
+ `.agents/skills`; existing `.claude/skills` paths keep working. The `.gitignore` hint adds the links.
28
+ - **`reelson install` asks where** when neither `--global` nor a folder is given: every project
29
+ (the default: `~/.agents/skills` + `~/.claude/skills`), this project, or another folder. `-y` takes
30
+ the default; without a terminal it never asks. (0.7.0 installed into the current folder.)
31
+ - Agent-neutral wording in the README, the skills and the prompting guide.
32
+
7
33
  ## 0.7.0 — 2026-09-23
8
34
 
9
35
  The first release on npm.
package/README.md CHANGED
@@ -1,7 +1,8 @@
1
1
  # reelson
2
2
 
3
3
  Turn a prompt into a finished, branded demo video of a web app. Two
4
- [Claude Code](https://claude.com/claude-code) skills plus a `reelson` CLI:
4
+ [Agent Skills](https://agentskills.io) plus a `reelson` CLI, for Claude Code, Codex or any agent
5
+ that reads `SKILL.md` skills:
5
6
 
6
7
  | Skill | Does | Output |
7
8
  |------------------------------------------|-----------------------------------------------------------------------------|------------------------------------------|
@@ -11,16 +12,24 @@ Turn a prompt into a finished, branded demo video of a web app. Two
11
12
  The same prompt re-creates the video after a UI change: a scenario re-records in ~20 s,
12
13
  headless, with identical pacing, and callouts/zooms follow their markers and clicks.
13
14
 
14
- ![The TodoMVC example: poster intro, the recorded walkthrough with callouts and zooms, recap and outro](https://cdn.jsdelivr.net/npm/reelson/docs/demo.webp)
15
+ ![The TodoMVC example: poster intro, the recorded walkthrough with callouts and zooms, recap and outro](https://github.com/reelson/reelson/releases/download/demo/demo.webp)
15
16
 
16
- <sub>The [example](examples/todo-add-item/scenario.ts), rendered by CI on every push
17
- (`docs/demo.webp` is a 960 px cut of it).</sub>
17
+ <sub>The [TodoMVC example](examples/todo-add-item/scenario.ts), recorded and rendered by CI on every
18
+ push to main.</sub>
18
19
 
19
20
  ```
20
- scenario.ts ──reelson record──▶ recording.mp4 + markers.json
21
-
22
- video.json (title, trim, callouts, zooms) ┴──reelson build──▶ video/ ──reelson render──▶ .mp4 / .gif
23
- demo.config.json (brand, logo, language, music) + template + sections ┘
21
+ scenario.ts
22
+ | reelson record
23
+ v
24
+ recording.mp4 + markers.json
25
+ | reelson build <-- video.json (title, trim, callouts, zooms)
26
+ | <-- demo.config.json (brand, logo, language, music)
27
+ | <-- template + sections
28
+ v
29
+ video/ a HyperFrames project
30
+ | reelson render
31
+ v
32
+ video/renders/<slug>.mp4 + .gif, portrait, square, .srt/.vtt captions
24
33
  ```
25
34
 
26
35
  ## Requirements
@@ -34,24 +43,33 @@ video.json (title, trim, callouts, zooms) ┴──reelson build──▶ video/
34
43
 
35
44
  ```bash
36
45
  npm install -g reelson
37
- reelson install ~/code/my-app # or: reelson install --global (~/.claude/skills, every project)
46
+ reelson install # asks: every project (default), this project, or another folder
38
47
  ```
39
48
 
40
- `reelson install` downloads Playwright's Chromium, links both skills into
41
- `my-app/.claude/skills/`, creates `my-app/demo.config.json` and prints the `.gitignore` lines.
42
- The links point at the installed package, so `npm update -g reelson` updates every project.
49
+ `reelson install` downloads Playwright's Chromium and links both skills into `.agents/skills/`
50
+ (Codex and the other agents that read Agent Skills) and `.claude/skills/` (Claude Code, linked to
51
+ the first or nothing to do when the whole folder already links to `.agents/skills`):
52
+
53
+ - **every project**: in `~/.agents/skills` and `~/.claude/skills` (`reelson install --global`);
54
+ run `reelson init` in a project for its `demo.config.json`;
55
+ - **one project**: in `my-app/.agents/skills` and `my-app/.claude/skills`
56
+ (`reelson install ~/code/my-app`); also creates `my-app/demo.config.json` and prints the
57
+ `.gitignore` lines.
58
+
59
+ `-y` takes the default without asking; without a terminal (CI, scripts) it never asks. The links
60
+ point at the installed package, so `npm update -g reelson` updates every project.
43
61
 
44
62
  To work on reelson itself, install from a checkout instead: the `reelson` command then runs the
45
63
  TypeScript sources directly and `git pull` updates every project.
46
64
 
47
65
  ```bash
48
66
  git clone git@github.com:reelson/reelson.git ~/reelson
49
- ~/reelson/install.sh ~/code/my-app # npm link + reelson install
67
+ ~/reelson/install.sh # npm link + reelson install (same question; or --global / <project-dir>)
50
68
  ```
51
69
 
52
70
  reelson was called reelkit before 0.7. Re-run `reelson install` (or `install.sh`) for each project: it drops the old
53
71
  `reelkit` command and `reelkit-*` skill links. Then point scenario imports and `$schema` paths
54
- at `.claude/skills/reelson-*`.
72
+ at `.agents/skills/reelson-*` (`.claude/skills/reelson-*` works too).
55
73
 
56
74
  ## Configure per project — `demo.config.json`
57
75
 
@@ -87,7 +105,7 @@ typo is an error with a "did you mean" hint).
87
105
 
88
106
  ## Use
89
107
 
90
- Ask Claude in the project, e.g.:
108
+ Ask your agent in the project, e.g.:
91
109
 
92
110
  > Make a demo video of searching a customer. Admin area on https://app.test as the admin.
93
111
  > Steps: open Customers, type a name in the table search, hover the match. Title "Find a
@@ -97,18 +115,30 @@ More prompts in [docs/prompting.md](docs/prompting.md); the rules every video fo
97
115
  [docs/style-guide.md](docs/style-guide.md). By hand:
98
116
 
99
117
  ```bash
100
- reelson doctor # tools + cursor/footage sync on this machine
101
- reelson new customers-search --url https://app.test # scenario stub
102
- reelson record customers-search [--headed] # --mobile / --square: the takes for --portrait / --square; --all-takes: all three
103
- reelson build customers-search --title "Find a customer" # creates video.json on first run
104
- # edit video.json: callout wording, { "clicks": [2, 3], "scale": 1.8 } zooms, trim ("auto" or a marker)
105
- reelson voice customers-search # "voice": true in video.json: speak the callouts (voice.provider: openai, elevenlabs, piper, command)
106
- reelson check customers-search # schemas, zoom timing, hyperframes lint
107
- reelson verify --all # after an app change: every demo still records and fits
108
- reelson studio customers-search # preview + edit on a layer timeline (saves video.json)
109
- reelson templates # templates and intro/recap/outro sections
110
- reelson render customers-search [--gif] [--square] [--portrait] [--all-formats] [--draft] # + .srt/.vtt captions
111
- reelson render --all # every demo; skips the unchanged ones
118
+ # tools + cursor/footage sync on this machine
119
+ reelson doctor
120
+ # a scenario stub
121
+ reelson new customers-search --url https://app.test
122
+ # --mobile / --square: the takes for --portrait / --square; --all-takes: all three
123
+ reelson record customers-search [--headed]
124
+ # creates video.json on the first run; then edit it: callout wording,
125
+ # { "clicks": [2, 3], "scale": 1.8 } zooms, trim ("auto" or a marker)
126
+ reelson build customers-search --title "Find a customer"
127
+ # "voice": true in video.json: speak the callouts
128
+ # (voice.provider: openai, elevenlabs, piper, command)
129
+ reelson voice customers-search
130
+ # schemas, zoom timing, hyperframes lint
131
+ reelson check customers-search
132
+ # after an app change: every demo still records and fits
133
+ reelson verify --all
134
+ # preview + edit on a layer timeline (saves video.json)
135
+ reelson studio customers-search
136
+ # templates and intro/recap/outro sections
137
+ reelson templates
138
+ # the MP4 + .srt/.vtt captions; --gif, --square, --portrait, --all-formats, --draft
139
+ reelson render customers-search
140
+ # every demo; skips the unchanged ones
141
+ reelson render --all
112
142
  ```
113
143
 
114
144
  Per video, commit `scenario.ts`, `markers.json` and `video.json`; everything else is generated.
@@ -155,10 +185,28 @@ Node will not run TypeScript from under `node_modules`, so the package ships `.j
155
185
  each `.ts` (`npm run build`; `npm pack` / `npm publish` build first and clean up after).
156
186
  `bin/run.js` picks the compiled CLI when installed from npm and the sources in a checkout.
157
187
 
158
- 1. Bump `version` in package.json, commit, tag `vX.Y.Z` and push the tag.
159
- 2. [.github/workflows/publish.yml](.github/workflows/publish.yml) runs the checks and publishes
160
- with npm trusted publishing (OIDC, with provenance; no token). Set it up once on npmjs.com →
161
- the package Settings Trusted publishing: GitHub Actions, `reelson/reelson`, `publish.yml`.
188
+ 1. Add the release to CHANGELOG.md, then `npm version patch` (or `minor` / `major`: bumps
189
+ package.json, commits, tags `vX.Y.Z`) and `git push --follow-tags`.
190
+ 2. [.github/workflows/publish.yml](.github/workflows/publish.yml) checks the tag matches
191
+ package.json, runs the checks and **stages** the release with npm trusted publishing (OIDC: no
192
+ npm token exists anywhere; with provenance). It runs in the `npm` environment, so it first
193
+ waits for a required reviewer on GitHub.
194
+ 3. Approve it on npm with 2FA: `npm stage list reelson`, then `npm stage approve <id>` (or on
195
+ npmjs.com). Until then nobody can install it.
196
+
197
+ One-time setup:
198
+
199
+ - npmjs.com → the package → Settings → **Trusted publisher**: GitHub Actions, `reelson` /
200
+ `reelson`, workflow `publish.yml`, environment `npm`, **"Allow npm publish" off** (it may only
201
+ stage). Under **Publishing access**, pick "Require two-factor authentication and disallow
202
+ bypass 2fa tokens".
203
+ - GitHub → Settings → **Environments** → `npm`: add yourself as a required reviewer and allow
204
+ only `v*` tags to deploy. Settings → Rules → **Rulesets**: restrict creating `v*` tags to
205
+ yourself. (Both need a public repository, or a paid plan for a private one.)
206
+
207
+ The workflows pin every action to a commit SHA ([Dependabot](.github/dependabot.yml) proposes
208
+ updates weekly, npm packages after a 7-day cooldown), install dependencies without install
209
+ scripts when publishing, and never leave a token in the checkout.
162
210
 
163
211
  The very first release has to be published by hand (trusted publishing needs the package to
164
212
  exist): `npm login && npm publish`. Check the contents first with `npm pack --dry-run`.
@@ -171,7 +219,7 @@ skills/
171
219
  reelson-record/ SKILL.md, scripts/ (record, scenario, cursor-overlay, config, validate), schemas/
172
220
  reelson-compose/ SKILL.md, scripts/ (build, check, timeline, zooms, composition, project, hyperframes),
173
221
  schemas/, templates/<name>/ (stages), sections/<slot>/<name>/
174
- docs/ style-guide.md, prompting.md, demo.webp (the README clip)
222
+ docs/ style-guide.md, prompting.md
175
223
  examples/ demo.config.json + todo-add-item/ (scenario, markers, video.json)
176
224
  test/ unit + golden tests, fixtures
177
225
  music/ local-only tracks (git-ignored; licences are per project)
package/bin/reelson.js CHANGED
@@ -5,12 +5,13 @@
5
5
  * demo.config.json (walking up from the working directory).
6
6
  */
7
7
  import { spawnSync } from 'node:child_process';
8
- import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
8
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
9
9
  import { createRequire } from 'node:module';
10
10
  import { homedir } from 'node:os';
11
+ import { createInterface } from 'node:readline/promises';
11
12
  import { basename, relative, resolve } from 'node:path';
12
13
  import { parseArgs, parseEnv } from 'node:util';
13
- import { CONFIG_SCHEMA_PATH, ConfigError, fromRoot, loadConfig } from "../skills/reelson-record/scripts/config.js";
14
+ import { CONFIG_SCHEMA_PATH, ConfigError, fromRoot, loadConfig, viaProjectSkills } from "../skills/reelson-record/scripts/config.js";
14
15
  import { doctor } from "../skills/reelson-record/scripts/doctor.js";
15
16
  import { build, plan } from "../skills/reelson-compose/scripts/build.js";
16
17
  import { captionCues, toSrt, toVtt } from "../skills/reelson-compose/scripts/captions.js";
@@ -26,11 +27,13 @@ const HELP = `reelson — scripted walkthroughs → branded demo videos
26
27
 
27
28
  Usage: reelson <command> [options]
28
29
 
29
- install [<project-dir>] [--global] [--no-browser]
30
+ install [<project-dir> | --global] [-y] [--no-browser]
30
31
  link the reelson-record + reelson-compose skills into
31
- <project>/.claude/skills/ (default: this directory; --global:
32
- ~/.claude/skills/), create demo.config.json if missing and
33
- download Playwright's Chromium (--no-browser: skip it)
32
+ .agents/skills/ (Codex and other agents) and .claude/skills/
33
+ (Claude Code): --global in ~/ for every project, or in a
34
+ project (which also gets a demo.config.json); asks which when
35
+ neither is given (-y, or no terminal: --global). Downloads
36
+ Playwright's Chromium (--no-browser: skip it)
34
37
  init create demo.config.json in this directory
35
38
  doctor check the tools and that the cursor layer lines up here
36
39
  new <slug> [--url <origin>] start <videosDir>/<slug>/scenario.ts
@@ -119,7 +122,7 @@ async function run(cmd, argv) {
119
122
  console.log(JSON.parse(readFileSync(resolve(KIT_ROOT, 'package.json'), 'utf8')).version);
120
123
  return 0;
121
124
  case 'install':
122
- return install(argv);
125
+ return await install(argv);
123
126
  case 'init':
124
127
  return init();
125
128
  case 'doctor':
@@ -176,22 +179,25 @@ function one(positionals, usage) {
176
179
  return positionals[0];
177
180
  }
178
181
  /**
179
- * `reelson install`: links the skills into a project (or ~/.claude) and prepares the machine. The links
182
+ * `reelson install`: links the skills into a project (or the home folder) and prepares the machine. The links
180
183
  * point at this installation, so `npm update -g reelson` (or `git pull` in a checkout) updates every project.
181
184
  */
182
- function install(argv) {
185
+ async function install(argv) {
183
186
  const skillNames = ['reelson-record', 'reelson-compose'];
184
187
  // Names used before 0.7 (demo-* before that): their links are dropped when they point at a reelson install.
185
188
  const oldNames = ['demo-record', 'demo-video', 'reelkit-record', 'reelkit-compose'];
186
189
  const { values, positionals } = parseArgs({
187
190
  args: argv,
188
191
  allowPositionals: true,
189
- options: { global: { type: 'boolean' }, 'no-browser': { type: 'boolean' } },
192
+ options: { global: { type: 'boolean' }, yes: { type: 'boolean', short: 'y' }, 'no-browser': { type: 'boolean' } },
190
193
  });
191
194
  if (positionals.length > 1 || (values.global && positionals.length)) {
192
- throw new ReelsonError('usage: reelson install [<project-dir> | --global] [--no-browser]');
195
+ throw new ReelsonError('usage: reelson install [<project-dir> | --global] [-y] [--no-browser]');
193
196
  }
194
- const target = values.global ? homedir() : resolve(positionals[0] ?? '.');
197
+ // Asked only when neither --global nor a folder is given and someone is at the terminal.
198
+ const where = values.global ? null : positionals.length ? resolve(positionals[0]) : values.yes || !process.stdin.isTTY ? null : await askWhere();
199
+ const global = where === null;
200
+ const target = where ?? homedir();
195
201
  if (!existsSync(target)) {
196
202
  throw new ReelsonError(`${target} does not exist`);
197
203
  }
@@ -206,43 +212,94 @@ function install(argv) {
206
212
  throw new ReelsonError('could not install Chromium — run `npx playwright install chromium`');
207
213
  }
208
214
  }
209
- const skills = resolve(target, '.claude/skills');
210
- mkdirSync(skills, { recursive: true });
215
+ // .agents/skills links to this installation; .claude/skills links to .agents/skills (unless the
216
+ // whole folder already is a link to it), so both agents see the same skills.
217
+ const agents = resolve(target, '.agents/skills');
218
+ const claude = resolve(target, '.claude/skills');
219
+ mkdirSync(agents, { recursive: true });
220
+ mkdirSync(claude, { recursive: true });
221
+ const shared = realpathSync(agents) === realpathSync(claude);
211
222
  const isLink = (path) => lstatSync(path, { throwIfNoEntry: false })?.isSymbolicLink() ?? false;
212
- for (const old of oldNames) {
213
- const link = resolve(skills, old);
214
- if (isLink(link) && (resolve(skills, readlinkSync(link)).startsWith(`${KIT_ROOT}/`) || /[\\/]skills[\\/](reelkit|reelson)-/.test(readlinkSync(link)))) {
215
- rmSync(link);
216
- console.log(` removed old link ${link}`);
223
+ const link = (path, to) => {
224
+ if (existsSync(path) && !isLink(path)) {
225
+ throw new ReelsonError(`${path} is a folder, not a link to reelson — move it away and run install again`);
226
+ }
227
+ if (isLink(path)) {
228
+ rmSync(path);
229
+ }
230
+ symlinkSync(to, path, 'dir');
231
+ console.log(` linked ${path} → ${to}`);
232
+ };
233
+ for (const dir of shared ? [agents] : [agents, claude]) {
234
+ for (const old of oldNames) {
235
+ const path = resolve(dir, old);
236
+ if (isLink(path) && (resolve(dir, readlinkSync(path)).startsWith(`${KIT_ROOT}/`) || /[\\/]skills[\\/](reelkit|reelson)-/.test(readlinkSync(path)))) {
237
+ rmSync(path);
238
+ console.log(` removed old link ${path}`);
239
+ }
217
240
  }
218
241
  }
219
242
  for (const skill of skillNames) {
220
- const link = resolve(skills, skill);
221
- if (existsSync(link) && !isLink(link)) {
222
- throw new ReelsonError(`${link} is a folder, not a link to reelson — move it away and run install again`);
243
+ link(resolve(agents, skill), resolve(KIT_ROOT, 'skills', skill));
244
+ if (!shared) {
245
+ link(resolve(claude, skill), relative(claude, resolve(agents, skill)));
223
246
  }
224
- if (isLink(link)) {
225
- rmSync(link);
226
- }
227
- symlinkSync(resolve(KIT_ROOT, 'skills', skill), link, 'dir');
228
- console.log(` linked ${link}`);
229
247
  }
230
- if (!values.global) {
248
+ if (!global) {
231
249
  if (!existsSync(resolve(target, 'demo.config.json'))) {
232
250
  init(target);
233
251
  }
234
252
  const videos = loadConfig(target).videosDir.replace(/^\.?\/+|\/+$/g, '');
235
253
  console.log(`
236
- Add to ${resolve(target, '.gitignore')}:
254
+ Add to ${resolve(target, '.gitignore')} (the skill links point at this machine's reelson):
237
255
 
256
+ /.agents/skills/reelson-*
257
+ /.claude/skills/reelson-*
238
258
  /${videos}/**/recording*.mp4
239
259
  /${videos}/**/.raw*/
240
260
  /${videos}/**/video/
241
261
  /${videos}/**/*.openscreen`);
242
262
  }
243
- console.log('\nDone. Try: reelson doctor');
263
+ console.log(global ? '\nDone — every project sees the skills. In a project: reelson init, then reelson doctor' : '\nDone. Try: reelson doctor');
244
264
  return 0;
245
265
  }
266
+ /** `reelson install`'s question: every project (the default), this one, or another folder → null for global, else the project. */
267
+ async function askWhere() {
268
+ const home = (path) => (path.startsWith(homedir()) ? `~${path.slice(homedir().length)}` : path);
269
+ const here = process.cwd();
270
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
271
+ try {
272
+ console.log(`Where should reelson install its skills?
273
+ 1. Every project (default): ~/.agents/skills, linked from ~/.claude/skills
274
+ 2. This project: ${home(here)}/.agents/skills, linked from .claude/skills
275
+ 3. Another project…`);
276
+ for (;;) {
277
+ const answer = (await rl.question('Choose [1]: ')).trim();
278
+ if (answer === '' || answer === '1')
279
+ return null;
280
+ if (answer === '2')
281
+ return here;
282
+ if (answer === '3') {
283
+ const dir = (await rl.question('Project folder: ')).trim().replace(/^~(?=$|\/)/, homedir());
284
+ if (dir && existsSync(dir))
285
+ return resolve(dir);
286
+ console.log(` no folder ${dir || '(empty)'}`);
287
+ continue;
288
+ }
289
+ console.log(' type 1, 2 or 3 (Enter: 1)');
290
+ }
291
+ }
292
+ catch (error) {
293
+ // Ctrl+D (or Ctrl+C) at the question: stop quietly, nothing linked yet.
294
+ if (error.name === 'AbortError') {
295
+ throw new ReelsonError('install cancelled — nothing was changed');
296
+ }
297
+ throw error;
298
+ }
299
+ finally {
300
+ rl.close();
301
+ }
302
+ }
246
303
  function init(dir = process.cwd()) {
247
304
  const target = resolve(dir, 'demo.config.json');
248
305
  if (existsSync(target)) {
@@ -251,8 +308,7 @@ function init(dir = process.cwd()) {
251
308
  const example = JSON.parse(readFileSync(resolve(KIT_ROOT, 'demo.config.example.json'), 'utf8'));
252
309
  delete example.$comment;
253
310
  delete example.$schema;
254
- const viaProject = resolve(dir, '.claude/skills/reelson-record/schemas/demo.config.schema.json');
255
- const schema = existsSync(viaProject) ? viaProject : CONFIG_SCHEMA_PATH;
311
+ const schema = viaProjectSkills(dir, 'reelson-record/schemas/demo.config.schema.json') ?? CONFIG_SCHEMA_PATH;
256
312
  let schemaRef = relative(dir, schema);
257
313
  if (!/^\.{1,2}\//.test(schemaRef) && !schemaRef.startsWith('/')) {
258
314
  schemaRef = `./${schemaRef}`;
@@ -275,8 +331,7 @@ function create(argv) {
275
331
  throw new ReelsonError(`${scenario} already exists`);
276
332
  }
277
333
  mkdirSync(dir, { recursive: true });
278
- const viaProject = resolve(cfg.root, '.claude/skills/reelson-record/scripts/scenario.ts');
279
- const typesFile = existsSync(viaProject) ? viaProject : resolve(KIT_ROOT, 'skills/reelson-record/scripts/scenario.ts');
334
+ const typesFile = viaProjectSkills(cfg.root, 'reelson-record/scripts/scenario.ts') ?? resolve(KIT_ROOT, 'skills/reelson-record/scripts/scenario.ts');
280
335
  let importPath = relative(dir, typesFile);
281
336
  if (!/^\.{1,2}\//.test(importPath)) {
282
337
  importPath = `./${importPath}`;
package/bin/reelson.ts CHANGED
@@ -5,12 +5,13 @@
5
5
  * demo.config.json (walking up from the working directory).
6
6
  */
7
7
  import { spawnSync } from 'node:child_process'
8
- import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
8
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
9
9
  import { createRequire } from 'node:module'
10
10
  import { homedir } from 'node:os'
11
+ import { createInterface } from 'node:readline/promises'
11
12
  import { basename, relative, resolve } from 'node:path'
12
13
  import { parseArgs, parseEnv } from 'node:util'
13
- import { CONFIG_SCHEMA_PATH, ConfigError, fromRoot, loadConfig, type LoadedConfig } from '../skills/reelson-record/scripts/config.ts'
14
+ import { CONFIG_SCHEMA_PATH, ConfigError, fromRoot, loadConfig, viaProjectSkills, type LoadedConfig } from '../skills/reelson-record/scripts/config.ts'
14
15
  import { doctor } from '../skills/reelson-record/scripts/doctor.ts'
15
16
  import { build, plan, type BuildOptions } from '../skills/reelson-compose/scripts/build.ts'
16
17
  import { captionCues, toSrt, toVtt } from '../skills/reelson-compose/scripts/captions.ts'
@@ -27,11 +28,13 @@ const HELP = `reelson — scripted walkthroughs → branded demo videos
27
28
 
28
29
  Usage: reelson <command> [options]
29
30
 
30
- install [<project-dir>] [--global] [--no-browser]
31
+ install [<project-dir> | --global] [-y] [--no-browser]
31
32
  link the reelson-record + reelson-compose skills into
32
- <project>/.claude/skills/ (default: this directory; --global:
33
- ~/.claude/skills/), create demo.config.json if missing and
34
- download Playwright's Chromium (--no-browser: skip it)
33
+ .agents/skills/ (Codex and other agents) and .claude/skills/
34
+ (Claude Code): --global in ~/ for every project, or in a
35
+ project (which also gets a demo.config.json); asks which when
36
+ neither is given (-y, or no terminal: --global). Downloads
37
+ Playwright's Chromium (--no-browser: skip it)
35
38
  init create demo.config.json in this directory
36
39
  doctor check the tools and that the cursor layer lines up here
37
40
  new <slug> [--url <origin>] start <videosDir>/<slug>/scenario.ts
@@ -122,7 +125,7 @@ async function run(cmd: string | undefined, argv: string[]): Promise<number> {
122
125
  console.log(JSON.parse(readFileSync(resolve(KIT_ROOT, 'package.json'), 'utf8')).version)
123
126
  return 0
124
127
  case 'install':
125
- return install(argv)
128
+ return await install(argv)
126
129
  case 'init':
127
130
  return init()
128
131
  case 'doctor':
@@ -183,22 +186,25 @@ function one(positionals: string[], usage: string): string {
183
186
  }
184
187
 
185
188
  /**
186
- * `reelson install`: links the skills into a project (or ~/.claude) and prepares the machine. The links
189
+ * `reelson install`: links the skills into a project (or the home folder) and prepares the machine. The links
187
190
  * point at this installation, so `npm update -g reelson` (or `git pull` in a checkout) updates every project.
188
191
  */
189
- function install(argv: string[]): number {
192
+ async function install(argv: string[]): Promise<number> {
190
193
  const skillNames = ['reelson-record', 'reelson-compose']
191
194
  // Names used before 0.7 (demo-* before that): their links are dropped when they point at a reelson install.
192
195
  const oldNames = ['demo-record', 'demo-video', 'reelkit-record', 'reelkit-compose']
193
196
  const { values, positionals } = parseArgs({
194
197
  args: argv,
195
198
  allowPositionals: true,
196
- options: { global: { type: 'boolean' }, 'no-browser': { type: 'boolean' } },
199
+ options: { global: { type: 'boolean' }, yes: { type: 'boolean', short: 'y' }, 'no-browser': { type: 'boolean' } },
197
200
  })
198
201
  if (positionals.length > 1 || (values.global && positionals.length)) {
199
- throw new ReelsonError('usage: reelson install [<project-dir> | --global] [--no-browser]')
202
+ throw new ReelsonError('usage: reelson install [<project-dir> | --global] [-y] [--no-browser]')
200
203
  }
201
- const target = values.global ? homedir() : resolve(positionals[0] ?? '.')
204
+ // Asked only when neither --global nor a folder is given and someone is at the terminal.
205
+ const where = values.global ? null : positionals.length ? resolve(positionals[0]) : values.yes || !process.stdin.isTTY ? null : await askWhere()
206
+ const global = where === null
207
+ const target = where ?? homedir()
202
208
  if (!existsSync(target)) {
203
209
  throw new ReelsonError(`${target} does not exist`)
204
210
  }
@@ -214,45 +220,92 @@ function install(argv: string[]): number {
214
220
  }
215
221
  }
216
222
 
217
- const skills = resolve(target, '.claude/skills')
218
- mkdirSync(skills, { recursive: true })
223
+ // .agents/skills links to this installation; .claude/skills links to .agents/skills (unless the
224
+ // whole folder already is a link to it), so both agents see the same skills.
225
+ const agents = resolve(target, '.agents/skills')
226
+ const claude = resolve(target, '.claude/skills')
227
+ mkdirSync(agents, { recursive: true })
228
+ mkdirSync(claude, { recursive: true })
229
+ const shared = realpathSync(agents) === realpathSync(claude)
219
230
  const isLink = (path: string): boolean => lstatSync(path, { throwIfNoEntry: false })?.isSymbolicLink() ?? false
220
- for (const old of oldNames) {
221
- const link = resolve(skills, old)
222
- if (isLink(link) && (resolve(skills, readlinkSync(link)).startsWith(`${KIT_ROOT}/`) || /[\\/]skills[\\/](reelkit|reelson)-/.test(readlinkSync(link)))) {
223
- rmSync(link)
224
- console.log(` removed old link ${link}`)
231
+ const link = (path: string, to: string): void => {
232
+ if (existsSync(path) && !isLink(path)) {
233
+ throw new ReelsonError(`${path} is a folder, not a link to reelson — move it away and run install again`)
234
+ }
235
+ if (isLink(path)) {
236
+ rmSync(path)
237
+ }
238
+ symlinkSync(to, path, 'dir')
239
+ console.log(` linked ${path} → ${to}`)
240
+ }
241
+ for (const dir of shared ? [agents] : [agents, claude]) {
242
+ for (const old of oldNames) {
243
+ const path = resolve(dir, old)
244
+ if (isLink(path) && (resolve(dir, readlinkSync(path)).startsWith(`${KIT_ROOT}/`) || /[\\/]skills[\\/](reelkit|reelson)-/.test(readlinkSync(path)))) {
245
+ rmSync(path)
246
+ console.log(` removed old link ${path}`)
247
+ }
225
248
  }
226
249
  }
227
250
  for (const skill of skillNames) {
228
- const link = resolve(skills, skill)
229
- if (existsSync(link) && !isLink(link)) {
230
- throw new ReelsonError(`${link} is a folder, not a link to reelson — move it away and run install again`)
231
- }
232
- if (isLink(link)) {
233
- rmSync(link)
251
+ link(resolve(agents, skill), resolve(KIT_ROOT, 'skills', skill))
252
+ if (!shared) {
253
+ link(resolve(claude, skill), relative(claude, resolve(agents, skill)))
234
254
  }
235
- symlinkSync(resolve(KIT_ROOT, 'skills', skill), link, 'dir')
236
- console.log(` linked ${link}`)
237
255
  }
238
256
 
239
- if (!values.global) {
257
+ if (!global) {
240
258
  if (!existsSync(resolve(target, 'demo.config.json'))) {
241
259
  init(target)
242
260
  }
243
261
  const videos = loadConfig(target).videosDir.replace(/^\.?\/+|\/+$/g, '')
244
262
  console.log(`
245
- Add to ${resolve(target, '.gitignore')}:
263
+ Add to ${resolve(target, '.gitignore')} (the skill links point at this machine's reelson):
246
264
 
265
+ /.agents/skills/reelson-*
266
+ /.claude/skills/reelson-*
247
267
  /${videos}/**/recording*.mp4
248
268
  /${videos}/**/.raw*/
249
269
  /${videos}/**/video/
250
270
  /${videos}/**/*.openscreen`)
251
271
  }
252
- console.log('\nDone. Try: reelson doctor')
272
+ console.log(global ? '\nDone — every project sees the skills. In a project: reelson init, then reelson doctor' : '\nDone. Try: reelson doctor')
253
273
  return 0
254
274
  }
255
275
 
276
+ /** `reelson install`'s question: every project (the default), this one, or another folder → null for global, else the project. */
277
+ async function askWhere(): Promise<string | null> {
278
+ const home = (path: string): string => (path.startsWith(homedir()) ? `~${path.slice(homedir().length)}` : path)
279
+ const here = process.cwd()
280
+ const rl = createInterface({ input: process.stdin, output: process.stdout })
281
+ try {
282
+ console.log(`Where should reelson install its skills?
283
+ 1. Every project (default): ~/.agents/skills, linked from ~/.claude/skills
284
+ 2. This project: ${home(here)}/.agents/skills, linked from .claude/skills
285
+ 3. Another project…`)
286
+ for (;;) {
287
+ const answer = (await rl.question('Choose [1]: ')).trim()
288
+ if (answer === '' || answer === '1') return null
289
+ if (answer === '2') return here
290
+ if (answer === '3') {
291
+ const dir = (await rl.question('Project folder: ')).trim().replace(/^~(?=$|\/)/, homedir())
292
+ if (dir && existsSync(dir)) return resolve(dir)
293
+ console.log(` no folder ${dir || '(empty)'}`)
294
+ continue
295
+ }
296
+ console.log(' type 1, 2 or 3 (Enter: 1)')
297
+ }
298
+ } catch (error) {
299
+ // Ctrl+D (or Ctrl+C) at the question: stop quietly, nothing linked yet.
300
+ if ((error as Error).name === 'AbortError') {
301
+ throw new ReelsonError('install cancelled — nothing was changed')
302
+ }
303
+ throw error
304
+ } finally {
305
+ rl.close()
306
+ }
307
+ }
308
+
256
309
  function init(dir = process.cwd()): number {
257
310
  const target = resolve(dir, 'demo.config.json')
258
311
  if (existsSync(target)) {
@@ -261,8 +314,7 @@ function init(dir = process.cwd()): number {
261
314
  const example = JSON.parse(readFileSync(resolve(KIT_ROOT, 'demo.config.example.json'), 'utf8'))
262
315
  delete example.$comment
263
316
  delete example.$schema
264
- const viaProject = resolve(dir, '.claude/skills/reelson-record/schemas/demo.config.schema.json')
265
- const schema = existsSync(viaProject) ? viaProject : CONFIG_SCHEMA_PATH
317
+ const schema = viaProjectSkills(dir, 'reelson-record/schemas/demo.config.schema.json') ?? CONFIG_SCHEMA_PATH
266
318
  let schemaRef = relative(dir, schema)
267
319
  if (!/^\.{1,2}\//.test(schemaRef) && !schemaRef.startsWith('/')) {
268
320
  schemaRef = `./${schemaRef}`
@@ -286,8 +338,8 @@ function create(argv: string[]): number {
286
338
  throw new ReelsonError(`${scenario} already exists`)
287
339
  }
288
340
  mkdirSync(dir, { recursive: true })
289
- const viaProject = resolve(cfg.root, '.claude/skills/reelson-record/scripts/scenario.ts')
290
- const typesFile = existsSync(viaProject) ? viaProject : resolve(KIT_ROOT, 'skills/reelson-record/scripts/scenario.ts')
341
+ const typesFile =
342
+ viaProjectSkills(cfg.root, 'reelson-record/scripts/scenario.ts') ?? resolve(KIT_ROOT, 'skills/reelson-record/scripts/scenario.ts')
291
343
  let importPath = relative(dir, typesFile)
292
344
  if (!/^\.{1,2}\//.test(importPath)) {
293
345
  importPath = `./${importPath}`
package/docs/prompting.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # How to ask for a video
2
2
 
3
3
  The prompt is the spec. A complete prompt has six parts; the more you give, the fewer guesses
4
- Claude makes and the fewer re-renders you need.
4
+ the agent makes and the fewer re-renders you need.
5
5
 
6
6
  ```
7
7
  Make a demo video of <FEATURE>.
@@ -14,7 +14,7 @@ Emphasis: zoom on <what> during step <n> only where the UI is small
14
14
  Output: mp4 (default) / also gif slug: <kebab-case>
15
15
  ```
16
16
 
17
- Claude writes the scenario, records, verifies frames, builds the composition, words the
17
+ The agent writes the scenario, records, verifies frames, builds the composition, words the
18
18
  callouts in video.json, checks, renders, and reports the file path with a few extracted frames.
19
19
 
20
20
  ## Example prompts
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "reelson",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
4
4
  "type": "module",
5
- "description": "Claude Code skills + CLI that turn a scripted Playwright walkthrough into a branded HyperFrames demo video.",
5
+ "description": "Agent Skills (Claude Code, Codex, …) + CLI that turn a scripted Playwright walkthrough into a branded HyperFrames demo video.",
6
6
  "keywords": [
7
7
  "demo-video",
8
8
  "screencast",
@@ -11,7 +11,9 @@
11
11
  "claude-code",
12
12
  "skills",
13
13
  "product-demo",
14
- "video"
14
+ "video",
15
+ "agent-skills",
16
+ "codex"
15
17
  ],
16
18
  "license": "MIT",
17
19
  "author": "Ion Caliman <icaliman92@gmail.com>",
@@ -6,7 +6,7 @@ import { spawnSync } from 'node:child_process';
6
6
  import { existsSync, readdirSync, readFileSync, realpathSync } from 'node:fs';
7
7
  import { basename, dirname, extname, relative, resolve } from 'node:path';
8
8
  import { fileURLToPath } from 'node:url';
9
- import { fromRoot } from "../../reelson-record/scripts/config.js";
9
+ import { fromRoot, viaProjectSkills } from "../../reelson-record/scripts/config.js";
10
10
  import { closest, loadSchema, validate } from "../../reelson-record/scripts/validate.js";
11
11
  import { SECTION_TIMING, SLOTS, STAGE_TIMING, } from "./timeline.js";
12
12
  const HERE = dirname(fileURLToPath(import.meta.url));
@@ -81,12 +81,12 @@ export function validateVideoSpec(raw, where) {
81
81
  return raw;
82
82
  }
83
83
  /**
84
- * `$schema` for a new video.json: through the project's .claude/skills link
85
- * when there is one (portable), else straight to the kit.
84
+ * `$schema` for a new video.json: through the project's skill link (.agents/skills or
85
+ * .claude/skills) when it points at this reelson (portable), else straight to the kit.
86
86
  */
87
87
  export function videoSchemaRef(demoDir, config) {
88
- const viaProject = resolve(config.root, '.claude/skills/reelson-compose/schemas/video.schema.json');
89
- const target = existsSync(viaProject) && realpathSync(viaProject) === realpathSync(VIDEO_SCHEMA_PATH)
88
+ const viaProject = viaProjectSkills(config.root, 'reelson-compose/schemas/video.schema.json');
89
+ const target = viaProject && realpathSync(viaProject) === realpathSync(VIDEO_SCHEMA_PATH)
90
90
  ? viaProject
91
91
  : VIDEO_SCHEMA_PATH;
92
92
  return relative(demoDir, target);
@@ -6,7 +6,7 @@ import { spawnSync } from 'node:child_process'
6
6
  import { existsSync, readdirSync, readFileSync, realpathSync } from 'node:fs'
7
7
  import { basename, dirname, extname, relative, resolve } from 'node:path'
8
8
  import { fileURLToPath } from 'node:url'
9
- import { fromRoot, type LoadedConfig } from '../../reelson-record/scripts/config.ts'
9
+ import { fromRoot, viaProjectSkills, type LoadedConfig } from '../../reelson-record/scripts/config.ts'
10
10
  import { closest, loadSchema, validate } from '../../reelson-record/scripts/validate.ts'
11
11
  import type { SectionSource } from './composition.ts'
12
12
  import {
@@ -102,13 +102,13 @@ export function validateVideoSpec(raw: unknown, where: string): VideoSpec {
102
102
  }
103
103
 
104
104
  /**
105
- * `$schema` for a new video.json: through the project's .claude/skills link
106
- * when there is one (portable), else straight to the kit.
105
+ * `$schema` for a new video.json: through the project's skill link (.agents/skills or
106
+ * .claude/skills) when it points at this reelson (portable), else straight to the kit.
107
107
  */
108
108
  export function videoSchemaRef(demoDir: string, config: LoadedConfig): string {
109
- const viaProject = resolve(config.root, '.claude/skills/reelson-compose/schemas/video.schema.json')
109
+ const viaProject = viaProjectSkills(config.root, 'reelson-compose/schemas/video.schema.json')
110
110
  const target =
111
- existsSync(viaProject) && realpathSync(viaProject) === realpathSync(VIDEO_SCHEMA_PATH)
111
+ viaProject && realpathSync(viaProject) === realpathSync(VIDEO_SCHEMA_PATH)
112
112
  ? viaProject
113
113
  : VIDEO_SCHEMA_PATH
114
114
 
@@ -33,7 +33,7 @@ async function load() {
33
33
  if (!body.data) return
34
34
  const first = !data
35
35
  data = body.data
36
- // Undo writes back a whole earlier video.json; after a change from elsewhere (Claude, an
36
+ // Undo writes back a whole earlier video.json; after a change from elsewhere (the agent, an
37
37
  // editor) that would silently revert it, so the history starts over.
38
38
  if (!first && body.revision !== revision && (history.undo.length || history.redo.length)) {
39
39
  history.undo = []
@@ -75,8 +75,9 @@ Common dev overlays (Laravel Debugbar, Vite/Next.js/webpack error overlays) are
75
75
  ## Prerequisites
76
76
 
77
77
  - The app running and reachable at the scenario's `baseURL`.
78
- - reelson installed once per machine (`npm install -g reelson`, then `reelson install` in the
79
- project: Playwright's Chromium and the skill links; a kit checkout's `install.sh` does both).
78
+ - reelson installed once per machine (`npm install -g reelson`, then `reelson install --global`
79
+ for every project or `reelson install <project>` for one: Playwright's Chromium and the skill
80
+ links; a kit checkout's `install.sh` does both). Pass one of them: without either it asks the user.
80
81
  - `ffmpeg` on PATH (`brew install ffmpeg`). Node 22.18+ (TypeScript runs directly).
81
82
  - `reelson doctor` checks the tools and films a local test page to confirm the cursor layer
82
83
  lines up with the footage on this machine (offset and px error; run it on a new machine).
@@ -85,7 +86,7 @@ Common dev overlays (Laravel Debugbar, Vite/Next.js/webpack error overlays) are
85
86
 
86
87
  1. **Pick the origin, panel and account.** Look for existing e2e helpers in the project
87
88
  (login, table/modal locators) and reuse them from the scenario — a scenario is mostly an
88
- e2e test with a cursor. Check the project's CLAUDE.md / e2e docs for seeded accounts.
89
+ e2e test with a cursor. Check the project's AGENTS.md / CLAUDE.md / e2e docs for seeded accounts.
89
90
 
90
91
  2. **Write `<videosDir>/<slug>/scenario.ts`.** `reelson new <slug> --url <origin>` creates a
91
92
  stub with the right type import; the kit's `examples/todo-add-item/scenario.ts` is a full
@@ -113,6 +113,21 @@ export function countLabel(count, label, language) {
113
113
  return `${count} ${label[category] ?? label.other}`;
114
114
  }
115
115
  /** Resolves a path from the config against the project root. */
116
+ /**
117
+ * Where a project links the skills, in order: .agents/skills (Codex and the other agents that read
118
+ * Agent Skills), then .claude/skills (Claude Code; `reelson install` links it to the first).
119
+ */
120
+ export const SKILL_DIRS = ['.agents/skills', '.claude/skills'];
121
+ /** `<skill>/<path>` inside the project's first skill folder that has it (a portable path to commit), else null. */
122
+ export function viaProjectSkills(root, path) {
123
+ for (const dir of SKILL_DIRS) {
124
+ const candidate = resolve(root, dir, path);
125
+ if (existsSync(candidate)) {
126
+ return candidate;
127
+ }
128
+ }
129
+ return null;
130
+ }
116
131
  export function fromRoot(config, path) {
117
132
  return isAbsolute(path) ? path : resolve(config.root, path);
118
133
  }
@@ -225,6 +225,23 @@ export function countLabel(count: number, label: Label, language: string): strin
225
225
  }
226
226
 
227
227
  /** Resolves a path from the config against the project root. */
228
+ /**
229
+ * Where a project links the skills, in order: .agents/skills (Codex and the other agents that read
230
+ * Agent Skills), then .claude/skills (Claude Code; `reelson install` links it to the first).
231
+ */
232
+ export const SKILL_DIRS = ['.agents/skills', '.claude/skills']
233
+
234
+ /** `<skill>/<path>` inside the project's first skill folder that has it (a portable path to commit), else null. */
235
+ export function viaProjectSkills(root: string, path: string): string | null {
236
+ for (const dir of SKILL_DIRS) {
237
+ const candidate = resolve(root, dir, path)
238
+ if (existsSync(candidate)) {
239
+ return candidate
240
+ }
241
+ }
242
+ return null
243
+ }
244
+
228
245
  export function fromRoot(config: LoadedConfig, path: string): string {
229
246
  return isAbsolute(path) ? path : resolve(config.root, path)
230
247
  }
package/docs/demo.webp DELETED
Binary file