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.
- package/README.md +82 -0
- package/index.mjs +194 -0
- 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
|
+
}
|