@insitue/claude-plugin 0.3.2 → 0.4.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/.claude-plugin/plugin.json +1 -1
- package/README.md +65 -8
- package/commands/connect.md +33 -11
- package/dist/chunk-RF5Q55CG.js +194 -0
- package/dist/mcp-server.js +383 -38
- package/dist/setup-cli.js +238 -0
- package/package.json +6 -5
package/README.md
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
# InSitue Dev — Claude Code
|
|
1
|
+
# InSitue Dev — Claude plugin (Code + Desktop)
|
|
2
2
|
|
|
3
|
-
Drive a
|
|
4
|
-
in the browser, describe what you want
|
|
5
|
-
|
|
6
|
-
the edit. No copy-pasting file paths. No
|
|
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
|
-
|
|
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
|
|
package/commands/connect.md
CHANGED
|
@@ -1,18 +1,30 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: Drive this Claude Code
|
|
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
|
-
#
|
|
5
|
+
# InSitue session
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
|
@@ -21,7 +33,11 @@ not need to ask the user to run any extra commands.
|
|
|
21
33
|
to click Send in the InSitue panel"). Otherwise just say
|
|
22
34
|
"Connected. Pick something in the browser when you're ready."
|
|
23
35
|
2. Enter the loop: call `mcp__insitue__next_pick`. It long-polls
|
|
24
|
-
(~
|
|
36
|
+
(~25s default — short on purpose so the chat stays responsive
|
|
37
|
+
to other questions the user might type while you wait). When
|
|
38
|
+
it returns with `status: "timeout"`, **call it again immediately
|
|
39
|
+
without announcing it** — the timeout is just a heartbeat, not
|
|
40
|
+
news. When it returns with `status: "ok"`:
|
|
25
41
|
- **Always echo the prompt back first.** Before any action,
|
|
26
42
|
diff, or follow-up question, lead with:
|
|
27
43
|
|
|
@@ -49,12 +65,18 @@ not need to ask the user to run any extra commands.
|
|
|
49
65
|
- Propose the edit with a clear diff in this chat. Wait for
|
|
50
66
|
the user to say "yes" / "approve" / "go" before writing.
|
|
51
67
|
Don't auto-apply.
|
|
52
|
-
- On approval, write with the Edit tool
|
|
53
|
-
changed.
|
|
68
|
+
- On approval, write with the Edit tool (Code) or
|
|
69
|
+
`mcp__insitue__apply_edit` (Desktop). Confirm what changed.
|
|
54
70
|
- Loop back to `next_pick`.
|
|
55
71
|
3. If `next_pick` returns `status: "timeout"`, the user simply
|
|
56
72
|
hasn't picked anything yet. Stay quiet and call `next_pick`
|
|
57
|
-
again.
|
|
73
|
+
again. **Do not narrate the loop** — no "still waiting…", no
|
|
74
|
+
"polling again…". The user sees `[insitue] 📥 pick received`
|
|
75
|
+
on stderr the moment their pick lands; that's the
|
|
76
|
+
confirmation, not your narration. If the user types another
|
|
77
|
+
question while you're between calls, answer it first (since
|
|
78
|
+
the chat is responsive), then resume the loop with
|
|
79
|
+
`next_pick`.
|
|
58
80
|
4. If a pick comes back with `target` starting with
|
|
59
81
|
`[insitue]` (e.g. "companion disconnected"), tell the user
|
|
60
82
|
what happened in one sentence and call `next_pick` again —
|
|
@@ -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
|
+
};
|
package/dist/mcp-server.js
CHANGED
|
@@ -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
|
|
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";
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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})`
|
|
27
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 });
|
|
28
130
|
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
169
|
+
var MAX_BUFFERED_PICKS = 32;
|
|
170
|
+
var NEXT_PICK_DEFAULT_TIMEOUT_MS = 25 * 1e3;
|
|
171
|
+
var NEXT_PICK_MAX_TIMEOUT_MS = 30 * 60 * 1e3;
|
|
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) {
|
|
@@ -54,7 +204,8 @@ function summariseBundle(raw) {
|
|
|
54
204
|
selector: t?.selector ?? null,
|
|
55
205
|
userNote: raw.bundle.userNote ?? null,
|
|
56
206
|
url: raw.bundle.runtime?.url ?? null,
|
|
57
|
-
componentStack
|
|
207
|
+
componentStack,
|
|
208
|
+
...t?.cmsSource ? { cmsSource: t.cmsSource } : {}
|
|
58
209
|
};
|
|
59
210
|
}
|
|
60
211
|
var PickBuffer = class {
|
|
@@ -75,13 +226,13 @@ var PickBuffer = class {
|
|
|
75
226
|
if (this.picks.length) {
|
|
76
227
|
return Promise.resolve(this.picks.shift());
|
|
77
228
|
}
|
|
78
|
-
return new Promise((
|
|
229
|
+
return new Promise((resolve) => {
|
|
79
230
|
const timer = setTimeout(() => {
|
|
80
231
|
const idx = this.waiters.findIndex((w) => w.timer === timer);
|
|
81
232
|
if (idx >= 0) this.waiters.splice(idx, 1);
|
|
82
|
-
|
|
233
|
+
resolve(null);
|
|
83
234
|
}, timeoutMs);
|
|
84
|
-
this.waiters.push({ resolve
|
|
235
|
+
this.waiters.push({ resolve, timer });
|
|
85
236
|
});
|
|
86
237
|
}
|
|
87
238
|
recent(limit) {
|
|
@@ -113,7 +264,7 @@ async function probeCompanion(session2) {
|
|
|
113
264
|
} catch {
|
|
114
265
|
return false;
|
|
115
266
|
}
|
|
116
|
-
return new Promise((
|
|
267
|
+
return new Promise((resolve) => {
|
|
117
268
|
const req = httpRequest(
|
|
118
269
|
{
|
|
119
270
|
host: "127.0.0.1",
|
|
@@ -124,20 +275,20 @@ async function probeCompanion(session2) {
|
|
|
124
275
|
},
|
|
125
276
|
(res) => {
|
|
126
277
|
res.resume();
|
|
127
|
-
|
|
278
|
+
resolve(true);
|
|
128
279
|
}
|
|
129
280
|
);
|
|
130
|
-
req.on("error", () =>
|
|
281
|
+
req.on("error", () => resolve(false));
|
|
131
282
|
req.on("timeout", () => {
|
|
132
283
|
req.destroy();
|
|
133
|
-
|
|
284
|
+
resolve(false);
|
|
134
285
|
});
|
|
135
286
|
req.end();
|
|
136
287
|
});
|
|
137
288
|
}
|
|
138
289
|
var ownedChild = null;
|
|
139
|
-
async function ensureCompanion() {
|
|
140
|
-
const existing = findSession();
|
|
290
|
+
async function ensureCompanion(projectDir2) {
|
|
291
|
+
const existing = findSession(projectDir2);
|
|
141
292
|
if (existing && await probeCompanion(existing.session)) {
|
|
142
293
|
process.stderr.write(
|
|
143
294
|
`[insitue-mcp] reusing companion at :${existing.session.port} (pid ${existing.session.pid})
|
|
@@ -146,13 +297,14 @@ async function ensureCompanion() {
|
|
|
146
297
|
return existing.session;
|
|
147
298
|
}
|
|
148
299
|
process.stderr.write(
|
|
149
|
-
|
|
300
|
+
`[insitue-mcp] starting companion via \`npx -y @insitue/companion@latest dev\` in ${projectDir2}\u2026
|
|
301
|
+
`
|
|
150
302
|
);
|
|
151
303
|
ownedChild = spawn(
|
|
152
304
|
"npx",
|
|
153
305
|
["-y", "@insitue/companion@latest", "dev"],
|
|
154
306
|
{
|
|
155
|
-
cwd:
|
|
307
|
+
cwd: projectDir2,
|
|
156
308
|
stdio: ["ignore", "pipe", "pipe"],
|
|
157
309
|
env: process.env
|
|
158
310
|
}
|
|
@@ -171,8 +323,8 @@ async function ensureCompanion() {
|
|
|
171
323
|
ownedChild = null;
|
|
172
324
|
});
|
|
173
325
|
const start = Date.now();
|
|
174
|
-
while (Date.now() - start <
|
|
175
|
-
const found = findSession();
|
|
326
|
+
while (Date.now() - start < 8e3) {
|
|
327
|
+
const found = findSession(projectDir2);
|
|
176
328
|
if (found && await probeCompanion(found.session)) {
|
|
177
329
|
return found.session;
|
|
178
330
|
}
|
|
@@ -239,8 +391,15 @@ function connectToCompanion(session2) {
|
|
|
239
391
|
}
|
|
240
392
|
if (tag === "broadcast-capture") {
|
|
241
393
|
try {
|
|
242
|
-
|
|
243
|
-
|
|
394
|
+
const summary = summariseBundle(
|
|
395
|
+
m
|
|
396
|
+
);
|
|
397
|
+
buffer.push(summary);
|
|
398
|
+
const note = summary.userNote ? summary.userNote.length > 60 ? `${summary.userNote.slice(0, 57)}\u2026` : summary.userNote : "(no description)";
|
|
399
|
+
const where = summary.source ? `${summary.source.file}:${summary.source.line}` : summary.target;
|
|
400
|
+
process.stderr.write(
|
|
401
|
+
`[insitue] \u{1F4E5} pick received \u2014 "${note}" @ ${where}
|
|
402
|
+
`
|
|
244
403
|
);
|
|
245
404
|
} catch (err) {
|
|
246
405
|
process.stderr.write(
|
|
@@ -260,7 +419,12 @@ function connectToCompanion(session2) {
|
|
|
260
419
|
ws.on("error", () => {
|
|
261
420
|
});
|
|
262
421
|
}
|
|
263
|
-
var
|
|
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);
|
|
264
428
|
if (!session) {
|
|
265
429
|
process.stderr.write(
|
|
266
430
|
"[insitue-mcp] no companion available \u2014 `next_pick` will time out.\n"
|
|
@@ -274,7 +438,7 @@ function ensureSubscriberAttached() {
|
|
|
274
438
|
}
|
|
275
439
|
var server = new McpServer({
|
|
276
440
|
name: "insitue",
|
|
277
|
-
version: "0.
|
|
441
|
+
version: "0.3.0"
|
|
278
442
|
});
|
|
279
443
|
server.registerTool(
|
|
280
444
|
"next_pick",
|
|
@@ -328,4 +492,185 @@ server.registerTool(
|
|
|
328
492
|
};
|
|
329
493
|
}
|
|
330
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
|
+
);
|
|
331
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.
|
|
4
|
-
"description": "Drive
|
|
3
|
+
"version": "0.4.0",
|
|
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,8 @@
|
|
|
14
14
|
".": "./dist/mcp-server.js"
|
|
15
15
|
},
|
|
16
16
|
"bin": {
|
|
17
|
-
"insitue-mcp": "./dist/mcp-server.js"
|
|
17
|
+
"insitue-mcp": "./dist/mcp-server.js",
|
|
18
|
+
"insitue": "./dist/setup-cli.js"
|
|
18
19
|
},
|
|
19
20
|
"dependencies": {
|
|
20
21
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
@@ -30,8 +31,8 @@
|
|
|
30
31
|
"access": "public"
|
|
31
32
|
},
|
|
32
33
|
"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",
|
|
34
|
+
"build": "tsup src/mcp-server.ts src/setup-cli.ts --format esm --clean --external @modelcontextprotocol/sdk --external ws --external zod",
|
|
35
|
+
"dev": "tsup src/mcp-server.ts src/setup-cli.ts --format esm --watch --external @modelcontextprotocol/sdk --external ws --external zod",
|
|
35
36
|
"typecheck": "tsc --noEmit",
|
|
36
37
|
"lint": "tsc --noEmit"
|
|
37
38
|
}
|