storyboard-bridge 0.2.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 (3) hide show
  1. package/README.md +82 -0
  2. package/index.mjs +194 -0
  3. package/package.json +18 -0
package/README.md ADDED
@@ -0,0 +1,82 @@
1
+ # Storyboard AI — Desktop Bridge
2
+
3
+ This tiny program lets a **hosted** Storyboard AI webapp run on **your own** Claude Code / Gemini CLI
4
+ login. It runs on your computer, connects out to the app, and answers the app's AI requests by running
5
+ your local CLI under your own subscription. **No API key.** Nothing listens for inbound connections —
6
+ the bridge dials out, so it works behind home routers and firewalls with no setup.
7
+
8
+ ## Prerequisites (one-time)
9
+
10
+ 1. **Node.js 18+**
11
+ 2. A logged-in AI CLI — at least one of:
12
+ - **Claude Code** — install, then `claude` once and sign in.
13
+ - **Gemini CLI** — install, then `gemini` once and sign in.
14
+
15
+ ## Run
16
+
17
+ ```bash
18
+ # easiest — always runs the latest published version
19
+ npx storyboard-bridge --server https://your-app.example.com
20
+
21
+ # or install once and run
22
+ npm i -g storyboard-bridge
23
+ storyboard-bridge --server https://your-app.example.com
24
+ ```
25
+
26
+ Leave it running while you use the webapp. (It auto-reconnects if the connection drops.)
27
+
28
+ ### Options
29
+
30
+ | Flag | Env | Default | Meaning |
31
+ |------|-----|---------|---------|
32
+ | `--server <url>` | `STORYBOARD_SERVER` | `http://localhost:8787` | The app's URL (https→wss is derived automatically). |
33
+ | `--token <tok>` | `STORYBOARD_TOKEN` | _(none)_ | Pairs the bridge to **one** browser. Copy it from the app's **Connect desktop** panel. Omit to run a **shared** bridge that powers every session (handy when self-hosting just for yourself). |
34
+ | `--provider <name>` | `LLM_PROVIDER` | `claude` | `claude` or `gemini` — which CLI to serve jobs with. |
35
+ | `--model <name>` | `LLM_MODEL` | _CLI default_ | Optional model override passed to the CLI. |
36
+ | `--files <dir>` | `STORYBOARD_FILES` | _(none)_ | A local folder to **store & serve your assets** (e.g. a OneDrive folder). With this set, your uploads + generated images/videos/audio live on **your** machine — the hosted app keeps only keys. Omit it and the app stores assets server-side. |
37
+ | — | `CLAUDE_BIN` / `GEMINI_BIN` | on PATH | Path to the CLI binary if it isn't on your PATH. |
38
+
39
+ ### Local asset storage (keep your media on your own machine)
40
+
41
+ Point `--files` at a folder — ideally a synced one like OneDrive — and the bridge becomes the file
42
+ store for your assets. The hosted backend never holds the bytes: it asks your bridge to read/write
43
+ them over the same connection. Use **your OS's native path**:
44
+
45
+ ```bash
46
+ # Windows (PowerShell/cmd)
47
+ npx storyboard-bridge --server https://your-app.example.com --token <tok> ^
48
+ --files "C:\Users\You\OneDrive\Storybuilder-ai"
49
+
50
+ # WSL
51
+ npx storyboard-bridge --server https://your-app.example.com --token <tok> \
52
+ --files "/mnt/c/Users/You/OneDrive/Storybuilder-ai"
53
+
54
+ # macOS
55
+ npx storyboard-bridge --server https://your-app.example.com --token <tok> \
56
+ --files "/Users/you/OneDrive/Storybuilder-ai"
57
+ ```
58
+
59
+ Caveat: your assets are only viewable/generatable **while this bridge is running** — if your machine
60
+ is off, the app can't reach your files. (The same is already true for the AI: jobs run on your CLI.)
61
+
62
+ The easiest path: open the app, click **Connect desktop**, and copy the ready-made command (it already
63
+ includes the right `--server` and `--token`).
64
+
65
+ ## Updating
66
+
67
+ `npx storyboard-bridge` always fetches the latest version. If you installed globally, run
68
+ `npm i -g storyboard-bridge` again. When you connect, the server tells the bridge the latest version and
69
+ it prints a notice if yours is older — so new features land by simply updating this one small package.
70
+
71
+ ## How it works
72
+
73
+ ```
74
+ your desktop (this bridge + your logged-in CLI) hosted Storyboard AI
75
+ │ │
76
+ │── dials out, "hello" (version + which CLIs) ─────────▶│
77
+ │◀──────────── job: { id, prompt } ─────────────────────│ (you click generate)
78
+ run `claude -p` / `gemini -p` locally
79
+ │── result: { id, text } ──────────────────────────────▶│── shows up in the app
80
+ ```
81
+
82
+ Your CLI login never leaves your machine; only prompts in and text out cross the wire.
package/index.mjs ADDED
@@ -0,0 +1,194 @@
1
+ #!/usr/bin/env node
2
+ // ===== Storyboard AI — desktop bridge =====
3
+ // Runs on YOUR machine, where your Claude Code / Gemini CLI is already logged in. It dials OUT to a
4
+ // hosted Storyboard AI backend over a websocket and answers the backend's LLM jobs by running the CLI
5
+ // locally, under your own subscription. No API key. Nothing inbound — the connection is opened from
6
+ // here, so it works behind home routers/firewalls with no port-forwarding.
7
+ //
8
+ // Usage: npx storyboard-bridge --server https://your-app.example.com
9
+ // (or) STORYBOARD_SERVER=https://your-app.example.com node index.mjs
10
+ //
11
+ // Flags / env:
12
+ // --server <url> STORYBOARD_SERVER backend base URL (http/https; ws/wss derived). default http://localhost:8787
13
+ // --token <tok> STORYBOARD_TOKEN pairing token shown in the webapp ("Connect desktop"). Omit to
14
+ // run as a SHARED bridge that powers every session (self-host).
15
+ // --provider <name> LLM_PROVIDER claude | gemini. default claude
16
+ // --model <name> LLM_MODEL optional model override passed to the CLI ('' = CLI default)
17
+ // --files <dir> STORYBOARD_FILES local folder to store/serve this user's assets (e.g. a
18
+ // OneDrive folder). Enables local storage for a hosted backend.
19
+ // Native path for YOUR OS — Windows: C:\Users\you\OneDrive\…
20
+ // · WSL: /mnt/c/Users/you/OneDrive/… · macOS: /Users/you/OneDrive/…
21
+ // -- CLAUDE_BIN/GEMINI_BIN path to the CLI binary if not on PATH
22
+ import { spawn, execFile } from 'node:child_process';
23
+ import { writeFile, readFile, unlink, stat, mkdir } from 'node:fs/promises';
24
+ import { join, resolve, dirname, sep } from 'node:path';
25
+ import { WebSocket } from 'ws';
26
+
27
+ const VERSION = '0.2.0';
28
+
29
+ // ---- config ----
30
+ const arg = (name, fallback) => {
31
+ const i = process.argv.indexOf(`--${name}`);
32
+ return i !== -1 && process.argv[i + 1] ? process.argv[i + 1] : fallback;
33
+ };
34
+ const SERVER = arg('server', process.env.STORYBOARD_SERVER || 'http://localhost:8787');
35
+ const TOKEN = arg('token', process.env.STORYBOARD_TOKEN || ''); // '' = shared bridge (serves all sessions)
36
+ const PROVIDER = arg('provider', process.env.LLM_PROVIDER || 'claude').toLowerCase();
37
+ const MODEL = arg('model', process.env.LLM_MODEL || '');
38
+ const BIN_FOR = { claude: process.env.CLAUDE_BIN || 'claude', gemini: process.env.GEMINI_BIN || 'gemini' };
39
+ const BIN = BIN_FOR[PROVIDER] || BIN_FOR.claude;
40
+ const FILES_ROOT = arg('files', process.env.STORYBOARD_FILES || ''); // '' = this bridge does no storage
41
+ const IS_WIN = process.platform === 'win32';
42
+ // claude supports `--output-format json` (wraps as {result}); gemini's CLI (<=0.1.x) does NOT — it
43
+ // prints raw text, and passing the flag makes it dump --help. Newer gemini supports it (wraps as
44
+ // {response}) — opt in with GEMINI_JSON=1. extractText() handles wrapped OR raw either way.
45
+ const USE_JSON = PROVIDER === 'claude' || process.env.GEMINI_JSON === '1';
46
+ const JOB_TIMEOUT_MS = 300_000;
47
+
48
+ const log = (m) => console.log(`[bridge ${new Date().toISOString().slice(11, 19)}] ${m}`);
49
+
50
+ function wsUrl(httpUrl) {
51
+ const u = new URL(httpUrl);
52
+ u.protocol = u.protocol === 'https:' ? 'wss:' : 'ws:';
53
+ if (!u.pathname || u.pathname === '/') u.pathname = '/bridge';
54
+ if (TOKEN) u.searchParams.set('token', TOKEN); // pair to a specific webapp session
55
+ return u.toString();
56
+ }
57
+
58
+ // ---- the same CLI contract the backend uses: `<bin> -p <prompt> --output-format json [--model m]` ----
59
+ function runCli(bin, args) {
60
+ return new Promise((resolve, reject) => {
61
+ // On Windows the CLI is usually a `.cmd` shim (claude.cmd) that spawn() can't launch without a
62
+ // shell; shell:true uses PATHEXT to resolve it. POSIX (Linux/WSL/macOS) spawns the binary directly.
63
+ const child = spawn(bin, args, { timeout: JOB_TIMEOUT_MS, shell: IS_WIN });
64
+ let out = '';
65
+ let err = '';
66
+ child.stdout.on('data', (d) => (out += d));
67
+ child.stderr.on('data', (d) => (err += d));
68
+ child.on('error', reject);
69
+ child.on('close', (code) => (code === 0 ? resolve(out) : reject(new Error(`${bin} exited ${code}: ${err.slice(0, 300)}`))));
70
+ child.stdin.end();
71
+ });
72
+ }
73
+ // claude wraps the answer as { result }, gemini as { response }; read either, else raw stdout.
74
+ function extractText(stdout) {
75
+ try {
76
+ const o = JSON.parse(stdout);
77
+ if (o && typeof o.result === 'string') return o.result;
78
+ if (o && typeof o.response === 'string') return o.response;
79
+ } catch {
80
+ /* not the JSON wrapper — raw text */
81
+ }
82
+ return stdout;
83
+ }
84
+ async function runJob(prompt) {
85
+ const args = ['-p', prompt];
86
+ if (USE_JSON) args.push('--output-format', 'json'); // claude wraps as JSON; gemini prints raw text
87
+ if (MODEL) args.push('--model', MODEL);
88
+ return extractText(await runCli(BIN, args));
89
+ }
90
+
91
+ // is a CLI installed / runnable? (best-effort; a logged-out CLI still answers --version)
92
+ const cliAvailable = (bin) =>
93
+ new Promise((res) => execFile(bin, ['--version'], { timeout: 15000, shell: IS_WIN }, (e) => res(!e)));
94
+
95
+ // ---- local asset store (file ops from a hosted backend) ----
96
+ // Map a storage key (always '/'-separated, e.g. world__id/image/x.png) to an absolute path UNDER
97
+ // FILES_ROOT, refusing anything that escapes it. path.resolve handles both '/' and '\' on every OS.
98
+ function safePath(key) {
99
+ if (!FILES_ROOT) throw new Error('this bridge has no --files folder configured (pass --files <dir>)');
100
+ const root = resolve(FILES_ROOT);
101
+ const p = resolve(root, String(key));
102
+ if (p !== root && !p.startsWith(root + sep)) throw new Error('path escapes the files root');
103
+ return p;
104
+ }
105
+ async function handleFile(msg) {
106
+ const p = safePath(msg.key);
107
+ if (msg.op === 'write') {
108
+ await mkdir(dirname(p), { recursive: true });
109
+ await writeFile(p, Buffer.from(String(msg.data ?? ''), 'base64'));
110
+ return { ok: true };
111
+ }
112
+ if (msg.op === 'read') {
113
+ try { const buf = await readFile(p); return { ok: true, found: true, size: buf.length, data: buf.toString('base64') }; }
114
+ catch { return { ok: true, found: false }; }
115
+ }
116
+ if (msg.op === 'delete') {
117
+ try { await unlink(p); return { ok: true, found: true }; }
118
+ catch { return { ok: true, found: false }; }
119
+ }
120
+ if (msg.op === 'stat') {
121
+ try { const s = await stat(p); return { ok: true, found: true, size: s.size }; }
122
+ catch { return { ok: true, found: false }; }
123
+ }
124
+ throw new Error(`unknown file op: ${msg.op}`);
125
+ }
126
+
127
+ // ---- connection (reconnects forever with backoff) ----
128
+ let backoff = 1000;
129
+ function connect() {
130
+ const url = wsUrl(SERVER);
131
+ const ws = new WebSocket(url);
132
+
133
+ ws.on('open', async () => {
134
+ backoff = 1000;
135
+ const providers = {
136
+ claude: await cliAvailable(BIN_FOR.claude),
137
+ gemini: await cliAvailable(BIN_FOR.gemini),
138
+ };
139
+ ws.send(JSON.stringify({ type: 'hello', version: VERSION, provider: PROVIDER, providers, files: !!FILES_ROOT }));
140
+ log(`connected to ${url} — serving jobs with: ${PROVIDER} (${BIN})${FILES_ROOT ? `; files → ${FILES_ROOT}` : ''}`);
141
+ if (!providers[PROVIDER]) {
142
+ log(`WARNING: '${BIN}' was not found / not runnable. Install it and log in, or pass --provider/--*_BIN. Jobs will fail until then.`);
143
+ }
144
+ });
145
+
146
+ ws.on('message', async (data) => {
147
+ let msg;
148
+ try {
149
+ msg = JSON.parse(data.toString());
150
+ } catch {
151
+ return;
152
+ }
153
+ if (msg.type === 'welcome') {
154
+ if (msg.latest && msg.latest !== VERSION) {
155
+ log(`UPDATE AVAILABLE: bridge v${msg.latest} (you have v${VERSION}). Update with: npm i -g storyboard-bridge (or just re-run via npx).`);
156
+ }
157
+ } else if (msg.type === 'job' && msg.id) {
158
+ const t0 = Date.now();
159
+ try {
160
+ const text = await runJob(String(msg.prompt ?? ''));
161
+ ws.send(JSON.stringify({ type: 'result', id: msg.id, text }));
162
+ log(`job ${msg.id} ✓ (${Date.now() - t0}ms)`);
163
+ } catch (e) {
164
+ ws.send(JSON.stringify({ type: 'error', id: msg.id, message: String(e?.message ?? e) }));
165
+ log(`job ${msg.id} ✗ ${e?.message ?? e}`);
166
+ }
167
+ } else if (msg.type === 'file' && msg.id) {
168
+ try {
169
+ const r = await handleFile(msg);
170
+ ws.send(JSON.stringify({ type: 'fileResult', id: msg.id, ...r }));
171
+ } catch (e) {
172
+ ws.send(JSON.stringify({ type: 'fileError', id: msg.id, message: String(e?.message ?? e) }));
173
+ log(`file ${msg.op} ${msg.id} ✗ ${e?.message ?? e}`);
174
+ }
175
+ }
176
+ });
177
+
178
+ ws.on('close', () => {
179
+ log(`disconnected — retrying in ${Math.round(backoff / 1000)}s`);
180
+ setTimeout(connect, backoff);
181
+ backoff = Math.min(backoff * 2, 30000);
182
+ });
183
+ ws.on('error', (e) => {
184
+ log(`socket error: ${e.message}`);
185
+ try {
186
+ ws.close();
187
+ } catch {
188
+ /* ignore */
189
+ }
190
+ });
191
+ }
192
+
193
+ log(`Storyboard bridge v${VERSION} → ${SERVER} (provider: ${PROVIDER}; ${TOKEN ? `paired to token ${TOKEN.slice(0, 6)}…` : 'SHARED — serves all sessions'}; storage: ${FILES_ROOT ? FILES_ROOT : 'off (no --files)'})`);
194
+ connect();
package/package.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "storyboard-bridge",
3
+ "version": "0.2.0",
4
+ "description": "Desktop bridge that powers a hosted Storyboard AI webapp with your own local Claude Code / Gemini CLI login (no API key).",
5
+ "type": "module",
6
+ "bin": {
7
+ "storyboard-bridge": "index.mjs"
8
+ },
9
+ "scripts": {
10
+ "start": "node index.mjs"
11
+ },
12
+ "dependencies": {
13
+ "ws": "^8.18.0"
14
+ },
15
+ "engines": {
16
+ "node": ">=18"
17
+ }
18
+ }