overlaify 0.0.0-stage → 0.1.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.
@@ -0,0 +1,127 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <script src="https://www.overlaify.com/sdk/v1/overlaify.js"></script>
5
+ <meta charset="utf-8">
6
+ <title>Subathon Timer</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link href="https://fonts.googleapis.com/css2?family=Chakra+Petch:wght@600;700&display=swap" rel="stylesheet">
9
+ <script type="application/json" id="overlaify-manifest">
10
+ {
11
+ "spec": 1,
12
+ "settings": [
13
+ {"key": "label", "label": "Title", "type": "text", "default": "Subathon", "group": "Look"},
14
+ {"key": "color", "label": "Accent color", "type": "color", "default": "#5ce1ff", "swatches": ["#5ce1ff", "#ff4f8b", "#c6ff3d", "#ffb547", "#a879ff"], "group": "Look"},
15
+ {"key": "hours", "label": "Starting length (hours)", "type": "number", "default": 4, "group": "Timer"},
16
+ {"key": "sub", "label": "Seconds per sub", "type": "number", "default": 300, "desc": "Tier 2 subs count double, tier 3 five times.", "group": "Timer"},
17
+ {"key": "bits", "label": "Seconds per 100 bits", "type": "number", "default": 60, "group": "Timer"},
18
+ {"key": "max", "label": "Maximum length (hours)", "type": "number", "default": 0, "desc": "0 means no cap.", "group": "Timer"}
19
+ ],
20
+ "actions": [
21
+ {"name": "pause", "label": "Pause", "style": "secondary", "group": "Clock"},
22
+ {"name": "resume", "label": "Resume", "group": "Clock"},
23
+ {"name": "add", "label": "Add time", "input": {"type": "number", "label": "Minutes", "default": 10, "step": 5, "min": 1}, "group": "Adjust"},
24
+ {"name": "remove", "label": "Remove time", "style": "secondary", "input": {"type": "number", "label": "Minutes", "default": 5, "step": 5, "min": 1}, "group": "Adjust"},
25
+ {"name": "restart", "label": "Start over", "style": "danger", "confirm": "Reset the timer?", "wide": true, "group": "Adjust"}
26
+ ]
27
+ }
28
+ </script>
29
+ <style>
30
+ :root { --c: #5ce1ff; --c2: #ff4f8b; }
31
+ html, body { margin: 0; width: 1920px; height: 1080px; overflow: hidden; background: transparent; }
32
+ .box { position: absolute; right: 60px; top: 90px; padding: 18px 34px 22px; border-radius: 20px; text-align: center;
33
+ font-family: 'Chakra Petch', system-ui, sans-serif; color: #fff;
34
+ background: linear-gradient(160deg, rgba(14,12,26,.9), rgba(24,16,40,.9)); border: 2px solid rgba(92,225,255,.45);
35
+ box-shadow: 0 0 0 1px rgba(0,0,0,.4), 0 12px 40px rgba(0,0,0,.45), 0 0 40px rgba(92,225,255,.18); }
36
+ .label { font-size: 22px; font-weight: 600; letter-spacing: .3em; text-transform: uppercase; color: var(--c); }
37
+ .time { font-size: 92px; font-weight: 700; line-height: 1.05; font-variant-numeric: tabular-nums; letter-spacing: .02em; text-shadow: 0 0 24px rgba(92,225,255,.45); }
38
+ .paused .time { opacity: .5; }
39
+ .state { font-size: 18px; font-weight: 600; letter-spacing: .2em; color: var(--c2); height: 22px; }
40
+ .rules { display: flex; justify-content: center; gap: 18px; margin-top: 6px; font-size: 18px; color: rgba(255,255,255,.6); }
41
+ .bump { animation: bump .5s; }
42
+ .add { position: absolute; left: 50%; top: 40px; font-size: 44px; font-weight: 700; color: var(--c); text-shadow: 0 0 16px var(--c); white-space: nowrap; animation: up 1.8s ease-out forwards; pointer-events: none; }
43
+ .add small { display: block; font-size: 20px; color: #fff; text-shadow: none; opacity: .85; }
44
+ .over .time { color: var(--c2); }
45
+ @keyframes bump { 35% { transform: scale(1.06); } }
46
+ @keyframes up { from { opacity: 0; transform: translate(-50%, 30px); } 15% { opacity: 1; } to { opacity: 0; transform: translate(-50%, -110px); } }
47
+ </style>
48
+ </head>
49
+ <body>
50
+ <div class="box" id="box">
51
+ <div class="label" id="label"></div>
52
+ <div class="time" id="time">0:00:00</div>
53
+ <div class="state" id="state"></div>
54
+ <div class="rules" id="rules"></div>
55
+ </div>
56
+ <script>
57
+ // ?hours=4 starting time &sub=300 seconds per sub (tier 2 = x2, tier 3 = x5) &bits=60 seconds per 100 bits
58
+ // &max=24 hard cap in hours (0 = none) &reset=1 start over (only when the value changes, so it is safe to leave in) &label=Subathon &color=%235ce1ff
59
+ // Mods and the broadcaster can type: !subathon add 10 !subathon remove 5 (minutes) !subathon pause !subathon resume
60
+ const esc = Overlaify.escape;
61
+ document.documentElement.style.setProperty('--c', Overlaify.param('color', '#5ce1ff'));
62
+ document.getElementById('label').textContent = Overlaify.param('label', 'Subathon');
63
+ const PER_SUB = Overlaify.param('sub', 300), PER_100_BITS = Overlaify.param('bits', 60);
64
+ const MAX = Overlaify.param('max', 0) * 3600e3;
65
+ const KEY = `overlaify-subathon-${Overlaify.channel || 'preview'}`;
66
+ const fmt = s => { s = Math.max(0, Math.round(s)); return Math.floor(s / 3600) + ':' + String(Math.floor(s / 60) % 60).padStart(2, '0') + ':' + String(s % 60).padStart(2, '0'); };
67
+ document.getElementById('rules').innerHTML = [PER_SUB && `sub +${fmt(PER_SUB).replace(/^0:/, '')}`, PER_100_BITS && `100 bits +${fmt(PER_100_BITS).replace(/^0:/, '')}`].filter(Boolean).map(r => `<span>${esc(r)}</span>`).join('');
68
+
69
+ // state: end = timestamp the timer hits zero; while paused, left = ms remaining.
70
+ let st = null;
71
+ const RESET = Overlaify.param('reset', '');
72
+ if (!Overlaify.isTest) { try { st = JSON.parse(localStorage.getItem(KEY)); } catch (e) {} }
73
+ if (st && RESET && st.reset !== RESET) st = null;
74
+ if (!st) st = { end: Date.now() + Overlaify.param('hours', 4) * 3600e3, started: Date.now(), paused: false, left: 0, reset: RESET };
75
+ const save = () => { if (Overlaify.isTest) return; try { localStorage.setItem(KEY, JSON.stringify(st)); } catch (e) {} };
76
+ save();
77
+ const left = () => st.paused ? st.left : st.end - Date.now();
78
+
79
+ const box = document.getElementById('box'), time = document.getElementById('time'), state = document.getElementById('state');
80
+ function render() {
81
+ const l = left();
82
+ time.textContent = fmt(l / 1000);
83
+ box.classList.toggle('paused', st.paused);
84
+ box.classList.toggle('over', l <= 0);
85
+ state.textContent = st.paused ? 'PAUSED' : l <= 0 ? 'SUBATHON OVER' : '';
86
+ }
87
+ (function tick() { render(); setTimeout(tick, 250); })();
88
+
89
+ function add(sec, why) {
90
+ if (!sec || left() <= 0 && !st.paused) return;
91
+ let ms = sec * 1000;
92
+ if (MAX && sec > 0) ms = Math.min(ms, st.started + MAX - (Date.now() + left()));
93
+ if (ms <= 0 && sec > 0) return;
94
+ if (st.paused) st.left = Math.max(0, st.left + ms); else st.end += ms;
95
+ save(); render();
96
+ box.classList.remove('bump'); void box.offsetWidth; box.classList.add('bump');
97
+ const a = document.createElement('div');
98
+ a.className = 'add';
99
+ a.innerHTML = `${ms > 0 ? '+' : '−'}${esc(fmt(Math.abs(ms) / 1000).replace(/^0:/, ''))}${why ? `<small>${esc(why)}</small>` : ''}`;
100
+ box.appendChild(a); setTimeout(() => a.remove(), 1900);
101
+ if (ms > 0) Overlaify.chime('coin', 0.15);
102
+ }
103
+ const mult = t => t === '3' ? 5 : t === '2' ? 2 : 1;
104
+ Overlaify.on('sub', e => add(PER_SUB * mult(e.tier), e.name));
105
+ Overlaify.on('gift', e => add(PER_SUB * mult(e.tier), e.name));
106
+ // A gift bomb is followed by one 'gift' event per sub; only simulations need the bulk add.
107
+ Overlaify.on('giftbomb', e => e.simulated && add(PER_SUB * e.count * mult(e.tier), e.name));
108
+ Overlaify.on('cheer', e => add(PER_100_BITS * e.bits / 100, e.name));
109
+
110
+ // Actions, from the control panel or from mods in chat.
111
+ Overlaify.action('add', (min, ev) => add(Number(min || 0) * 60, ev.by ? 'added by ' + ev.by : ''));
112
+ Overlaify.action('remove', min => add(-Number(min || 0) * 60, ''));
113
+ Overlaify.action('pause', () => { if (!st.paused) { st.left = Math.max(0, left()); st.paused = true; save(); render(); } });
114
+ Overlaify.action('resume', () => { if (st.paused) { st.end = Date.now() + st.left; st.paused = false; save(); render(); } });
115
+ Overlaify.action('restart', () => {
116
+ st = { end: Date.now() + Overlaify.param('hours', 4) * 3600e3, started: Date.now(), paused: false, left: 0, reset: RESET };
117
+ save(); render();
118
+ });
119
+
120
+ Overlaify.on('chat', e => {
121
+ if (!(e.isMod || e.isBroadcaster || e.user === Overlaify.channel)) return;
122
+ const m = e.message.trim().toLowerCase().match(/^!subathon\s+(add|remove|pause|resume)\s*(\d+)?/);
123
+ if (m) Overlaify.dispatch(m[1], m[2], { by: e.name });
124
+ });
125
+ </script>
126
+ </body>
127
+ </html>
@@ -0,0 +1,93 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <script src="https://www.overlaify.com/sdk/v1/overlaify.js"></script>
5
+ <meta charset="utf-8">
6
+ <title>Neon Webcam Frame</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;700&display=swap" rel="stylesheet">
9
+ <script type="application/json" id="overlaify-manifest">
10
+ {
11
+ "spec": 1,
12
+ "settings": [
13
+ {"key": "x", "label": "Left edge", "type": "range", "default": 1340, "min": 0, "max": 1920, "step": 10, "unit": "px", "group": "Layout", "desc": "Position on the 1920x1080 canvas. Defaults to the bottom-right corner (1920 - width - 60)."},
14
+ {"key": "y", "label": "Top edge", "type": "range", "default": 670, "min": 0, "max": 1080, "step": 10, "unit": "px", "group": "Layout", "desc": "Defaults to the bottom-right corner (1080 - height - 80)."},
15
+ {"key": "w", "label": "Width", "type": "range", "default": 520, "min": 160, "max": 1920, "step": 10, "unit": "px", "group": "Layout", "desc": "Match your camera source."},
16
+ {"key": "h", "label": "Height", "type": "range", "default": 330, "min": 90, "max": 1080, "step": 10, "unit": "px", "group": "Layout"},
17
+ {"key": "color", "label": "Color 1", "type": "color", "default": "#c6ff3d", "swatches": ["#c6ff3d", "#5ce1ff", "#ff4f8b", "#ffb547", "#ffffff"], "group": "Look", "desc": "First gradient color, also the name tag and alert label."},
18
+ {"key": "color2", "label": "Color 2", "type": "color", "default": "#9b5cff", "swatches": ["#9b5cff", "#ff4f8b", "#5ce1ff", "#ffb547", "#c6ff3d"], "group": "Look", "desc": "Second gradient color."},
19
+ {"key": "radius", "label": "Corner radius", "type": "range", "default": 28, "min": 0, "max": 120, "step": 1, "unit": "px", "group": "Look", "desc": "0 for square corners."},
20
+ {"key": "border", "label": "Border width", "type": "range", "default": 6, "min": 1, "max": 30, "step": 1, "unit": "px", "group": "Look"},
21
+ {"key": "name", "label": "Name tag", "type": "text", "default": "", "group": "Text", "desc": "Leave empty to use your channel name; 0 hides the tag."},
22
+ {"key": "alerts", "label": "Flash on events", "type": "toggle", "default": true, "group": "Alerts", "desc": "Follows, subs, gifts, bits and raids make the border glow and show a label."},
23
+ {"key": "duration", "label": "Label time", "type": "range", "default": 4, "min": 1, "max": 15, "step": 0.5, "unit": "s", "group": "Alerts", "desc": "Seconds each event label stays up."}
24
+ ]
25
+ }
26
+ </script>
27
+ <style>
28
+ :root { --c: #c6ff3d; --c2: #9b5cff; --r: 28px; --bw: 6px; }
29
+ html, body { margin: 0; width: 1920px; height: 1080px; overflow: hidden; background: transparent; }
30
+ .frame { position: absolute; border-radius: var(--r); font-family: 'Space Grotesk', system-ui, sans-serif; color: #fff; }
31
+ /* Animated gradient border: a masked ring, the inside stays fully transparent for the camera. */
32
+ .ring { position: absolute; inset: calc(var(--bw) * -1); border-radius: calc(var(--r) + var(--bw)); padding: var(--bw);
33
+ background: conic-gradient(from var(--a, 0deg), var(--c), var(--c2), var(--c), var(--c2), var(--c));
34
+ -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); -webkit-mask-composite: xor; mask-composite: exclude;
35
+ animation: spin 6s linear infinite; filter: drop-shadow(0 0 14px var(--c)); }
36
+ .flash .ring { animation: spin 1s linear infinite; filter: drop-shadow(0 0 34px var(--c)) brightness(1.4); }
37
+ .corner { position: absolute; width: 42px; height: 42px; border: 5px solid #fff; opacity: .85; }
38
+ .tl { left: 14px; top: 14px; border-right: 0; border-bottom: 0; border-radius: 12px 0 0 0; }
39
+ .br { right: 14px; bottom: 14px; border-left: 0; border-top: 0; border-radius: 0 0 12px 0; }
40
+ .tag { position: absolute; left: 22px; bottom: -26px; display: flex; align-items: center; gap: 10px; padding: 10px 20px; border-radius: 14px;
41
+ background: #0e0b18; border: 2px solid var(--c); font-size: 24px; font-weight: 700; box-shadow: 0 8px 24px rgba(0,0,0,.5); }
42
+ .tag i { width: 12px; height: 12px; border-radius: 50%; background: #ff3b3b; box-shadow: 0 0 10px #ff3b3b; animation: blink 1.4s infinite; }
43
+ .toast { position: absolute; right: 18px; top: -30px; padding: 10px 18px; border-radius: 12px; background: var(--c); color: #0b0a10; font-size: 22px; font-weight: 700;
44
+ white-space: nowrap; box-shadow: 0 8px 24px rgba(0,0,0,.45); opacity: 0; transform: translateY(14px) scale(.9); transition: .35s cubic-bezier(.2,1.4,.4,1); }
45
+ .toast.on { opacity: 1; transform: none; }
46
+ @property --a { syntax: '<angle>'; inherits: false; initial-value: 0deg; }
47
+ @keyframes spin { to { --a: 360deg; } }
48
+ @keyframes blink { 50% { opacity: .3; } }
49
+ </style>
50
+ </head>
51
+ <body>
52
+ <div class="frame" id="frame">
53
+ <div class="ring"></div><div class="corner tl"></div><div class="corner br"></div>
54
+ <div class="toast" id="toast"></div>
55
+ <div class="tag" id="tagbox"><i></i><span id="tag"></span></div>
56
+ </div>
57
+ <script>
58
+ // Position and size in 1920x1080 pixels: ?x=1380&y=700&w=480&h=320 &radius=28 &color=%23c6ff3d &color2=%239b5cff
59
+ // &name=... (defaults to your channel; name=0 hides the tag) &alerts=0 turns off the event flash
60
+ const root = document.documentElement.style;
61
+ root.setProperty('--c', Overlaify.param('color', '#c6ff3d'));
62
+ root.setProperty('--c2', Overlaify.param('color2', '#9b5cff'));
63
+ root.setProperty('--r', Overlaify.param('radius', 28) + 'px');
64
+ root.setProperty('--bw', Overlaify.param('border', 6) + 'px');
65
+ const W = Overlaify.param('w', 520), H = Overlaify.param('h', 330);
66
+ const frame = document.getElementById('frame');
67
+ Object.assign(frame.style, { left: Overlaify.param('x', 1920 - W - 60) + 'px', top: Overlaify.param('y', 1080 - H - 80) + 'px', width: W + 'px', height: H + 'px' });
68
+ const name = Overlaify.param('name', Overlaify.channel || 'LIVE');
69
+ if (name === '0') document.getElementById('tagbox').remove(); else document.getElementById('tag').textContent = name;
70
+
71
+ const toast = document.getElementById('toast');
72
+ const DURATION = Overlaify.param('duration', 4) * 1000;
73
+ function label(e) {
74
+ switch (e.type) {
75
+ case 'follow': return `+ follow · ${e.name}`;
76
+ case 'sub': return e.months > 1 ? `${e.name} · ${e.months} months` : `new sub · ${e.name}`;
77
+ case 'gift': return `${e.name} gifted a sub`;
78
+ case 'giftbomb': return `${e.name} gifted ${e.count}`;
79
+ case 'cheer': return `${e.name} · ${e.bits.toLocaleString()} bits`;
80
+ case 'raid': return `raid · ${e.name} +${e.viewers.toLocaleString()}`;
81
+ }
82
+ }
83
+ const show = Overlaify.queue(async e => {
84
+ toast.textContent = label(e);
85
+ toast.classList.add('on'); frame.classList.add('flash');
86
+ Overlaify.chime('pop');
87
+ await Overlaify.sleep(DURATION);
88
+ toast.classList.remove('on'); frame.classList.remove('flash');
89
+ }, { gap: 400 });
90
+ if (Overlaify.param('alerts', true)) ['follow', 'sub', 'gift', 'giftbomb', 'cheer', 'raid'].forEach(t => Overlaify.on(t, show));
91
+ </script>
92
+ </body>
93
+ </html>
@@ -0,0 +1,114 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync, readdirSync } from 'node:fs';
3
+ import { dirname, join, relative } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { init, validate, installSkill, templates, bold, dim, green, red, yellow } from '../lib/commands.js';
6
+ import { dev } from '../lib/dev.js';
7
+ import { login, logout, whoami, publish, download, siteUrl } from '../lib/publish.js';
8
+
9
+ const PKG = join(dirname(fileURLToPath(import.meta.url)), '..');
10
+ const { version } = JSON.parse(readFileSync(join(PKG, 'package.json'), 'utf8'));
11
+
12
+ const HELP = `${bold('overlaify')} ${version} — build Twitch overlays that follow the Overlaify Overlay Spec
13
+
14
+ Usage
15
+ overlaify init [file] [--template <slug>] [--force] start an overlay (blank starter, or a library template)
16
+ overlaify init --list list the library templates
17
+ overlaify validate <files...> [--json] check overlays against the spec (exit 1 on errors)
18
+ overlaify dev [file] [--port 4545] [--channel name] [--host 127.0.0.1]
19
+ preview with simulated events, settings and action keys;
20
+ gives an OBS URL that reloads when you save
21
+ overlaify login [--site url] [--no-browser] [--token t]
22
+ connect the CLI to your overlaify.com account
23
+ overlaify publish [file] [--public | --private] [--name n] [--title t] [--kind k] [--new]
24
+ publish an overlay to overlaify.com/<you>/<name>;
25
+ publishing the same file again adds a new version
26
+ overlaify download <user>/<name> [file] [--version n] [--force]
27
+ save an overlay from overlaify.com as a local file;
28
+ a private one needs its owner logged in
29
+ overlaify whoami | logout [--site url] show / forget the connected account
30
+ overlaify spec print the Overlay Spec (Markdown)
31
+ overlaify skill [--dir path] install the Overlaify skill for Claude
32
+ (default ~/.claude/skills/overlaify)`;
33
+
34
+ // --name value / --flag; everything else is positional.
35
+ function parse(argv) {
36
+ const args = [], opts = {};
37
+ for (let i = 0; i < argv.length; i++) {
38
+ const a = argv[i];
39
+ if (!a.startsWith('--')) { args.push(a); continue; }
40
+ const [k, v] = a.slice(2).split('=');
41
+ if (v !== undefined) opts[k] = v;
42
+ else if (argv[i + 1] && !argv[i + 1].startsWith('--') && !['force', 'list', 'json', 'help', 'public', 'private', 'new', 'no-browser'].includes(k)) opts[k] = argv[++i];
43
+ else opts[k] = true;
44
+ }
45
+ return { args, opts };
46
+ }
47
+
48
+ const [cmd, ...rest] = process.argv.slice(2);
49
+ const { args, opts } = parse(rest);
50
+
51
+ try {
52
+ if (!cmd || cmd === 'help' || cmd === '--help' || opts.help) {
53
+ console.log(HELP);
54
+ } else if (cmd === '--version' || cmd === 'version') {
55
+ console.log(version);
56
+ } else if (cmd === 'init') {
57
+ if (opts.list) {
58
+ for (const t of templates()) console.log(`${t.slug.padEnd(24)} ${dim(t.kind.padEnd(14))} ${t.title}`);
59
+ } else {
60
+ const file = init(args[0], { template: opts.template, force: opts.force });
61
+ const rel = relative(process.cwd(), file);
62
+ console.log(`${green('Created')} ${rel}\n\nNext:\n overlaify dev ${rel} preview it and get the OBS URL\n overlaify validate ${rel} check it against the spec`);
63
+ }
64
+ } else if (cmd === 'validate') {
65
+ if (!args.length) throw new Error('Name the overlay files to check: overlaify validate overlay.html');
66
+ process.exitCode = validate(args, { json: opts.json }) ? 1 : 0;
67
+ } else if (cmd === 'dev') {
68
+ const file = args[0] || onlyHtmlFile('dev');
69
+ const host = opts.host || '127.0.0.1';
70
+ const port = await dev(file, { port: Number(opts.port) || 4545, host, channel: opts.channel });
71
+ const base = `http://${host === '0.0.0.0' ? 'localhost' : host}:${port}`;
72
+ console.log(`${bold('overlaify dev')} ${relative(process.cwd(), file)}\n\n Dev page ${base}/\n OBS URL ${base}/overlay ${dim('(Browser source, 1920 × 1080)')}\n\nSaving the file reloads the preview and OBS. Ctrl+C to stop.`);
73
+ } else if (cmd === 'login') {
74
+ const { site, user } = await login({ site: opts.site, token: typeof opts.token === 'string' ? opts.token : undefined, browser: !opts['no-browser'] });
75
+ console.log(`${green('Logged in')} to ${site} as ${bold(user.name)}.\nPublish an overlay with: overlaify publish <file>.html`);
76
+ } else if (cmd === 'logout') {
77
+ console.log((await logout({ site: opts.site })) ? `Logged out of ${siteUrl(opts.site)}.` : `Not logged in to ${siteUrl(opts.site)}.`);
78
+ } else if (cmd === 'whoami') {
79
+ const user = await whoami({ site: opts.site });
80
+ if (!user) { console.log(`Not logged in to ${siteUrl(opts.site)}. Run: overlaify login`); process.exitCode = 1; }
81
+ else console.log(`${user.name} ${dim(`(${siteUrl(opts.site)}/${user.handle})`)}`);
82
+ } else if (cmd === 'publish') {
83
+ if (opts.public && opts.private) throw new Error('Pick one: --public or --private.');
84
+ const file = args[0] || onlyHtmlFile('publish');
85
+ const out = await publish(file, {
86
+ site: opts.site, name: opts.name, title: opts.title, kind: opts.kind, fresh: opts.new,
87
+ visibility: opts.public ? 'public' : opts.private ? 'private' : undefined
88
+ });
89
+ for (const w of out.warnings || []) console.log(`${yellow('warning')} ${dim(`[${w.code}]`)} ${w.message}`);
90
+ console.log(`${green('Published')} ${bold(out.title)} ${dim(`(${out.visibility}, version ${out.versions})`)}\n\n Page ${out.page}\n OBS URL ${out.url} ${dim('(Browser source, 1920 × 1080)')}`);
91
+ } else if (cmd === 'download') {
92
+ if (!args[0]) throw new Error('Name the overlay: overlaify download <user>/<name>');
93
+ const out = await download(args[0], args[1], { site: opts.site, version: opts.version, force: opts.force });
94
+ const rel = relative(process.cwd(), out.file);
95
+ console.log(`${green('Downloaded')} ${bold(out.title)} ${dim(`(${out.handle}/${out.name}, version ${out.version} of ${out.versions})`)} to ${rel}\n\nNext:\n overlaify dev ${rel} preview it` +
96
+ (out.owned ? `\n overlaify publish ${rel} publish your changes as a new version` : ''));
97
+ } else if (cmd === 'spec') {
98
+ process.stdout.write(readFileSync(join(PKG, 'assets/overlay-spec.md'), 'utf8'));
99
+ } else if (cmd === 'skill') {
100
+ const dir = installSkill({ dir: opts.dir });
101
+ console.log(`${green('Installed')} the Overlaify skill in ${dir}\nAsk Claude for a stream overlay and it will follow the spec and use this CLI.`);
102
+ } else {
103
+ throw new Error(`Unknown command "${cmd}". Run overlaify --help.`);
104
+ }
105
+ } catch (err) {
106
+ console.error(red('Error: ') + err.message);
107
+ process.exitCode = 1;
108
+ }
109
+
110
+ function onlyHtmlFile(cmd = 'dev') {
111
+ const html = readdirSync(process.cwd()).filter((f) => /\.html?$/i.test(f));
112
+ if (html.length === 1) return html[0];
113
+ throw new Error(html.length ? `Several .html files here; name one: overlaify ${cmd} ${html[0]}` : 'No .html file here. Start one with: overlaify init');
114
+ }
@@ -0,0 +1,67 @@
1
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, cpSync, rmSync } from 'node:fs';
2
+ import { dirname, join, resolve, relative } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { validateOverlay } from './validate.js';
6
+
7
+ const PKG = join(dirname(fileURLToPath(import.meta.url)), '..');
8
+ const TEMPLATES = join(PKG, 'assets/templates');
9
+ export const SDK_TAG = '<script src="https://www.overlaify.com/sdk/v1/overlaify.js"></script>';
10
+
11
+ const color = process.stdout.isTTY && !process.env.NO_COLOR;
12
+ const paint = (code) => (s) => (color ? `\x1b[${code}m${s}\x1b[0m` : s);
13
+ export const red = paint(31), yellow = paint(33), green = paint(32), dim = paint(2), bold = paint(1);
14
+
15
+ /** The library templates bundled with the CLI: [{ slug, title, kind, desc }]. */
16
+ export function templates() {
17
+ return JSON.parse(readFileSync(join(TEMPLATES, 'catalog.json'), 'utf8'))
18
+ .map(({ slug, title, kind, desc }) => ({ slug, title, kind, desc }));
19
+ }
20
+
21
+ /**
22
+ * overlaify init [file] [--template slug] [--force]
23
+ * Writes a starter overlay, or a copy of a library template, with the SDK loaded from overlaify.com
24
+ * so the file also works on its own (OBS can load it as a local file).
25
+ */
26
+ export function init(file, { template, force } = {}) {
27
+ let html;
28
+ if (template) {
29
+ const t = templates().find((x) => x.slug === template);
30
+ if (!t) throw new Error(`Unknown template "${template}". See the list with: overlaify init --list`);
31
+ html = readFileSync(join(TEMPLATES, `${t.slug}.html`), 'utf8')
32
+ .replace(/<script[^>]*src=["'][^"']*overlaify(\.min)?\.js["'][^>]*>\s*<\/script>/i, SDK_TAG);
33
+ } else {
34
+ html = readFileSync(join(PKG, 'lib/starter.html'), 'utf8');
35
+ }
36
+ file = resolve(file || (template ? `${template}.html` : 'overlay.html'));
37
+ if (existsSync(file) && !force) throw new Error(`${relative(process.cwd(), file)} already exists (use --force to overwrite).`);
38
+ mkdirSync(dirname(file), { recursive: true });
39
+ writeFileSync(file, html);
40
+ return file;
41
+ }
42
+
43
+ /** overlaify validate <files...> — prints each file's issues. Returns the number of files with errors. */
44
+ export function validate(files, { json } = {}) {
45
+ const results = files.map((f) => ({ file: f, ...validateOverlay(readFileSync(f, 'utf8')) }));
46
+ if (json) {
47
+ console.log(JSON.stringify(results.map(({ file, ok, errors, warnings }) => ({ file, ok, errors, warnings })), null, 2));
48
+ } else {
49
+ for (const r of results) {
50
+ const tag = !r.ok ? red('FAIL') : r.warnings.length ? yellow('warn') : green(' ok ');
51
+ console.log(`${tag} ${relative(process.cwd(), resolve(r.file))}`);
52
+ for (const i of r.errors) console.log(` ${red('error')} ${dim(`[${i.code}]`)} ${i.message}`);
53
+ for (const i of r.warnings) console.log(` ${yellow('warning')} ${dim(`[${i.code}]`)} ${i.message}`);
54
+ }
55
+ const failed = results.filter((r) => !r.ok).length;
56
+ if (results.length > 1 || failed) console.log(`\n${results.length} checked: ${results.length - failed} pass, ${failed} fail.`);
57
+ }
58
+ return results.filter((r) => !r.ok).length;
59
+ }
60
+
61
+ /** overlaify skill [--dir path] — installs the Overlaify skill for Claude (default ~/.claude/skills/overlaify). */
62
+ export function installSkill({ dir } = {}) {
63
+ const target = resolve(dir || join(homedir(), '.claude/skills/overlaify'));
64
+ rmSync(target, { recursive: true, force: true });
65
+ cpSync(join(PKG, 'skill'), target, { recursive: true });
66
+ return target;
67
+ }