@insitue/claude-plugin 0.0.1
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/.claude-plugin/plugin.json +12 -0
- package/LICENSE +21 -0
- package/README.md +74 -0
- package/commands/connect.md +35 -0
- package/dist/mcp-server.js +222 -0
- package/package.json +38 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "insitue",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Drive a Claude Code session from the InSitue browser overlay. Pick an element in your app, claude reads the file and proposes the edit.",
|
|
5
|
+
"mcpServers": {
|
|
6
|
+
"insitue": {
|
|
7
|
+
"command": "node",
|
|
8
|
+
"args": ["${CLAUDE_PLUGIN_ROOT}/dist/mcp-server.js"],
|
|
9
|
+
"cwd": "${CLAUDE_PROJECT_DIR}"
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
}
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Rod Leviton
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# @insitue/claude-plugin
|
|
2
|
+
|
|
3
|
+
Drive a Claude Code session from the InSitue browser overlay. Pick an
|
|
4
|
+
element in your running app, claude reads the file and proposes the
|
|
5
|
+
edit — no copy/paste of file paths, no typing line numbers.
|
|
6
|
+
|
|
7
|
+
## What you get
|
|
8
|
+
|
|
9
|
+
- A namespaced slash command **`/insitue:connect`** that puts the
|
|
10
|
+
current `claude` session into "watch InSitue" mode.
|
|
11
|
+
- An MCP server (`insitue`) with two tools:
|
|
12
|
+
- `insitue__next_pick` — long-polls until the next pick lands.
|
|
13
|
+
- `insitue__list_recent_picks` — replays recent picks (e.g. things
|
|
14
|
+
you selected before claude attached).
|
|
15
|
+
|
|
16
|
+
The bridge connects to the same loopback companion the browser
|
|
17
|
+
overlay uses (reads `.insitu/session.json` from your project), so
|
|
18
|
+
there's no extra config — start `insitue dev`, start `claude`, run
|
|
19
|
+
`/insitue:connect`, click in the browser.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
claude plugin install @insitue/claude-plugin
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Use
|
|
28
|
+
|
|
29
|
+
Three terminals:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
# Terminal A — your dev server (the app you're editing)
|
|
33
|
+
pnpm dev # or npm run dev, etc.
|
|
34
|
+
|
|
35
|
+
# Terminal B — the InSitue companion (writes .insitu/session.json)
|
|
36
|
+
npx insitue dev
|
|
37
|
+
|
|
38
|
+
# Terminal C — Claude Code
|
|
39
|
+
claude
|
|
40
|
+
> /insitue:connect
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Now click **Select** in the InSitue overlay, pick an element, and
|
|
44
|
+
type your request in the panel's "User note" field. Claude in
|
|
45
|
+
Terminal C reads the file at the exact location, proposes the diff,
|
|
46
|
+
and waits for your "approve" before writing.
|
|
47
|
+
|
|
48
|
+
## Architecture
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
┌───────────────────┐ ┌───────────────────┐
|
|
52
|
+
│ Browser overlay │ ──WS──▶ │ Local companion │
|
|
53
|
+
│ (insitue/sdk) │ │ (insitue dev) │
|
|
54
|
+
└───────────────────┘ └─────────┬─────────┘
|
|
55
|
+
│ WS broadcast-capture
|
|
56
|
+
▼
|
|
57
|
+
┌───────────────────┐
|
|
58
|
+
│ @insitue/ │
|
|
59
|
+
│ claude-plugin │ ◀── stdio MCP ──┐
|
|
60
|
+
│ (MCP bridge) │ │
|
|
61
|
+
└───────────────────┘ │
|
|
62
|
+
┌─────────┴───────┐
|
|
63
|
+
│ claude (CLI) │
|
|
64
|
+
│ /insitue:connect│
|
|
65
|
+
└─────────────────┘
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
The bridge never writes files — it just hands picks to `claude`, and
|
|
69
|
+
`claude` proposes edits through its normal Edit/Write tools (which
|
|
70
|
+
still respect your `claude` permissions).
|
|
71
|
+
|
|
72
|
+
## License
|
|
73
|
+
|
|
74
|
+
MIT — same as the rest of InSitue.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Watch the InSitue browser overlay and act on each pick. Reads picks from the running local InSitue companion, edits the file the user pointed at, asks for confirmation, loops.
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# /insitue:connect
|
|
6
|
+
|
|
7
|
+
Connect this Claude Code session to the running InSitue companion and turn each browser pick into a real code change.
|
|
8
|
+
|
|
9
|
+
## How it works
|
|
10
|
+
|
|
11
|
+
1. The user runs `npx insitue dev` in their project (the companion).
|
|
12
|
+
2. The user clicks **Select** in the InSitue browser overlay, then picks an element.
|
|
13
|
+
3. Claude (you) calls `mcp__insitue__next_pick` — blocks until a pick lands.
|
|
14
|
+
4. The tool returns the resolved `file:line`, component name, the user's note (what they typed in the panel), and surrounding context.
|
|
15
|
+
5. You read the file at the returned path, propose the edit, show the diff, and ask the user **here in chat** to approve before writing.
|
|
16
|
+
6. After applying, you call `next_pick` again and the loop continues.
|
|
17
|
+
|
|
18
|
+
## Your behavior on /insitue:connect
|
|
19
|
+
|
|
20
|
+
- **First**, call `mcp__insitue__list_recent_picks` once to see if the user already picked things before attaching. If there are recent picks the user hasn't acted on, summarise them so they can choose which to address.
|
|
21
|
+
- **Then enter the loop**: call `mcp__insitue__next_pick`. It blocks for ~5 min by default. When it returns:
|
|
22
|
+
- If `status: "ok"`: read the `pick.source.file` at `pick.source.line`, plus a few surrounding lines for context.
|
|
23
|
+
- The `pick.userNote` is the user's instruction. If empty, ask "What would you like to change at `<componentName>` (`<file>:<line>`)?" before doing anything.
|
|
24
|
+
- Propose an edit. Show a small diff in chat. Wait for the user to say "go" / "approve" / "yes" before writing.
|
|
25
|
+
- On approval, apply with Edit/Write. Confirm what changed.
|
|
26
|
+
- Loop back to `next_pick`.
|
|
27
|
+
- **If a pick comes through with `target` starting with `[insitue]`**: the companion disconnected (HMR / restart). Tell the user, then call `next_pick` again — the bridge auto-reconnects.
|
|
28
|
+
- **Exit the loop** when the user says "stop", "done", "quit", or similar.
|
|
29
|
+
|
|
30
|
+
## Guardrails
|
|
31
|
+
|
|
32
|
+
- Don't touch any file outside the pick's `pick.source.file` unless the user explicitly asks for cross-file changes.
|
|
33
|
+
- Don't auto-approve. Every write is gated by an explicit user "yes" in chat.
|
|
34
|
+
- If `pick.source` is `null` (selector-only pick), don't guess — tell the user the source wasn't resolved and ask which file to edit.
|
|
35
|
+
- If `pick.confidence` is `"approximate"` (owner-fiber fallback, not the exact JSX site), warn the user before editing.
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/mcp-server.ts
|
|
4
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
|
+
import { existsSync, readFileSync } from "fs";
|
|
7
|
+
import { dirname, join, resolve } from "path";
|
|
8
|
+
import WebSocket from "ws";
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
var MAX_BUFFERED_PICKS = 32;
|
|
11
|
+
var NEXT_PICK_DEFAULT_TIMEOUT_MS = 5 * 60 * 1e3;
|
|
12
|
+
var NEXT_PICK_MAX_TIMEOUT_MS = 30 * 60 * 1e3;
|
|
13
|
+
function findSession(start = process.cwd()) {
|
|
14
|
+
let dir = resolve(start);
|
|
15
|
+
while (true) {
|
|
16
|
+
const candidate = join(dir, ".insitu", "session.json");
|
|
17
|
+
if (existsSync(candidate)) {
|
|
18
|
+
try {
|
|
19
|
+
const session = JSON.parse(readFileSync(candidate, "utf8"));
|
|
20
|
+
return { dir, session };
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
const parent = dirname(dir);
|
|
26
|
+
if (parent === dir) return null;
|
|
27
|
+
dir = parent;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function summariseBundle(raw) {
|
|
31
|
+
const t = raw.bundle.target ?? null;
|
|
32
|
+
const componentStack = (t?.componentStack ?? []).map((c) => ({
|
|
33
|
+
name: c.name,
|
|
34
|
+
...c.source?.file ? { file: c.source.file } : {},
|
|
35
|
+
...c.source?.line ? { line: c.source.line } : {}
|
|
36
|
+
}));
|
|
37
|
+
const target = componentStack[0]?.name ?? (t?.selector ? t.selector.split(" ").slice(-1)[0] : "?");
|
|
38
|
+
return {
|
|
39
|
+
id: raw.id,
|
|
40
|
+
at: raw.at,
|
|
41
|
+
source: raw.resolved?.file ? {
|
|
42
|
+
file: raw.resolved.file,
|
|
43
|
+
...raw.resolved.line ? { line: raw.resolved.line } : {}
|
|
44
|
+
} : t?.source ? { file: t.source.file, ...t.source.line ? { line: t.source.line } : {} } : null,
|
|
45
|
+
confidence: raw.resolved?.confidence ?? t?.confidence ?? "unknown",
|
|
46
|
+
target,
|
|
47
|
+
selector: t?.selector ?? null,
|
|
48
|
+
userNote: raw.bundle.userNote ?? null,
|
|
49
|
+
url: raw.bundle.runtime?.url ?? null,
|
|
50
|
+
componentStack
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
var PickBuffer = class {
|
|
54
|
+
picks = [];
|
|
55
|
+
waiters = [];
|
|
56
|
+
/** Last pick id handed out via `next_pick` — defends against
|
|
57
|
+
* re-delivering the same pick across reconnects. */
|
|
58
|
+
lastDelivered = null;
|
|
59
|
+
push(p) {
|
|
60
|
+
this.picks.push(p);
|
|
61
|
+
if (this.picks.length > MAX_BUFFERED_PICKS) {
|
|
62
|
+
this.picks.shift();
|
|
63
|
+
}
|
|
64
|
+
while (this.waiters.length && this.picks.length) {
|
|
65
|
+
const w = this.waiters.shift();
|
|
66
|
+
clearTimeout(w.timer);
|
|
67
|
+
const next = this.picks.shift();
|
|
68
|
+
this.lastDelivered = next.id;
|
|
69
|
+
w.resolve(next);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/** Resolve with the next pick to land OR null on timeout. */
|
|
73
|
+
next(timeoutMs) {
|
|
74
|
+
if (this.picks.length) {
|
|
75
|
+
const next = this.picks.shift();
|
|
76
|
+
this.lastDelivered = next.id;
|
|
77
|
+
return Promise.resolve(next);
|
|
78
|
+
}
|
|
79
|
+
return new Promise((resolve2) => {
|
|
80
|
+
const timer = setTimeout(() => {
|
|
81
|
+
const idx = this.waiters.findIndex((w) => w.timer === timer);
|
|
82
|
+
if (idx >= 0) this.waiters.splice(idx, 1);
|
|
83
|
+
resolve2(null);
|
|
84
|
+
}, timeoutMs);
|
|
85
|
+
this.waiters.push({ resolve: resolve2, timer });
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
recent(limit) {
|
|
89
|
+
return this.picks.slice(-limit);
|
|
90
|
+
}
|
|
91
|
+
/** When the WS reconnects, drop pending waiters with a sentinel so
|
|
92
|
+
* claude sees the disruption instead of hanging forever. */
|
|
93
|
+
rejectAll(reason) {
|
|
94
|
+
for (const w of this.waiters) {
|
|
95
|
+
clearTimeout(w.timer);
|
|
96
|
+
w.resolve({
|
|
97
|
+
id: "reconnect",
|
|
98
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
99
|
+
source: null,
|
|
100
|
+
confidence: "n/a",
|
|
101
|
+
target: `[insitu] ${reason}`,
|
|
102
|
+
selector: null,
|
|
103
|
+
userNote: null,
|
|
104
|
+
url: null,
|
|
105
|
+
componentStack: []
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
this.waiters.length = 0;
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
var buffer = new PickBuffer();
|
|
112
|
+
function connectToCompanion(session) {
|
|
113
|
+
const url = `ws://127.0.0.1:${session.port}/insitu/cli`;
|
|
114
|
+
const ws = new WebSocket(url, {
|
|
115
|
+
headers: { "user-agent": "insitue-claude-plugin/0.0.1" }
|
|
116
|
+
});
|
|
117
|
+
ws.on("open", () => {
|
|
118
|
+
ws.send(
|
|
119
|
+
JSON.stringify({
|
|
120
|
+
t: "hello",
|
|
121
|
+
// Pin to the published companion protocol version — bumped
|
|
122
|
+
// when the wire format breaks, NOT for every release.
|
|
123
|
+
protocolVersion: 4,
|
|
124
|
+
token: session.token
|
|
125
|
+
})
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
ws.on("message", (data) => {
|
|
129
|
+
let m;
|
|
130
|
+
try {
|
|
131
|
+
m = JSON.parse(String(data));
|
|
132
|
+
} catch {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (m && typeof m === "object" && m.t === "hello-ok") {
|
|
136
|
+
ws.send(JSON.stringify({ t: "subscribe" }));
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (m && typeof m === "object" && m.t === "broadcast-capture") {
|
|
140
|
+
try {
|
|
141
|
+
buffer.push(summariseBundle(m));
|
|
142
|
+
} catch (err) {
|
|
143
|
+
process.stderr.write(
|
|
144
|
+
`[insitue-mcp] dropped malformed pick: ${err.message}
|
|
145
|
+
`
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
ws.on("close", () => {
|
|
151
|
+
buffer.rejectAll("companion disconnected \u2014 restart `insitue dev`?");
|
|
152
|
+
setTimeout(() => connectToCompanion(session), 2e3);
|
|
153
|
+
});
|
|
154
|
+
ws.on("error", () => {
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
var found = findSession();
|
|
158
|
+
if (!found) {
|
|
159
|
+
process.stderr.write(
|
|
160
|
+
"[insitue-mcp] no `.insitu/session.json` found in cwd or any parent.\n Start the companion first: `npx insitue dev` from your project root.\n"
|
|
161
|
+
);
|
|
162
|
+
process.exit(1);
|
|
163
|
+
}
|
|
164
|
+
connectToCompanion(found.session);
|
|
165
|
+
var server = new McpServer({
|
|
166
|
+
name: "insitue",
|
|
167
|
+
version: "0.0.1"
|
|
168
|
+
});
|
|
169
|
+
server.registerTool(
|
|
170
|
+
"next_pick",
|
|
171
|
+
{
|
|
172
|
+
description: 'Long-polls for the next element the user picks in the InSitue browser overlay. Returns the resolved source location (file + line), component name, optional user note, and surrounding context (URL, selector, component stack). Use this in a loop: call \u2192 wait \u2192 edit the returned file \u2192 call again. Returns a special `target: "[insitue] ..."` envelope on companion disconnect.',
|
|
173
|
+
inputSchema: {
|
|
174
|
+
timeout_ms: z.number().int().positive().max(NEXT_PICK_MAX_TIMEOUT_MS).optional().describe(
|
|
175
|
+
`How long to wait for the next pick (ms). Default ${NEXT_PICK_DEFAULT_TIMEOUT_MS}; max ${NEXT_PICK_MAX_TIMEOUT_MS}.`
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
async ({ timeout_ms }) => {
|
|
180
|
+
const ms = timeout_ms ?? NEXT_PICK_DEFAULT_TIMEOUT_MS;
|
|
181
|
+
const pick = await buffer.next(ms);
|
|
182
|
+
if (!pick) {
|
|
183
|
+
return {
|
|
184
|
+
content: [
|
|
185
|
+
{
|
|
186
|
+
type: "text",
|
|
187
|
+
text: JSON.stringify({ status: "timeout", waited_ms: ms })
|
|
188
|
+
}
|
|
189
|
+
]
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
return {
|
|
193
|
+
content: [
|
|
194
|
+
{
|
|
195
|
+
type: "text",
|
|
196
|
+
text: JSON.stringify({ status: "ok", pick })
|
|
197
|
+
}
|
|
198
|
+
]
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
);
|
|
202
|
+
server.registerTool(
|
|
203
|
+
"list_recent_picks",
|
|
204
|
+
{
|
|
205
|
+
description: "Returns up to N most-recent picks buffered since the MCP server started. Use this once at session start to see what the user already selected before claude attached, or to re-read context without consuming a pick.",
|
|
206
|
+
inputSchema: {
|
|
207
|
+
limit: z.number().int().positive().max(MAX_BUFFERED_PICKS).optional().describe(`Max picks to return (1..${MAX_BUFFERED_PICKS}). Default 10.`)
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
async ({ limit }) => {
|
|
211
|
+
const picks = buffer.recent(limit ?? 10);
|
|
212
|
+
return {
|
|
213
|
+
content: [
|
|
214
|
+
{
|
|
215
|
+
type: "text",
|
|
216
|
+
text: JSON.stringify({ count: picks.length, picks })
|
|
217
|
+
}
|
|
218
|
+
]
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
);
|
|
222
|
+
await server.connect(new StdioServerTransport());
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@insitue/claude-plugin",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Drive a Claude Code session from the InSitue browser overlay — pick an element in your app, claude reads the file and proposes the edit.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"files": [
|
|
8
|
+
".claude-plugin",
|
|
9
|
+
"commands",
|
|
10
|
+
"dist",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"exports": {
|
|
14
|
+
".": "./dist/mcp-server.js"
|
|
15
|
+
},
|
|
16
|
+
"bin": {
|
|
17
|
+
"insitue-mcp": "./dist/mcp-server.js"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
21
|
+
"ws": "^8.18.0",
|
|
22
|
+
"zod": "^3.23.8"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@types/ws": "^8.5.13",
|
|
26
|
+
"tsup": "^8.3.5",
|
|
27
|
+
"typescript": "^5.6.3"
|
|
28
|
+
},
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public"
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"build": "tsup src/mcp-server.ts --format esm --clean --external @modelcontextprotocol/sdk --external ws --external zod",
|
|
34
|
+
"dev": "tsup src/mcp-server.ts --format esm --watch --external @modelcontextprotocol/sdk --external ws --external zod",
|
|
35
|
+
"typecheck": "tsc --noEmit",
|
|
36
|
+
"lint": "tsc --noEmit"
|
|
37
|
+
}
|
|
38
|
+
}
|