@insitue/claude-plugin 0.3.3 → 0.4.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "insitue",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
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
5
  "mcpServers": {
6
6
  "insitue": {
package/README.md CHANGED
@@ -1,10 +1,10 @@
1
- # InSitue Dev — Claude Code plugin
1
+ # InSitue Dev — Claude plugin (Code + Desktop)
2
2
 
3
- Drive a `claude` session from your running app. Pick an element
4
- in the browser, describe what you want changed, hit Send.
5
- `claude` reads the file at exactly the right line and proposes
6
- the edit. No copy-pasting file paths. No fumbling for line
7
- numbers. The picker IS the prompt.
3
+ Drive a Claude session **Code or Desktop** from your running
4
+ app. Pick an element in the browser, describe what you want
5
+ changed, hit Send. claude reads the file at exactly the right
6
+ line and proposes the edit. No copy-pasting file paths. No
7
+ fumbling for line numbers. The picker IS the prompt.
8
8
 
9
9
  ```
10
10
  ┌────────────────────────────────┐ ┌────────────────────┐
@@ -26,7 +26,14 @@ numbers. The picker IS the prompt.
26
26
 
27
27
  ## Setup (60 seconds, one-time)
28
28
 
29
- ### 1. Install the plugin
29
+ Pick your runtime:
30
+
31
+ - **Claude Code** (the CLI / terminal app) → §1A below.
32
+ - **Claude Desktop** (the macOS / Windows app) → §1B below.
33
+
34
+ If you use both, do both — same MCP, same widget, same picks.
35
+
36
+ ### 1A. Claude Code — install via the marketplace
30
37
 
31
38
  In any `claude` session:
32
39
 
@@ -37,7 +44,57 @@ In any `claude` session:
37
44
 
38
45
  That's it for the plugin side. The MCP server it ships will
39
46
  auto-start the InSitue companion process in the background of
40
- your `claude` session — no separate terminal to babysit.
47
+ your `claude` session — no separate terminal to babysit. The
48
+ slash command `/insitue:connect` enters the loop.
49
+
50
+ ### 1B. Claude Desktop — one-command setup
51
+
52
+ Claude Desktop doesn't have a plugin marketplace, but it does
53
+ load MCP servers from `claude_desktop_config.json`. The package
54
+ ships an `insitue` CLI that writes the right entry for you:
55
+
56
+ ```bash
57
+ # from your project directory
58
+ npx -y @insitue/claude-plugin setup --desktop
59
+ ```
60
+
61
+ What it does (all idempotent + backed up):
62
+
63
+ 1. Detects your OS and finds the Desktop config file
64
+ (`~/Library/Application Support/Claude/claude_desktop_config.json`
65
+ on macOS, `%APPDATA%\Claude\…` on Windows,
66
+ `$XDG_CONFIG_HOME/Claude/…` on Linux).
67
+ 2. Backs up the existing file with an `.insitue-backup-<timestamp>`
68
+ suffix.
69
+ 3. Adds (or updates) a `mcpServers["insitue-<projectname>"]`
70
+ entry pointing at `npx -y @insitue/claude-plugin@latest`
71
+ with `INSITUE_PROJECT_DIR` set to your project.
72
+
73
+ Restart Claude Desktop, open a new chat, and type:
74
+
75
+ > Use the InSitue MCP — call `start_session`.
76
+
77
+ claude fetches the operating instructions, attaches to the
78
+ companion, and enters the loop. The slash command on Code and
79
+ `start_session` on Desktop deliver the exact same content.
80
+
81
+ **Multi-project?** Run `setup --desktop --project=/path/to/other`
82
+ in each project root. Each gets its own MCP entry
83
+ (`insitue-<dirname>`), so switching between projects in Desktop
84
+ is just picking the right server-prefix in chat.
85
+
86
+ **Want to see what would change first?** Append `--dry-run`. The
87
+ CLI prints the JSON entry without touching the file.
88
+
89
+ **Diagnose a setup that's misbehaving:**
90
+
91
+ ```bash
92
+ npx -y @insitue/claude-plugin diagnose
93
+ ```
94
+
95
+ Reports project dir, session file freshness, companion
96
+ reachability, SDK + SWC-plugin versions + wiring, and concrete
97
+ recommendations.
41
98
 
42
99
  ### 2. Mount the widget in your app
43
100
 
@@ -1,18 +1,30 @@
1
1
  ---
2
- description: Drive this Claude Code session from the InSitue browser overlay — pick + describe in the browser, claude acts in the terminal.
2
+ description: Drive this Claude session (Code or Desktop) from the InSitue browser overlay — pick + describe in the browser, claude acts here.
3
3
  ---
4
4
 
5
- # /insitue:connect
5
+ # InSitue session
6
6
 
7
- Connects this session to the local InSitue companion. The user
8
- picks an element in their app, types a description in the
9
- InSitue panel, clicks Send and you receive the pick (file,
10
- line, component, screenshot) plus the description here, ready to
11
- act on.
7
+ This is the operating manual the InSitue MCP loads at session
8
+ start. On Claude Code it lands as the `/insitue:connect` slash
9
+ command; on Claude Desktop the user has claude call the
10
+ `start_session` tool to fetch the same content. Either way, the
11
+ instructions below are how you behave for the rest of the chat.
12
+
13
+ The user picks an element in their running app, types a
14
+ description in the InSitue panel, clicks Send — and you receive
15
+ the pick (file, line, component, screenshot) plus the
16
+ description here, ready to act on.
12
17
 
13
18
  The companion auto-starts when this MCP server boots. You do
14
19
  not need to ask the user to run any extra commands.
15
20
 
21
+ **Runtime note.** Where this manual says "use the Edit tool",
22
+ that means:
23
+ - on **Claude Code** → the built-in Edit/Write/Read tools
24
+ - on **Claude Desktop** → the `apply_edit` / `write_file` /
25
+ `read_file` tools exposed by this same MCP server
26
+ Either path is fine; pick whichever your runtime has.
27
+
16
28
  ## Your behaviour
17
29
 
18
30
  1. Call `mcp__insitue__list_recent_picks` once. If there are
@@ -53,8 +65,8 @@ not need to ask the user to run any extra commands.
53
65
  - Propose the edit with a clear diff in this chat. Wait for
54
66
  the user to say "yes" / "approve" / "go" before writing.
55
67
  Don't auto-apply.
56
- - On approval, write with the Edit tool. Confirm what
57
- changed.
68
+ - On approval, write with the Edit tool (Code) or
69
+ `mcp__insitue__apply_edit` (Desktop). Confirm what changed.
58
70
  - Loop back to `next_pick`.
59
71
  3. If `next_pick` returns `status: "timeout"`, the user simply
60
72
  hasn't picked anything yet. Stay quiet and call `next_pick`
@@ -0,0 +1,194 @@
1
+ // src/diagnose.ts
2
+ import { existsSync, readFileSync } from "fs";
3
+ import { request as httpRequest } from "http";
4
+ import { join } from "path";
5
+ function readPkgVersion(projectDir, pkgName) {
6
+ const pkgJson = join(projectDir, "node_modules", pkgName, "package.json");
7
+ if (!existsSync(pkgJson)) return null;
8
+ try {
9
+ return JSON.parse(readFileSync(pkgJson, "utf8")).version ?? null;
10
+ } catch {
11
+ return null;
12
+ }
13
+ }
14
+ function readSession(projectDir) {
15
+ const file = join(projectDir, ".insitue", "session.json");
16
+ if (!existsSync(file)) return null;
17
+ try {
18
+ return JSON.parse(readFileSync(file, "utf8"));
19
+ } catch {
20
+ return null;
21
+ }
22
+ }
23
+ async function pokeCompanion(port) {
24
+ return new Promise((resolve2) => {
25
+ const req = httpRequest(
26
+ {
27
+ host: "127.0.0.1",
28
+ port,
29
+ path: "/insitue/handshake",
30
+ method: "GET",
31
+ timeout: 1500
32
+ },
33
+ (res) => {
34
+ res.resume();
35
+ resolve2({ alive: true, subscribers: null });
36
+ }
37
+ );
38
+ req.on("error", () => resolve2({ alive: false, subscribers: null }));
39
+ req.on("timeout", () => {
40
+ req.destroy();
41
+ resolve2({ alive: false, subscribers: null });
42
+ });
43
+ req.end();
44
+ });
45
+ }
46
+ function detectSwcPluginConfigured(projectDir) {
47
+ for (const f of [
48
+ "next.config.mjs",
49
+ "next.config.js",
50
+ "next.config.ts",
51
+ "vite.config.ts",
52
+ "vite.config.js"
53
+ ]) {
54
+ const p = join(projectDir, f);
55
+ if (existsSync(p)) {
56
+ try {
57
+ const c = readFileSync(p, "utf8");
58
+ return c.includes("@insitue/swc-source-attr");
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+ }
64
+ return null;
65
+ }
66
+ async function diagnose(projectDir) {
67
+ const session = readSession(projectDir.dir);
68
+ const hasSessionFile = session !== null;
69
+ let companionReachable = false;
70
+ let companionPort = null;
71
+ let companionSubscribers = null;
72
+ if (session) {
73
+ const r = await pokeCompanion(session.port);
74
+ companionReachable = r.alive;
75
+ if (r.alive) companionPort = session.port;
76
+ companionSubscribers = r.subscribers;
77
+ }
78
+ const sdkVersion = readPkgVersion(projectDir.dir, "@insitue/sdk");
79
+ const swcPluginVersion = readPkgVersion(
80
+ projectDir.dir,
81
+ "@insitue/swc-source-attr"
82
+ );
83
+ const swcPluginConfigured = detectSwcPluginConfigured(projectDir.dir);
84
+ const recommendations = [];
85
+ if (!sdkVersion) {
86
+ recommendations.push(
87
+ "`@insitue/sdk` not installed in the project \u2014 `pnpm add -D @insitue/sdk`"
88
+ );
89
+ }
90
+ if (!swcPluginVersion) {
91
+ recommendations.push(
92
+ "`@insitue/swc-source-attr` not installed \u2014 exact source resolution will degrade. `pnpm add -D @insitue/swc-source-attr`"
93
+ );
94
+ }
95
+ if (swcPluginVersion && swcPluginConfigured === false) {
96
+ recommendations.push(
97
+ "`@insitue/swc-source-attr` installed but not wired into next.config / vite.config \u2014 see the SDK README for the snippet"
98
+ );
99
+ }
100
+ if (!hasSessionFile) {
101
+ recommendations.push(
102
+ "No `.insitue/session.json` yet \u2014 the companion hasn't run in this project. Start `pnpm dev` and the companion should auto-spawn when claude attaches."
103
+ );
104
+ } else if (!companionReachable) {
105
+ recommendations.push(
106
+ "Stale `.insitue/session.json` (companion not reachable). Delete the `.insitue/` directory and re-attach."
107
+ );
108
+ }
109
+ return {
110
+ projectDir,
111
+ hasSessionFile,
112
+ companionReachable,
113
+ companionPort,
114
+ companionSubscribers,
115
+ sdkVersion,
116
+ swcPluginVersion,
117
+ swcPluginConfigured,
118
+ recommendations
119
+ };
120
+ }
121
+
122
+ // src/project-dir.ts
123
+ import { existsSync as existsSync2, realpathSync } from "fs";
124
+ import { dirname, isAbsolute, join as join2, resolve } from "path";
125
+ function readProjectDirArg(argv) {
126
+ for (let i = 0; i < argv.length; i++) {
127
+ const a = argv[i];
128
+ if (a === "--project-dir" && i + 1 < argv.length) return argv[i + 1];
129
+ if (a.startsWith("--project-dir=")) return a.slice("--project-dir=".length);
130
+ }
131
+ return null;
132
+ }
133
+ function walkUpFor(start, marker) {
134
+ let dir = resolve(start);
135
+ while (true) {
136
+ if (existsSync2(join2(dir, marker))) return dir;
137
+ const parent = dirname(dir);
138
+ if (parent === dir) return null;
139
+ dir = parent;
140
+ }
141
+ }
142
+ function realpathSafe(p) {
143
+ try {
144
+ return realpathSync(p);
145
+ } catch {
146
+ return resolve(p);
147
+ }
148
+ }
149
+ function resolveProjectDir(argv = process.argv.slice(2), env = process.env) {
150
+ const fromArg = readProjectDirArg(argv);
151
+ if (fromArg) {
152
+ return { dir: realpathSafe(fromArg), source: "argv" };
153
+ }
154
+ if (env.INSITUE_PROJECT_DIR) {
155
+ return {
156
+ dir: realpathSafe(env.INSITUE_PROJECT_DIR),
157
+ source: "INSITUE_PROJECT_DIR"
158
+ };
159
+ }
160
+ if (env.CLAUDE_PROJECT_DIR) {
161
+ return {
162
+ dir: realpathSafe(env.CLAUDE_PROJECT_DIR),
163
+ source: "CLAUDE_PROJECT_DIR"
164
+ };
165
+ }
166
+ const cwd = process.cwd();
167
+ const sessionDir = walkUpFor(cwd, ".insitue");
168
+ if (sessionDir && existsSync2(join2(sessionDir, ".insitue", "session.json"))) {
169
+ return { dir: realpathSafe(sessionDir), source: "session-walk-up" };
170
+ }
171
+ const pkgDir = walkUpFor(cwd, "package.json");
172
+ if (pkgDir) {
173
+ return { dir: realpathSafe(pkgDir), source: "package-walk-up" };
174
+ }
175
+ return { dir: realpathSafe(cwd), source: "cwd" };
176
+ }
177
+ function isInsideProject(root, target) {
178
+ const r = realpathSafe(root);
179
+ let t;
180
+ try {
181
+ t = realpathSync(target);
182
+ } catch {
183
+ t = resolve(target);
184
+ }
185
+ if (!isAbsolute(r) || !isAbsolute(t)) return false;
186
+ const rWithSep = r.endsWith("/") ? r : r + "/";
187
+ return t === r || t.startsWith(rWithSep);
188
+ }
189
+
190
+ export {
191
+ diagnose,
192
+ resolveProjectDir,
193
+ isInsideProject
194
+ };
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/dispatcher.ts
4
+ var SUBCOMMANDS = /* @__PURE__ */ new Set(["setup", "diagnose", "help", "--help", "-h"]);
5
+ var first = process.argv[2];
6
+ if (first && SUBCOMMANDS.has(first)) {
7
+ await import("./setup-cli.js");
8
+ } else {
9
+ await import("./mcp-server.js");
10
+ }
@@ -1,34 +1,184 @@
1
1
  #!/usr/bin/env node
2
+ import {
3
+ diagnose,
4
+ isInsideProject,
5
+ resolveProjectDir
6
+ } from "./chunk-RF5Q55CG.js";
2
7
 
3
8
  // src/mcp-server.ts
4
9
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
10
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
11
  import { spawn } from "child_process";
7
- import { existsSync, readFileSync } from "fs";
12
+ import { existsSync as existsSync2, readFileSync as readFileSync3 } from "fs";
8
13
  import { request as httpRequest } from "http";
9
- import { dirname, join, resolve } from "path";
14
+ import { dirname as dirname3, join as join3 } from "path";
15
+ import { fileURLToPath as fileURLToPath2 } from "url";
10
16
  import WebSocket from "ws";
11
17
  import { z } from "zod";
18
+
19
+ // src/file-tools.ts
20
+ import {
21
+ existsSync,
22
+ mkdirSync,
23
+ readFileSync,
24
+ writeFileSync
25
+ } from "fs";
26
+ import { dirname, isAbsolute, join } from "path";
27
+ function resolveWithin(projectDir2, path) {
28
+ if (!path || typeof path !== "string") {
29
+ return {
30
+ ok: false,
31
+ result: { status: "error", message: "missing or invalid `path`" }
32
+ };
33
+ }
34
+ const abs = isAbsolute(path) ? path : join(projectDir2, path);
35
+ if (!isInsideProject(projectDir2, abs)) {
36
+ return {
37
+ ok: false,
38
+ result: {
39
+ status: "error",
40
+ message: `refused: \`${path}\` resolves outside the project dir (${projectDir2})`
41
+ }
42
+ };
43
+ }
44
+ return { ok: true, abs };
45
+ }
46
+ function readFileInProject(projectDir2, path, opts = {}) {
47
+ const resolved = resolveWithin(projectDir2, path);
48
+ if (!resolved.ok) return resolved.result;
49
+ if (!existsSync(resolved.abs)) {
50
+ return { status: "error", message: `file not found: ${path}` };
51
+ }
52
+ try {
53
+ const full = readFileSync(resolved.abs, "utf8");
54
+ if (opts.startLine == null && opts.endLine == null) {
55
+ return {
56
+ status: "ok",
57
+ content: full,
58
+ bytes: Buffer.byteLength(full, "utf8"),
59
+ message: `read ${path}`
60
+ };
61
+ }
62
+ const lines = full.split("\n");
63
+ const start = Math.max(1, opts.startLine ?? 1) - 1;
64
+ const end = Math.min(lines.length, opts.endLine ?? lines.length);
65
+ const slice = lines.slice(start, end).join("\n");
66
+ return {
67
+ status: "ok",
68
+ content: slice,
69
+ bytes: Buffer.byteLength(slice, "utf8"),
70
+ message: `read ${path} L${start + 1}-${end}`
71
+ };
72
+ } catch (err) {
73
+ return {
74
+ status: "error",
75
+ message: `read failed: ${err.message}`
76
+ };
77
+ }
78
+ }
79
+ function applyEditInProject(projectDir2, path, oldString, newString, opts = {}) {
80
+ const resolved = resolveWithin(projectDir2, path);
81
+ if (!resolved.ok) return resolved.result;
82
+ if (!existsSync(resolved.abs)) {
83
+ return { status: "error", message: `file not found: ${path}` };
84
+ }
85
+ if (oldString === newString) {
86
+ return {
87
+ status: "error",
88
+ message: "`oldString` and `newString` are identical \u2014 nothing to apply"
89
+ };
90
+ }
91
+ try {
92
+ const before = readFileSync(resolved.abs, "utf8");
93
+ if (!before.includes(oldString)) {
94
+ return {
95
+ status: "error",
96
+ message: "`oldString` not found in file \u2014 fetch the current content with read_file and retry with the exact match"
97
+ };
98
+ }
99
+ if (!opts.replaceAll) {
100
+ const first = before.indexOf(oldString);
101
+ const next = before.indexOf(oldString, first + oldString.length);
102
+ if (next !== -1) {
103
+ return {
104
+ status: "error",
105
+ message: "`oldString` matches multiple times \u2014 pass `replaceAll: true` or include more context to make the match unique"
106
+ };
107
+ }
108
+ }
109
+ const after = opts.replaceAll ? before.split(oldString).join(newString) : before.replace(oldString, newString);
110
+ writeFileSync(resolved.abs, after, "utf8");
111
+ const diffBytes = Buffer.byteLength(after, "utf8") - Buffer.byteLength(before, "utf8");
112
+ return {
113
+ status: "ok",
114
+ message: `applied edit to ${path} (${diffBytes >= 0 ? "+" : ""}${diffBytes} bytes)`,
115
+ bytes: Buffer.byteLength(after, "utf8")
116
+ };
117
+ } catch (err) {
118
+ return {
119
+ status: "error",
120
+ message: `edit failed: ${err.message}`
121
+ };
122
+ }
123
+ }
124
+ function writeFileInProject(projectDir2, path, content, opts = {}) {
125
+ const resolved = resolveWithin(projectDir2, path);
126
+ if (!resolved.ok) return resolved.result;
127
+ try {
128
+ if (opts.createParents) {
129
+ mkdirSync(dirname(resolved.abs), { recursive: true });
130
+ }
131
+ writeFileSync(resolved.abs, content, "utf8");
132
+ return {
133
+ status: "ok",
134
+ message: `wrote ${path} (${Buffer.byteLength(content, "utf8")} bytes)`,
135
+ bytes: Buffer.byteLength(content, "utf8")
136
+ };
137
+ } catch (err) {
138
+ return {
139
+ status: "error",
140
+ message: `write failed: ${err.message}`
141
+ };
142
+ }
143
+ }
144
+
145
+ // src/instructions.ts
146
+ import { readFileSync as readFileSync2 } from "fs";
147
+ import { dirname as dirname2, join as join2 } from "path";
148
+ import { fileURLToPath } from "url";
149
+ var cached = null;
150
+ function loadInstructions() {
151
+ if (cached) return cached;
152
+ const here = dirname2(fileURLToPath(import.meta.url));
153
+ const candidates = [
154
+ join2(here, "..", "commands", "connect.md"),
155
+ join2(here, "commands", "connect.md")
156
+ ];
157
+ for (const p of candidates) {
158
+ try {
159
+ cached = readFileSync2(p, "utf8");
160
+ return cached;
161
+ } catch {
162
+ }
163
+ }
164
+ cached = "Call `mcp__insitue__next_pick` in a loop. Each ok-status pick has a `userNote` (the user's instruction) and a `source.file:line` (where to act). Propose an edit, ask for approval in this chat, then apply with `apply_edit` (or the runtime's native Edit tool).";
165
+ return cached;
166
+ }
167
+
168
+ // src/mcp-server.ts
12
169
  var MAX_BUFFERED_PICKS = 32;
13
170
  var NEXT_PICK_DEFAULT_TIMEOUT_MS = 25 * 1e3;
14
171
  var NEXT_PICK_MAX_TIMEOUT_MS = 30 * 60 * 1e3;
15
- function findSession(start = process.cwd()) {
16
- let dir = resolve(start);
17
- while (true) {
18
- const candidate = join(dir, ".insitue", "session.json");
19
- if (existsSync(candidate)) {
20
- try {
21
- const session2 = JSON.parse(
22
- readFileSync(candidate, "utf8")
23
- );
24
- return { dir, session: session2 };
25
- } catch {
26
- return null;
27
- }
28
- }
29
- const parent = dirname(dir);
30
- if (parent === dir) return null;
31
- dir = parent;
172
+ function findSession(projectDir2) {
173
+ const candidate = join3(projectDir2, ".insitue", "session.json");
174
+ if (!existsSync2(candidate)) return null;
175
+ try {
176
+ const session2 = JSON.parse(
177
+ readFileSync3(candidate, "utf8")
178
+ );
179
+ return { dir: projectDir2, session: session2 };
180
+ } catch {
181
+ return null;
32
182
  }
33
183
  }
34
184
  function summariseBundle(raw) {
@@ -76,13 +226,13 @@ var PickBuffer = class {
76
226
  if (this.picks.length) {
77
227
  return Promise.resolve(this.picks.shift());
78
228
  }
79
- return new Promise((resolve2) => {
229
+ return new Promise((resolve) => {
80
230
  const timer = setTimeout(() => {
81
231
  const idx = this.waiters.findIndex((w) => w.timer === timer);
82
232
  if (idx >= 0) this.waiters.splice(idx, 1);
83
- resolve2(null);
233
+ resolve(null);
84
234
  }, timeoutMs);
85
- this.waiters.push({ resolve: resolve2, timer });
235
+ this.waiters.push({ resolve, timer });
86
236
  });
87
237
  }
88
238
  recent(limit) {
@@ -114,7 +264,7 @@ async function probeCompanion(session2) {
114
264
  } catch {
115
265
  return false;
116
266
  }
117
- return new Promise((resolve2) => {
267
+ return new Promise((resolve) => {
118
268
  const req = httpRequest(
119
269
  {
120
270
  host: "127.0.0.1",
@@ -125,20 +275,20 @@ async function probeCompanion(session2) {
125
275
  },
126
276
  (res) => {
127
277
  res.resume();
128
- resolve2(true);
278
+ resolve(true);
129
279
  }
130
280
  );
131
- req.on("error", () => resolve2(false));
281
+ req.on("error", () => resolve(false));
132
282
  req.on("timeout", () => {
133
283
  req.destroy();
134
- resolve2(false);
284
+ resolve(false);
135
285
  });
136
286
  req.end();
137
287
  });
138
288
  }
139
289
  var ownedChild = null;
140
- async function ensureCompanion() {
141
- const existing = findSession();
290
+ async function ensureCompanion(projectDir2) {
291
+ const existing = findSession(projectDir2);
142
292
  if (existing && await probeCompanion(existing.session)) {
143
293
  process.stderr.write(
144
294
  `[insitue-mcp] reusing companion at :${existing.session.port} (pid ${existing.session.pid})
@@ -147,13 +297,14 @@ async function ensureCompanion() {
147
297
  return existing.session;
148
298
  }
149
299
  process.stderr.write(
150
- "[insitue-mcp] starting companion via `npx -y @insitue/companion@latest dev`\u2026\n"
300
+ `[insitue-mcp] starting companion via \`npx -y @insitue/companion@latest dev\` in ${projectDir2}\u2026
301
+ `
151
302
  );
152
303
  ownedChild = spawn(
153
304
  "npx",
154
305
  ["-y", "@insitue/companion@latest", "dev"],
155
306
  {
156
- cwd: process.cwd(),
307
+ cwd: projectDir2,
157
308
  stdio: ["ignore", "pipe", "pipe"],
158
309
  env: process.env
159
310
  }
@@ -172,8 +323,8 @@ async function ensureCompanion() {
172
323
  ownedChild = null;
173
324
  });
174
325
  const start = Date.now();
175
- while (Date.now() - start < 5e3) {
176
- const found = findSession();
326
+ while (Date.now() - start < 8e3) {
327
+ const found = findSession(projectDir2);
177
328
  if (found && await probeCompanion(found.session)) {
178
329
  return found.session;
179
330
  }
@@ -268,7 +419,12 @@ function connectToCompanion(session2) {
268
419
  ws.on("error", () => {
269
420
  });
270
421
  }
271
- var session = await ensureCompanion();
422
+ var projectDir = resolveProjectDir();
423
+ process.stderr.write(
424
+ `[insitue-mcp] project dir: ${projectDir.dir} (via ${projectDir.source})
425
+ `
426
+ );
427
+ var session = await ensureCompanion(projectDir.dir);
272
428
  if (!session) {
273
429
  process.stderr.write(
274
430
  "[insitue-mcp] no companion available \u2014 `next_pick` will time out.\n"
@@ -282,7 +438,7 @@ function ensureSubscriberAttached() {
282
438
  }
283
439
  var server = new McpServer({
284
440
  name: "insitue",
285
- version: "0.2.0"
441
+ version: "0.3.0"
286
442
  });
287
443
  server.registerTool(
288
444
  "next_pick",
@@ -336,4 +492,185 @@ server.registerTool(
336
492
  };
337
493
  }
338
494
  );
495
+ server.registerTool(
496
+ "start_session",
497
+ {
498
+ description: "Returns the operating instructions for InSitue + current state (project dir, companion reachable, buffered pick count). On Claude Code you typically don't need this \u2014 the slash command `/insitue:connect` already loaded the instructions. On Claude Desktop there are no slash commands, so call this once at the start of every session before entering the next_pick loop.",
499
+ inputSchema: {}
500
+ },
501
+ async () => {
502
+ ensureSubscriberAttached();
503
+ const instructions = loadInstructions();
504
+ const buffered = buffer.recent(32).length;
505
+ const status = `
506
+
507
+ ---
508
+
509
+ **Current state**
510
+
511
+ - Project: \`${projectDir.dir}\` (resolved via ${projectDir.source})
512
+ - Companion: ${session ? `reachable on port ${session.port}` : "NOT reachable"}
513
+ - Buffered picks waiting: ${buffered}
514
+
515
+ Begin the loop by calling \`list_recent_picks\` once, then loop on \`next_pick\`.`;
516
+ return {
517
+ content: [
518
+ { type: "text", text: instructions + status }
519
+ ]
520
+ };
521
+ }
522
+ );
523
+ server.registerTool(
524
+ "diagnose",
525
+ {
526
+ description: "Run a health check on the local InSitue setup \u2014 companion reachability, SDK install, SWC plugin install + wiring, session file freshness. Returns a structured report plus human-readable recommendations. Use when picks don't seem to be flowing.",
527
+ inputSchema: {}
528
+ },
529
+ async () => {
530
+ const report = await diagnose(projectDir);
531
+ return {
532
+ content: [
533
+ { type: "text", text: JSON.stringify(report, null, 2) }
534
+ ]
535
+ };
536
+ }
537
+ );
538
+ server.registerTool(
539
+ "read_file",
540
+ {
541
+ description: "Read a file from the resolved project directory. Paths are relative to the project root (or absolute, in which case they must still live inside the project). Optional `startLine`/`endLine` for partial reads. On Claude Code, prefer the built-in Read tool \u2014 this exists primarily for Claude Desktop, where no built-in file tools are available.",
542
+ inputSchema: {
543
+ path: z.string().describe("Project-relative or absolute path."),
544
+ startLine: z.number().int().positive().optional().describe("1-indexed start line (inclusive)."),
545
+ endLine: z.number().int().positive().optional().describe("1-indexed end line (inclusive).")
546
+ }
547
+ },
548
+ async ({ path, startLine, endLine }) => {
549
+ const opts = {};
550
+ if (startLine !== void 0) opts.startLine = startLine;
551
+ if (endLine !== void 0) opts.endLine = endLine;
552
+ const r = readFileInProject(projectDir.dir, path, opts);
553
+ return {
554
+ content: [
555
+ { type: "text", text: JSON.stringify(r) }
556
+ ]
557
+ };
558
+ }
559
+ );
560
+ server.registerTool(
561
+ "apply_edit",
562
+ {
563
+ description: "Apply a string-replacement edit to a file inside the project. `oldString` must occur exactly once in the file (otherwise pass `replaceAll: true`). Returns a status + brief summary. ALWAYS ask the user for explicit approval before calling this \u2014 InSitue's contract is human-in-the-loop on every write. On Claude Code, prefer the built-in Edit tool.",
564
+ inputSchema: {
565
+ path: z.string().describe("Project-relative or absolute path."),
566
+ oldString: z.string().describe("Exact text to replace."),
567
+ newString: z.string().describe("Replacement text."),
568
+ replaceAll: z.boolean().optional().describe(
569
+ "Replace every occurrence of `oldString` instead of refusing on ambiguity."
570
+ )
571
+ }
572
+ },
573
+ async ({ path, oldString, newString, replaceAll }) => {
574
+ const opts = {};
575
+ if (replaceAll !== void 0) opts.replaceAll = replaceAll;
576
+ const r = applyEditInProject(
577
+ projectDir.dir,
578
+ path,
579
+ oldString,
580
+ newString,
581
+ opts
582
+ );
583
+ return {
584
+ content: [
585
+ { type: "text", text: JSON.stringify(r) }
586
+ ]
587
+ };
588
+ }
589
+ );
590
+ server.registerTool(
591
+ "write_file",
592
+ {
593
+ description: "Write the full contents of a file inside the project. Use for new files or full rewrites where `apply_edit`'s string match isn't a good fit. ALWAYS ask the user for explicit approval before calling. On Claude Code, prefer the built-in Write tool.",
594
+ inputSchema: {
595
+ path: z.string().describe("Project-relative or absolute path."),
596
+ content: z.string().describe("Full file contents to write."),
597
+ createParents: z.boolean().optional().describe("Create parent directories if they don't exist.")
598
+ }
599
+ },
600
+ async ({ path, content, createParents }) => {
601
+ const opts = {};
602
+ if (createParents !== void 0) opts.createParents = createParents;
603
+ const r = writeFileInProject(projectDir.dir, path, content, opts);
604
+ return {
605
+ content: [
606
+ { type: "text", text: JSON.stringify(r) }
607
+ ]
608
+ };
609
+ }
610
+ );
611
+ server.registerPrompt(
612
+ "connect",
613
+ {
614
+ title: "Connect to InSitue",
615
+ description: "Loads the operating instructions and begins the pick \u2192 edit loop."
616
+ },
617
+ () => ({
618
+ messages: [
619
+ {
620
+ role: "user",
621
+ content: { type: "text", text: loadInstructions() }
622
+ }
623
+ ]
624
+ })
625
+ );
626
+ function readPkgFile(rel) {
627
+ const here = dirname3(fileURLToPath2(import.meta.url));
628
+ for (const base of [join3(here, ".."), here]) {
629
+ const p = join3(base, rel);
630
+ if (existsSync2(p)) {
631
+ try {
632
+ return readFileSync3(p, "utf8");
633
+ } catch {
634
+ return null;
635
+ }
636
+ }
637
+ }
638
+ return null;
639
+ }
640
+ server.registerResource(
641
+ "instructions",
642
+ "insitue://instructions",
643
+ {
644
+ title: "InSitue operating instructions",
645
+ description: "The same content that drives `/insitue:connect` on Code and `start_session` on Desktop.",
646
+ mimeType: "text/markdown"
647
+ },
648
+ async () => ({
649
+ contents: [
650
+ {
651
+ uri: "insitue://instructions",
652
+ mimeType: "text/markdown",
653
+ text: loadInstructions()
654
+ }
655
+ ]
656
+ })
657
+ );
658
+ server.registerResource(
659
+ "readme",
660
+ "insitue://readme",
661
+ {
662
+ title: "@insitue/claude-plugin README",
663
+ description: "Package overview, setup steps, and runtime notes.",
664
+ mimeType: "text/markdown"
665
+ },
666
+ async () => ({
667
+ contents: [
668
+ {
669
+ uri: "insitue://readme",
670
+ mimeType: "text/markdown",
671
+ text: readPkgFile("README.md") ?? "README not bundled \u2014 see https://github.com/InSitue/insitue/tree/main/packages/claude-plugin"
672
+ }
673
+ ]
674
+ })
675
+ );
339
676
  await server.connect(new StdioServerTransport());
@@ -0,0 +1,238 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ diagnose,
4
+ resolveProjectDir
5
+ } from "./chunk-RF5Q55CG.js";
6
+
7
+ // src/setup-cli.ts
8
+ import {
9
+ copyFileSync,
10
+ existsSync,
11
+ mkdirSync,
12
+ readFileSync,
13
+ writeFileSync
14
+ } from "fs";
15
+ import { basename, dirname, join, resolve } from "path";
16
+ import { homedir, platform } from "os";
17
+ function parseArgs(argv) {
18
+ const positional2 = [];
19
+ const flags2 = /* @__PURE__ */ new Map();
20
+ for (let i = 0; i < argv.length; i++) {
21
+ const a = argv[i];
22
+ if (!a.startsWith("--")) {
23
+ positional2.push(a);
24
+ continue;
25
+ }
26
+ const eq = a.indexOf("=");
27
+ if (eq !== -1) {
28
+ flags2.set(a.slice(2, eq), a.slice(eq + 1));
29
+ } else if (i + 1 < argv.length && !argv[i + 1].startsWith("--")) {
30
+ flags2.set(a.slice(2), argv[++i]);
31
+ } else {
32
+ flags2.set(a.slice(2), true);
33
+ }
34
+ }
35
+ return { positional: positional2, flags: flags2 };
36
+ }
37
+ function desktopConfigPath() {
38
+ const home = homedir();
39
+ switch (platform()) {
40
+ case "darwin":
41
+ return join(
42
+ home,
43
+ "Library",
44
+ "Application Support",
45
+ "Claude",
46
+ "claude_desktop_config.json"
47
+ );
48
+ case "win32":
49
+ return join(
50
+ process.env.APPDATA ?? join(home, "AppData", "Roaming"),
51
+ "Claude",
52
+ "claude_desktop_config.json"
53
+ );
54
+ default:
55
+ return join(
56
+ process.env.XDG_CONFIG_HOME ?? join(home, ".config"),
57
+ "Claude",
58
+ "claude_desktop_config.json"
59
+ );
60
+ }
61
+ }
62
+ function makeEntryName(projectDir, explicit) {
63
+ if (explicit) return explicit;
64
+ const base = basename(projectDir).toLowerCase().replace(/[^a-z0-9-]+/g, "-");
65
+ return `insitue-${base || "project"}`;
66
+ }
67
+ function buildEntry(projectDir) {
68
+ return {
69
+ command: "npx",
70
+ args: ["-y", "@insitue/claude-plugin@latest"],
71
+ env: {
72
+ INSITUE_PROJECT_DIR: projectDir
73
+ }
74
+ };
75
+ }
76
+ function readDesktopConfig(path) {
77
+ if (!existsSync(path)) return {};
78
+ try {
79
+ return JSON.parse(readFileSync(path, "utf8"));
80
+ } catch (err) {
81
+ throw new Error(
82
+ `Couldn't parse existing config at ${path}: ${err.message}. Fix the JSON manually and re-run.`
83
+ );
84
+ }
85
+ }
86
+ function writeDesktopConfig(path, cfg) {
87
+ mkdirSync(dirname(path), { recursive: true });
88
+ writeFileSync(path, JSON.stringify(cfg, null, 2) + "\n", "utf8");
89
+ }
90
+ function backupConfig(path) {
91
+ if (!existsSync(path)) return null;
92
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
93
+ const backup = `${path}.insitue-backup-${stamp}`;
94
+ copyFileSync(path, backup);
95
+ return backup;
96
+ }
97
+ async function cmdSetup(flags2) {
98
+ const projectFlag = flags2.get("project");
99
+ const projectDir = resolve(
100
+ typeof projectFlag === "string" ? projectFlag : process.cwd()
101
+ );
102
+ if (!existsSync(projectDir)) {
103
+ process.stderr.write(`error: project dir doesn't exist: ${projectDir}
104
+ `);
105
+ return 1;
106
+ }
107
+ const wantDesktop = flags2.has("desktop") || flags2.has("both");
108
+ const wantCode = flags2.has("code") || flags2.has("both");
109
+ const wantInteractive = !flags2.has("desktop") && !flags2.has("code") && !flags2.has("both");
110
+ const name = typeof flags2.get("name") === "string" ? flags2.get("name") : makeEntryName(projectDir);
111
+ const entry = buildEntry(projectDir);
112
+ const dryRun = flags2.has("dry-run");
113
+ if (wantInteractive) {
114
+ process.stdout.write(
115
+ `InSitue setup
116
+ \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
117
+ Project: ${projectDir}
118
+ Entry name: ${name}
119
+
120
+ Pass one of:
121
+ --desktop Wire into Claude Desktop
122
+ --code Print the Claude Code marketplace install hint
123
+ --both Both runtimes
124
+
125
+ Example:
126
+ npx @insitue/claude-plugin setup --desktop --project=${projectDir}
127
+ `
128
+ );
129
+ return 0;
130
+ }
131
+ if (wantCode) {
132
+ process.stdout.write(
133
+ "\nClaude Code setup\n\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nInside `claude`, run:\n\n /plugin marketplace add InSitue/insitue\n /plugin install insitue@insitue-plugins\n\nThen `/insitue:connect` to start the loop. No further config\nneeded \u2014 claude provides ${CLAUDE_PROJECT_DIR} automatically.\n"
134
+ );
135
+ }
136
+ if (wantDesktop) {
137
+ const cfgPath = desktopConfigPath();
138
+ process.stdout.write(
139
+ `
140
+ Claude Desktop setup
141
+ \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
142
+ Config file: ${cfgPath}
143
+ Entry name: ${name}
144
+ Project: ${projectDir}
145
+ `
146
+ );
147
+ const cfg = readDesktopConfig(cfgPath);
148
+ const current = cfg.mcpServers?.[name];
149
+ const same = current && JSON.stringify(current) === JSON.stringify(entry);
150
+ if (same) {
151
+ process.stdout.write(
152
+ "\u2713 Already wired correctly \u2014 no changes needed.\n"
153
+ );
154
+ } else if (dryRun) {
155
+ process.stdout.write(
156
+ `
157
+ [dry-run] Would write entry:
158
+ "${name}": ${JSON.stringify(entry, null, 2).replace(/\n/g, "\n ")}
159
+ `
160
+ );
161
+ } else {
162
+ const backup = backupConfig(cfgPath);
163
+ const next = { ...cfg };
164
+ next.mcpServers = { ...cfg.mcpServers ?? {}, [name]: entry };
165
+ writeDesktopConfig(cfgPath, next);
166
+ process.stdout.write(
167
+ (backup ? `\u2713 Backed up existing config \u2192 ${backup}
168
+ ` : "\u2713 Created new config (no existing file).\n") + `\u2713 Wrote entry "${name}".
169
+
170
+ Restart Claude Desktop, then start a new chat and tell
171
+ claude: "Use the InSitue MCP \u2014 call \`start_session\` and
172
+ follow the instructions it returns."
173
+ `
174
+ );
175
+ }
176
+ }
177
+ process.stdout.write("\n");
178
+ return 0;
179
+ }
180
+ async function cmdDiagnose(flags2) {
181
+ const projectFlag = flags2.get("project");
182
+ const argv = typeof projectFlag === "string" ? ["--project-dir", projectFlag] : [];
183
+ const projectDir = resolveProjectDir(argv);
184
+ const report = await diagnose(projectDir);
185
+ const tick = (b) => b ? "\u2713" : "\u2717";
186
+ const lines = [
187
+ "InSitue diagnostics",
188
+ "\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500",
189
+ `Project: ${report.projectDir.dir} (via ${report.projectDir.source})`,
190
+ `Session: ${tick(report.hasSessionFile)} .insitue/session.json ${report.hasSessionFile ? "exists" : "missing"}`,
191
+ `Companion: ${tick(report.companionReachable)} ${report.companionReachable ? `reachable on port ${report.companionPort}` : "not reachable"}`,
192
+ `@insitue/sdk: ${report.sdkVersion ?? "(not installed)"}`,
193
+ `@insitue/swc-source-attr: ${report.swcPluginVersion ?? "(not installed)"}`,
194
+ `SWC plugin configured: ${report.swcPluginConfigured == null ? "(no next/vite config found)" : report.swcPluginConfigured ? "yes" : "no"}`
195
+ ];
196
+ if (report.recommendations.length) {
197
+ lines.push("", "Recommendations:");
198
+ for (const r of report.recommendations) lines.push(` \xB7 ${r}`);
199
+ } else {
200
+ lines.push("", "No recommendations \u2014 everything looks healthy.");
201
+ }
202
+ process.stdout.write(lines.join("\n") + "\n");
203
+ return 0;
204
+ }
205
+ function cmdHelp() {
206
+ process.stdout.write(
207
+ "Usage: insitue <command> [flags]\n\nCommands:\n setup Wire the InSitue MCP into Claude Desktop / Code\n diagnose Health-check the local InSitue setup\n help Show this message\n\nFlags (setup):\n --desktop Configure Claude Desktop\n --code Print the Claude Code install hint\n --both Configure both\n --project=PATH Project directory (default: cwd)\n --name=NAME Desktop MCP entry name (default: insitue-<dirname>)\n --dry-run Show what would change without writing\n\nFlags (diagnose):\n --project=PATH Project directory (default: walk-up from cwd)\n"
208
+ );
209
+ return 0;
210
+ }
211
+ var { positional, flags } = parseArgs(process.argv.slice(2));
212
+ var sub = positional[0] ?? "help";
213
+ try {
214
+ let code;
215
+ switch (sub) {
216
+ case "setup":
217
+ code = await cmdSetup(flags);
218
+ break;
219
+ case "diagnose":
220
+ code = await cmdDiagnose(flags);
221
+ break;
222
+ case "help":
223
+ case "--help":
224
+ case "-h":
225
+ code = cmdHelp();
226
+ break;
227
+ default:
228
+ process.stderr.write(`unknown command: ${sub}
229
+ `);
230
+ cmdHelp();
231
+ code = 2;
232
+ }
233
+ process.exit(code);
234
+ } catch (err) {
235
+ process.stderr.write(`error: ${err.message}
236
+ `);
237
+ process.exit(1);
238
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@insitue/claude-plugin",
3
- "version": "0.3.3",
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.",
3
+ "version": "0.4.1",
4
+ "description": "Drive Claude (Code AND Desktop) from the InSitue browser overlay — pick an element in your app, claude reads the file and proposes the edit.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "files": [
@@ -14,7 +14,7 @@
14
14
  ".": "./dist/mcp-server.js"
15
15
  },
16
16
  "bin": {
17
- "insitue-mcp": "./dist/mcp-server.js"
17
+ "insitue-claude-plugin": "./dist/dispatcher.js"
18
18
  },
19
19
  "dependencies": {
20
20
  "@modelcontextprotocol/sdk": "^1.29.0",
@@ -30,8 +30,8 @@
30
30
  "access": "public"
31
31
  },
32
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",
33
+ "build": "tsup src/dispatcher.ts src/mcp-server.ts src/setup-cli.ts --format esm --clean --external @modelcontextprotocol/sdk --external ws --external zod",
34
+ "dev": "tsup src/dispatcher.ts src/mcp-server.ts src/setup-cli.ts --format esm --watch --external @modelcontextprotocol/sdk --external ws --external zod",
35
35
  "typecheck": "tsc --noEmit",
36
36
  "lint": "tsc --noEmit"
37
37
  }