linear-grab 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 +166 -0
- package/bin/linear-grab-bridge.mjs +245 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.global.js +206 -0
- package/dist/linear-grab.js +44426 -0
- package/package.json +48 -0
package/README.md
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# Linear Grab
|
|
2
|
+
|
|
3
|
+
Point at any React element in your running dev app, capture a highlighted screenshot + a looping GIF of the interaction, draft a structured Linear issue with AI, delegate it to the **Cursor cloud agent** (with your repo's skills & memory), and track/steer the fix live — **without leaving your app**. Works in **any browser**: Safari, Firefox, Chrome.
|
|
4
|
+
|
|
5
|
+
Two workflows in one tool:
|
|
6
|
+
|
|
7
|
+
| Workflow | What a pick does |
|
|
8
|
+
|---|---|
|
|
9
|
+
| **Cloud · Linear + Cursor** | Opens the panel → AI-drafted issue → create + delegate → Cursor's cloud agent fixes it and opens a PR with a demo video |
|
|
10
|
+
| **Local · clipboard** (react-grab style) | Auto-copies the element's context (component, `file:line`, stack, your skills/memory paths) — paste into a local Claude Code / Cursor session |
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
### Next.js (script tag via CDN)
|
|
17
|
+
|
|
18
|
+
```jsx
|
|
19
|
+
// app/layout.tsx
|
|
20
|
+
import Script from "next/script";
|
|
21
|
+
|
|
22
|
+
export default function RootLayout({ children }) {
|
|
23
|
+
return (
|
|
24
|
+
<html>
|
|
25
|
+
<body>
|
|
26
|
+
{children}
|
|
27
|
+
{process.env.NODE_ENV === "development" && (
|
|
28
|
+
<Script
|
|
29
|
+
src="https://cdn.jsdelivr.net/gh/ahmedbanihanibh/linear-grab@v0.7.2/dist/index.global.js"
|
|
30
|
+
crossOrigin="anonymous"
|
|
31
|
+
strategy="afterInteractive"
|
|
32
|
+
/>
|
|
33
|
+
)}
|
|
34
|
+
</body>
|
|
35
|
+
</html>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
> Use `afterInteractive` (not `beforeInteractive` — App Router head scripts can race hydration). Pin a tag for stability, or use `@main` to always get the latest push.
|
|
41
|
+
|
|
42
|
+
### Vite / anything with a client entry
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
// main.tsx — client only; the bundle is browser-only
|
|
46
|
+
if (import.meta.env.DEV) {
|
|
47
|
+
import('linear-grab').then(({ init }) => init());
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Chrome extension (optional second distribution)
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
npm run build:ext # → load .output/chrome-mv3 via chrome://extensions (Developer mode → Load unpacked)
|
|
55
|
+
```
|
|
56
|
+
Same panel as a browser side panel. Bonus: native uploads (no CORS limits) and Linear OAuth (PKCE) on top of API keys.
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## First-run setup (panel → Settings)
|
|
61
|
+
|
|
62
|
+
1. **Workflow** — Cloud (Linear + Cursor) or Local (clipboard). Switch anytime; all settings are **per dev origin**, so every project keeps its own configuration.
|
|
63
|
+
2. **Linear** — paste a personal API key (linear.app → Settings → Security & access → API keys). Verifies immediately.
|
|
64
|
+
3. **Workspace**
|
|
65
|
+
- **Default team** — where issues are created.
|
|
66
|
+
- **Cursor agent** — pick Cursor from your workspace's app users. Requires the [Cursor ↔ Linear integration](https://linear.app/integrations/cursor) installed (workspace admin) and usage-based pricing enabled in Cursor.
|
|
67
|
+
- **Default repository** — `owner/name`, embedded as `[repo=…]` so Cursor clones the right repo. Override per issue in the Draft tab.
|
|
68
|
+
- **Cursor cloud model** — optional `[model=…]` override (e.g. `claude-opus-4-8`, `composer`).
|
|
69
|
+
- **Dev server log URL** + **lines** — see *Server logs* below.
|
|
70
|
+
- **Skills & memory paths** — see *Skills & memory* below.
|
|
71
|
+
- **Agent instructions** — standing directives appended to every issue. Empty = the default (self-orient in the repo, computer-use testing, record a demo video, open a PR and babysit the first review round).
|
|
72
|
+
- **Test account** — username/password the agent uses to log into your app while testing. Use a throwaway — it's visible to everyone who can read the issue.
|
|
73
|
+
4. **Asset uploads (recommended in Safari)** — a GitHub fine-grained PAT (Contents read/write) + a **public** assets repo (`owner/linear-grab-assets`). Linear's own storage rejects cross-origin browser uploads in some browsers; with this set, GIFs/screenshots upload to GitHub instead and embed automatically.
|
|
74
|
+
5. **AI providers** — OpenAI key (default: `gpt-5.4-nano` fast / `gpt-5.2` best) and/or Anthropic key (fallback: `claude-haiku-4-5` / `claude-opus-4-8`). Auto-resolution prefers OpenAI; runtime failures retry cross-provider. No keys = manual drafting still works.
|
|
75
|
+
6. **Panel** — mode (Overlay / **Beside page** — DevTools-style squeeze), dock side, and note the panel is drag-floatable and edge-resizable (300–720px).
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
79
|
+
## The flow (Cloud workflow)
|
|
80
|
+
|
|
81
|
+
1. **Pick** — crosshair in the launcher pill (or *Pick element*). The panel minimizes (overlay mode), the overlay highlights elements; click one. The grab publishes **instantly** (component + `file:line` from React fiber debug info — dev builds only), then a **highlighted screenshot** of the element in its container fades in (time-sliced capture — never freezes the page).
|
|
82
|
+
2. **Record** (optional, Capture tab) — captures the tab as a looping **GIF** (≤30s, real animated GIF). The panel minimizes; the pill shows a live clock + Stop. The browser's share dialog is a hard security requirement — Chrome preselects "This Tab" (one click).
|
|
83
|
+
3. **Draft** — type a rough note → **Draft with AI**. Fields stream in live following the template: *Summary → Steps to Reproduce → Expected → Actual → Impact → Analysis/Notes → Suggested Next Steps → Priority*, grounded in the captured source and your server-log tail. Edit anything inline.
|
|
84
|
+
4. **Create issue** — one mutation creates it and sets the Cursor agent as **delegate** (this is what triggers the cloud agent). Attached automatically: element screenshot, GIF, server logs, agent instructions (+ skills/memory pointers, test account), `[repo=…]` `[model=…]` tags. Attachment failures warn — they never block the issue.
|
|
85
|
+
5. **Track & steer** — **Activity** tab polls live (12s list / 8s detail): agent session status, comments (rendered markdown incl. inline images), PR links. The reply composer targets matter:
|
|
86
|
+
- **Steer running agent** — threaded reply into the agent session (default when one exists). ⚠️ This is the important one: a top-level @Cursor mention **spawns a NEW cloud agent**; the threaded reply steers the one already running.
|
|
87
|
+
- **Start NEW agent (@Cursor)** / **Comment only** — explicit alternatives.
|
|
88
|
+
6. **PRs** tab — every PR the agents linked back to your issues: state, jump-to-issue, open in Linear, copy URL.
|
|
89
|
+
|
|
90
|
+
### The launcher pill
|
|
91
|
+
|
|
92
|
+
One dock (react-grab's own toolbar is hidden): **Linear logo** (open panel) · **crosshair** (pick) · **status dot + running-agent count** — click for the **minimap** popover listing every running agent; click one to deep-link into its Activity detail. While recording, the pill becomes the stop control. Draggable; position persists.
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
## Skills & memory (make agents follow YOUR rules)
|
|
97
|
+
|
|
98
|
+
Cloud agents clone your repo — so committed skills/memory just need **authoritative pointers**, same as Claude Code via CLAUDE.md:
|
|
99
|
+
|
|
100
|
+
- **By default**, every issue tells the agent to self-orient: read `CLAUDE.md` + `AGENTS.md`, skim `.claude/memory/MEMORY.md`, load matching `.claude/skills/<name>/SKILL.md` (following symlinks into `.agents/skills/`) — skip-if-missing, safe in any repo.
|
|
101
|
+
- **Skills & memory paths** (Settings) adds your *load-bearing* files as MANDATORY reading per issue, e.g.:
|
|
102
|
+
```
|
|
103
|
+
.claude/skills/pb-design-system/SKILL.md
|
|
104
|
+
.claude/skills/apple-hig-design-principles/SKILL.md
|
|
105
|
+
.claude/memory/MEMORY.md
|
|
106
|
+
convex/_generated/ai/guidelines.md
|
|
107
|
+
```
|
|
108
|
+
- The same pointers travel in the **Local** workflow's copied context and Capture's **Copy context** button — so local Claude Code sessions get them too.
|
|
109
|
+
- Rule: **commit what agents must see.** Machine-local paths (`~/.claude/projects/...`) don't exist in a fresh clone.
|
|
110
|
+
|
|
111
|
+
## Server logs
|
|
112
|
+
|
|
113
|
+
Serve your dev log over HTTP and Linear Grab tails it into every issue (and feeds a 40-line tail to AI drafts for root-cause analysis):
|
|
114
|
+
|
|
115
|
+
```jsonc
|
|
116
|
+
// package.json
|
|
117
|
+
"dev:http": "next dev 2>&1 | tee public/dev-server.log"
|
|
118
|
+
```
|
|
119
|
+
```gitignore
|
|
120
|
+
public/dev-server.log
|
|
121
|
+
```
|
|
122
|
+
Settings → Log URL `/dev-server.log`, lines 10–500 (default 100). Per-create toggle next to the delegate checkbox.
|
|
123
|
+
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
## Recording actions (Capture tab)
|
|
127
|
+
|
|
128
|
+
Icon buttons: **🔗 Copy markdown** (uploads, copies ``) · **⧉ Copy GIF** (copies the binary — paste into a Linear comment and *Linear* uploads it; the zero-setup path when direct upload is blocked) · **↓ Download** · **🗑 Discard**. Copy markdown auto-falls back to Copy GIF when the upload is blocked and no GitHub fallback is configured.
|
|
129
|
+
|
|
130
|
+
## Panel UX
|
|
131
|
+
|
|
132
|
+
- **Modes**: Overlay (floats; drag by header, double-click header to re-dock) · **Beside page** (pinned — squeezes the app next to the panel via an animated `<html>` margin; toggle via the dock icon in the header). Caveat: your app's `position: fixed` elements track the viewport and may extend under a pinned panel.
|
|
133
|
+
- **Resize**: drag the panel's inner edge (300–720px, persisted; pinned mode re-squeezes live).
|
|
134
|
+
- **Minimize** (–) collapses to the pill. New grabs reopen the panel (cloud mode).
|
|
135
|
+
- **Theme**: follows the host app (sampled background) — dark and light token sets.
|
|
136
|
+
- **Instant views**: issues/details/sessions hydrate from IndexedDB while fresh data loads.
|
|
137
|
+
|
|
138
|
+
---
|
|
139
|
+
|
|
140
|
+
## Troubleshooting
|
|
141
|
+
|
|
142
|
+
| Symptom | Cause / fix |
|
|
143
|
+
|---|---|
|
|
144
|
+
| No `file:line` on pick | Production build — React strips fiber debug info. Run the dev server. |
|
|
145
|
+
| "Upload to Linear storage failed" | Linear's storage rejects cross-origin browser uploads (Safari especially). Configure the **GitHub assets fallback**, use **Copy GIF → paste**, or use the extension. |
|
|
146
|
+
| Share dialog on every recording | Browser security requirement — cannot be bypassed. Chrome: one click ("This Tab" preselected). |
|
|
147
|
+
| Second Cursor agent appeared | You posted a top-level @Cursor mention. Use the composer's **Steer running agent** target (threaded reply). |
|
|
148
|
+
| Settings select shows placeholder | Fixed in v0.2.0+ — update your pinned version. |
|
|
149
|
+
| Page freezes on pick | Fixed in v0.4.2+ (time-sliced capture + worker encoding) — update your pin. |
|
|
150
|
+
|
|
151
|
+
## Security
|
|
152
|
+
|
|
153
|
+
Dev-tool only — **never load it in deployed environments** (the `NODE_ENV === "development"` gate matters). All keys (Linear, OpenAI/Anthropic, GitHub PAT) live in `localStorage` of your dev origin. Scope the GitHub PAT to the assets repo only. Test-account credentials appear in issue bodies — throwaway accounts only.
|
|
154
|
+
|
|
155
|
+
## Development
|
|
156
|
+
|
|
157
|
+
```bash
|
|
158
|
+
npm install
|
|
159
|
+
npm run dev # Chrome extension dev mode (WXT, HMR)
|
|
160
|
+
npm run build:lib # npm package → dist/ (ESM + auto-init IIFE + types)
|
|
161
|
+
npm run build:ext # Chrome extension → .output/chrome-mv3
|
|
162
|
+
npm run build # both
|
|
163
|
+
npm run compile # typecheck
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Stack: WXT · SolidJS · Tailwind v4 · TanStack Query/Virtual · AI SDK core · react-grab (picker engine) · gifenc (worker-side GIF encode) · html-to-image + custom time-sliced DOM capturer.
|
|
@@ -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/dist/index.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface InitOptions {
|
|
2
|
+
/** Open the panel immediately instead of waiting for the launcher click. */
|
|
3
|
+
defaultOpen?: boolean;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Mount Linear Grab into the current page (dev builds only).
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* if (import.meta.env.DEV) {
|
|
11
|
+
* import('linear-grab').then(({ init }) => init());
|
|
12
|
+
* }
|
|
13
|
+
*/
|
|
14
|
+
export declare function init(options?: InitOptions): void;
|