linear-grab-bridge 0.8.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 ADDED
@@ -0,0 +1,3 @@
1
+ # linear-grab-bridge
2
+
3
+ Local bridge for [Linear Grab](https://github.com/ahmedbanihanibh/linear-grab): run `npx linear-grab-bridge` in your repo to delegate issues from the browser panel to headless Claude Code sessions, watch their live status in the Local tab, and relay uploads to Linear storage. Binds 127.0.0.1 only.
@@ -0,0 +1,245 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Linear Grab bridge — delegate tasks from the browser panel to LOCAL Claude
4
+ * Code sessions running in this repo.
5
+ *
6
+ * npx linear-grab-bridge [--port 4577] [--dir .] [--claude claude]
7
+ *
8
+ * Each task spawns a headless `claude -p` session (stream-json output) in the
9
+ * repo directory. The browser polls task status over localhost. Zero deps.
10
+ * Binds 127.0.0.1 only — never exposed to the network.
11
+ */
12
+ import { createServer } from 'node:http';
13
+ import { spawn } from 'node:child_process';
14
+ import { randomUUID } from 'node:crypto';
15
+
16
+ const argv = process.argv.slice(2);
17
+ const flag = (name, fallback) => {
18
+ const i = argv.indexOf(name);
19
+ return i >= 0 && argv[i + 1] ? argv[i + 1] : fallback;
20
+ };
21
+ const PORT = Number(flag('--port', '4577'));
22
+ const DIR = flag('--dir', process.cwd());
23
+ const CLAUDE_BIN = flag('--claude', 'claude');
24
+ const VERSION = '0.8.0';
25
+ const MAX_TAIL = 200;
26
+
27
+ /** @type {Map<string, any>} */
28
+ const tasks = new Map();
29
+
30
+ const CORS = {
31
+ 'Access-Control-Allow-Origin': '*',
32
+ 'Access-Control-Allow-Methods': 'GET,POST,OPTIONS',
33
+ 'Access-Control-Allow-Headers': 'content-type,x-upload-url,x-upload-headers',
34
+ };
35
+
36
+ /** Upload proxy targets — Linear's storage only (SSRF guard). */
37
+ const UPLOAD_HOSTS = /(^|\.)uploads\.linear\.app$|(^|\.)storage\.googleapis\.com$/;
38
+
39
+ function json(res, code, obj) {
40
+ res.writeHead(code, { 'Content-Type': 'application/json', ...CORS });
41
+ res.end(JSON.stringify(obj));
42
+ }
43
+
44
+ function readBody(req) {
45
+ return new Promise((resolve, reject) => {
46
+ let data = '';
47
+ req.on('data', (c) => {
48
+ data += c;
49
+ if (data.length > 5_000_000) reject(new Error('body too large'));
50
+ });
51
+ req.on('end', () => {
52
+ try {
53
+ resolve(JSON.parse(data || '{}'));
54
+ } catch (e) {
55
+ reject(e);
56
+ }
57
+ });
58
+ req.on('error', reject);
59
+ });
60
+ }
61
+
62
+ function summary(t) {
63
+ return {
64
+ id: t.id,
65
+ title: t.title,
66
+ status: t.status,
67
+ startedAt: t.startedAt,
68
+ endedAt: t.endedAt ?? null,
69
+ lastText: t.lastText,
70
+ result: t.status === 'done' ? t.result?.slice(0, 2000) ?? null : null,
71
+ };
72
+ }
73
+
74
+ function startTask({ title, prompt }) {
75
+ const id = randomUUID().slice(0, 8);
76
+ const task = {
77
+ id,
78
+ title: String(title ?? 'Task').slice(0, 200),
79
+ status: 'running',
80
+ startedAt: Date.now(),
81
+ endedAt: null,
82
+ lastText: 'Starting Claude Code…',
83
+ tail: [],
84
+ result: null,
85
+ child: null,
86
+ };
87
+ tasks.set(id, task);
88
+
89
+ // Headless Claude Code: prompt over stdin (avoids argv limits), streamed
90
+ // JSON events out. acceptEdits lets it actually work unattended; pass extra
91
+ // flags via --claude-args if you need a different permission posture.
92
+ const child = spawn(
93
+ CLAUDE_BIN,
94
+ ['-p', '--output-format', 'stream-json', '--verbose', '--permission-mode', 'acceptEdits'],
95
+ { cwd: DIR, stdio: ['pipe', 'pipe', 'pipe'] },
96
+ );
97
+ task.child = child;
98
+ child.stdin.write(String(prompt ?? ''));
99
+ child.stdin.end();
100
+
101
+ let buffer = '';
102
+ child.stdout.on('data', (chunk) => {
103
+ buffer += chunk.toString();
104
+ let nl;
105
+ while ((nl = buffer.indexOf('\n')) >= 0) {
106
+ const line = buffer.slice(0, nl).trim();
107
+ buffer = buffer.slice(nl + 1);
108
+ if (!line) continue;
109
+ try {
110
+ const event = JSON.parse(line);
111
+ ingest(task, event);
112
+ } catch {
113
+ pushTail(task, line.slice(0, 500));
114
+ }
115
+ }
116
+ });
117
+ child.stderr.on('data', (chunk) => pushTail(task, `[stderr] ${String(chunk).slice(0, 500)}`));
118
+ child.on('error', (err) => {
119
+ task.status = 'error';
120
+ task.endedAt = Date.now();
121
+ task.lastText = `Failed to launch "${CLAUDE_BIN}" — is Claude Code installed and on PATH? (${err.message})`;
122
+ });
123
+ child.on('exit', (code) => {
124
+ if (task.status === 'running') {
125
+ task.status = code === 0 ? 'done' : 'error';
126
+ if (task.status === 'error') task.lastText = `Exited with code ${code}. ${task.lastText}`;
127
+ }
128
+ task.endedAt = Date.now();
129
+ task.child = null;
130
+ });
131
+ return task;
132
+ }
133
+
134
+ function pushTail(task, text) {
135
+ task.tail.push({ at: Date.now(), text });
136
+ if (task.tail.length > MAX_TAIL) task.tail.shift();
137
+ }
138
+
139
+ function ingest(task, event) {
140
+ // stream-json events: system/init, assistant messages, tool use, final result.
141
+ if (event.type === 'assistant') {
142
+ const parts = event.message?.content ?? [];
143
+ for (const p of parts) {
144
+ if (p.type === 'text' && p.text?.trim()) {
145
+ task.lastText = p.text.trim().slice(0, 300);
146
+ pushTail(task, task.lastText);
147
+ } else if (p.type === 'tool_use') {
148
+ const label = `→ ${p.name}${p.input?.file_path ? ` ${p.input.file_path}` : ''}`;
149
+ task.lastText = label.slice(0, 300);
150
+ pushTail(task, label.slice(0, 500));
151
+ }
152
+ }
153
+ } else if (event.type === 'result') {
154
+ task.result = typeof event.result === 'string' ? event.result : JSON.stringify(event.result);
155
+ task.lastText = (task.result ?? '').slice(0, 300) || task.lastText;
156
+ task.status = event.is_error ? 'error' : 'done';
157
+ task.endedAt = Date.now();
158
+ pushTail(task, `[result] ${(task.result ?? '').slice(0, 1000)}`);
159
+ }
160
+ }
161
+
162
+ createServer(async (req, res) => {
163
+ try {
164
+ if (req.method === 'OPTIONS') {
165
+ res.writeHead(204, CORS);
166
+ return res.end();
167
+ }
168
+ const url = new URL(req.url ?? '/', 'http://localhost');
169
+
170
+ if (req.method === 'GET' && url.pathname === '/health') {
171
+ return json(res, 200, {
172
+ ok: true,
173
+ version: VERSION,
174
+ cwd: DIR,
175
+ active: [...tasks.values()].filter((t) => t.status === 'running').length,
176
+ });
177
+ }
178
+ if (req.method === 'GET' && url.pathname === '/tasks') {
179
+ const list = [...tasks.values()].sort((a, b) => b.startedAt - a.startedAt).map(summary);
180
+ return json(res, 200, { tasks: list });
181
+ }
182
+ const detail = url.pathname.match(/^\/tasks\/([\w-]+)$/);
183
+ if (req.method === 'GET' && detail) {
184
+ const t = tasks.get(detail[1]);
185
+ return t
186
+ ? json(res, 200, { ...summary(t), tail: t.tail.slice(-60) })
187
+ : json(res, 404, { error: 'not found' });
188
+ }
189
+ const stop = url.pathname.match(/^\/tasks\/([\w-]+)\/stop$/);
190
+ if (req.method === 'POST' && stop) {
191
+ const t = tasks.get(stop[1]);
192
+ if (t?.child && t.status === 'running') {
193
+ t.child.kill('SIGTERM');
194
+ t.status = 'stopped';
195
+ t.endedAt = Date.now();
196
+ t.lastText = 'Stopped from the panel.';
197
+ }
198
+ return json(res, 200, { ok: true });
199
+ }
200
+ if (req.method === 'POST' && url.pathname === '/tasks') {
201
+ const body = await readBody(req);
202
+ if (!body.prompt) return json(res, 400, { error: 'prompt required' });
203
+ const task = startTask(body);
204
+ return json(res, 201, summary(task));
205
+ }
206
+ // Upload proxy: browsers can't PUT to Linear's storage (its endpoint has
207
+ // no CORS support) — this local process can. Panel sends the signed URL +
208
+ // headers it got from the fileUpload mutation; we relay the bytes.
209
+ if (req.method === 'POST' && url.pathname === '/put') {
210
+ const target = String(req.headers['x-upload-url'] ?? '');
211
+ let host = '';
212
+ try {
213
+ host = new URL(target).hostname;
214
+ } catch {
215
+ /* invalid */
216
+ }
217
+ if (!UPLOAD_HOSTS.test(host)) return json(res, 400, { error: 'target host not allowed' });
218
+ const extra = JSON.parse(String(req.headers['x-upload-headers'] ?? '{}'));
219
+ const chunks = [];
220
+ for await (const c of req) {
221
+ chunks.push(c);
222
+ if (chunks.reduce((n, b) => n + b.length, 0) > 30_000_000) {
223
+ return json(res, 413, { error: 'file too large' });
224
+ }
225
+ }
226
+ const upstream = await fetch(target, {
227
+ method: 'PUT',
228
+ headers: {
229
+ 'Content-Type': String(req.headers['content-type'] ?? 'application/octet-stream'),
230
+ ...extra,
231
+ },
232
+ body: Buffer.concat(chunks),
233
+ });
234
+ return json(res, upstream.ok ? 200 : 502, { ok: upstream.ok, status: upstream.status });
235
+ }
236
+ json(res, 404, { error: 'not found' });
237
+ } catch (err) {
238
+ json(res, 500, { error: err instanceof Error ? err.message : String(err) });
239
+ }
240
+ }).listen(PORT, '127.0.0.1', () => {
241
+ console.log(`linear-grab bridge v${VERSION}`);
242
+ console.log(` repo: ${DIR}`);
243
+ console.log(` listen: http://127.0.0.1:${PORT} (localhost only)`);
244
+ console.log(` tasks run: ${CLAUDE_BIN} -p --permission-mode acceptEdits`);
245
+ });
package/package.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "linear-grab-bridge",
3
+ "version": "0.8.0",
4
+ "description": "Local bridge for Linear Grab — delegate issues from the browser panel to headless Claude Code sessions running in your repo, with live status and an upload relay.",
5
+ "type": "module",
6
+ "bin": {
7
+ "linear-grab-bridge": "./linear-grab-bridge.mjs"
8
+ },
9
+ "files": [
10
+ "linear-grab-bridge.mjs"
11
+ ],
12
+ "engines": {
13
+ "node": ">=18"
14
+ },
15
+ "keywords": ["linear", "claude-code", "cursor", "agent", "bridge", "linear-grab"],
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/ahmedbanihanibh/linear-grab.git"
19
+ },
20
+ "license": "MIT"
21
+ }