@insitue/claude-plugin 0.5.1 → 0.6.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.
@@ -1,11 +1,14 @@
1
1
  {
2
2
  "name": "insitue",
3
- "version": "0.4.6",
3
+ "version": "0.5.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": {
7
7
  "command": "npx",
8
- "args": ["-y", "@insitue/claude-plugin@latest"],
8
+ "args": [
9
+ "-y",
10
+ "@insitue/claude-plugin@latest"
11
+ ],
9
12
  "cwd": "${CLAUDE_PROJECT_DIR}"
10
13
  }
11
14
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,36 @@
1
1
  # @insitue/claude-plugin
2
2
 
3
+ ## 0.6.0
4
+
5
+ - **Sign in from Claude.** New `/insitue:login` command + `authenticate` / `complete_authentication` MCP tools open a browser, you approve, and a token is stored automatically — no manual paste.
6
+ - **CLI subcommands.** `insitue login` / `insitue link` / `insitue whoami` for terminal use.
7
+ - **Auth-aware.** `diagnose` reports authentication + linked-project state; cloud-issue errors now point at `/insitue:login`.
8
+
9
+ ## 0.5.2
10
+
11
+ - **Fix (hardcoded protocol version):** `mcp-server.ts` was sending
12
+ `protocolVersion: 5` as a literal in the WS hello handshake — the
13
+ same class of bug fixed in `@insitue/companion` 0.4.4 for the CLI
14
+ subscriber path. The literal would silently mismatch the next time
15
+ `@insitue/capture-core` bumps `PROTOCOL_VERSION`. The value is now
16
+ imported from `@insitue/capture-core` (added as a real dependency)
17
+ so there is a single source of truth. `@insitue/capture-core` is
18
+ bundled by tsup (not external) — no new runtime dep in the published
19
+ package.
20
+ - **Fix (subscriber attach race — `start_session` / `list_recent_picks`
21
+ says "connected" before it is):** `connectToCompanion` previously
22
+ returned `void` and was not awaited in `ensureSubscriberAttached`.
23
+ This meant `list_recent_picks` (and therefore the `/insitue:connect`
24
+ slash command) could return "No picks buffered yet" before the
25
+ companion had added this MCP server to its `subscribers` set — so
26
+ the browser launcher badge stayed dark for a moment after claude
27
+ claimed to be listening. `connectToCompanion` now returns a
28
+ `Promise<boolean>` that resolves `true` when `subscribe-ok` arrives
29
+ (subscriber set joined, badge lit), or `false` on pre-subscribe
30
+ close. `ensureSubscriberAttached` awaits it and resets `attached` on
31
+ failure so the next explicit call retries cleanly.
32
+ - Bumped 0.5.1 → 0.5.2.
33
+
3
34
  ## 0.5.1
4
35
 
5
36
  - **Docs:** the operating instructions (`commands/connect.md`) now document
@@ -142,10 +142,14 @@ both can be used in the same session.
142
142
  it locally).
143
143
 
144
144
  **Prerequisites.** These tools require:
145
- - `insitue login` (creates `~/.insitue/auth.json` generate a
146
- token at https://app.insitue.com/app/settings/developer)
147
- - `insitue link <projectId>` run in this repo (writes
148
- `.insitue/project.json` find the id in your project settings)
145
+ - Signing in to InSitue Cloud — run `/insitue:login` (Code) for a
146
+ browser-based sign-in (no token paste needed), or manually generate
147
+ a token at https://app.insitue.com/app/settings/developer and set it
148
+ with `npx @insitue/claude-plugin login --token <pat>`
149
+ - Linking this repo to a cloud project — `/insitue:login` auto-links
150
+ when it can match by git remote; otherwise run
151
+ `npx @insitue/claude-plugin link <projectId>` (find the id in your
152
+ project settings)
149
153
  - A paid InSitue Cloud plan
150
154
 
151
155
  If any tool returns `error: "not_logged_in"`, `"not_linked"`, or
@@ -0,0 +1,54 @@
1
+ ---
2
+ description: Sign in to InSitue Cloud via browser — no token paste required. Auto-links this repo to its cloud project.
3
+ ---
4
+
5
+ # /insitue:login
6
+
7
+ Signs you in to InSitue Cloud using a secure browser-based flow (PKCE).
8
+ No token paste required — approve in your browser and you're done.
9
+ Cloud issues will become available via the claude-plugin tools once signed in.
10
+
11
+ ## Your behaviour
12
+
13
+ 1. Call `mcp__insitue__authenticate` (no arguments).
14
+
15
+ 2. The tool returns `{ status: "browser_opened", url, userCode, message }`.
16
+ Show the user:
17
+
18
+ > I've opened your browser to sign in to InSitue. Please:
19
+ > 1. Complete the sign-in on the consent page.
20
+ > 2. Confirm the page shows the pairing code: **`<userCode>`**
21
+ >
22
+ > (If your browser didn't open, visit this URL manually: `<url>`)
23
+
24
+ Do NOT proceed to step 3 until the user says they've approved or you
25
+ detect they're ready. Wait for the user to confirm.
26
+
27
+ 3. Call `mcp__insitue__complete_authentication` (no arguments).
28
+ This waits up to 5 minutes for the browser approval.
29
+
30
+ 4. On `{ status: "ok" }`:
31
+ - If `linked: true`: reply "Signed in as **@`<login>`** and linked to
32
+ project `<projectId>`. Cloud issues are now available — call
33
+ `/insitue:connect` or `list_cloud_issues` to get started."
34
+ - If `linked: false` and `projectId` is null: reply "Signed in as
35
+ **@`<login>`**. To link this repo to a cloud project run:
36
+ `npx @insitue/claude-plugin link <projectId>` (find the project id in
37
+ your InSitue dashboard settings)."
38
+
39
+ 5. On `{ status: "timeout" }`: tell the user the browser sign-in timed out
40
+ and they can try again by running `/insitue:login`.
41
+
42
+ 6. On `{ status: "error" }`: relay the `message` field and suggest running
43
+ `/insitue:login` again.
44
+
45
+ ## Notes
46
+
47
+ - The pairing `userCode` is a visual confirmation only — the user does NOT
48
+ type it anywhere. It just proves the browser consent page corresponds to
49
+ THIS sign-in request.
50
+ - Never call `complete_authentication` more than once per `authenticate` call.
51
+ If the user needs to retry, run `/insitue:login` from the start.
52
+ - Cloud issue tools (`list_cloud_issues`, `claim_cloud_issue`, etc.) require
53
+ both a signed-in account AND a linked project. This command handles both
54
+ steps where possible.
@@ -0,0 +1,59 @@
1
+ // src/cloud/config.ts
2
+ import { homedir } from "os";
3
+ import { join } from "path";
4
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
5
+ function loadAuth() {
6
+ const p = join(homedir(), ".insitue", "auth.json");
7
+ if (!existsSync(p)) return {};
8
+ try {
9
+ return JSON.parse(readFileSync(p, "utf8"));
10
+ } catch {
11
+ return {};
12
+ }
13
+ }
14
+ function loadProjectId(projectDir) {
15
+ const p = join(projectDir, ".insitue", "project.json");
16
+ if (!existsSync(p)) return null;
17
+ try {
18
+ return JSON.parse(readFileSync(p, "utf8")).projectId ?? null;
19
+ } catch {
20
+ return null;
21
+ }
22
+ }
23
+ function resolveHost(cfg) {
24
+ return process.env["INSITUE_API_HOST"] ?? cfg.host ?? "https://app.insitue.com";
25
+ }
26
+ function saveAuth(patch) {
27
+ const dir = join(homedir(), ".insitue");
28
+ const p = join(dir, "auth.json");
29
+ mkdirSync(dir, { recursive: true });
30
+ let existing = {};
31
+ if (existsSync(p)) {
32
+ try {
33
+ existing = JSON.parse(readFileSync(p, "utf8"));
34
+ } catch {
35
+ }
36
+ }
37
+ const merged = { ...existing, ...patch };
38
+ writeFileSync(p, JSON.stringify(merged, null, 2) + "\n", {
39
+ encoding: "utf8",
40
+ mode: 384
41
+ });
42
+ }
43
+ function saveProjectLink(projectDir, projectId) {
44
+ const dir = join(projectDir, ".insitue");
45
+ mkdirSync(dir, { recursive: true });
46
+ writeFileSync(
47
+ join(dir, "project.json"),
48
+ JSON.stringify({ projectId }, null, 2) + "\n",
49
+ { encoding: "utf8" }
50
+ );
51
+ }
52
+
53
+ export {
54
+ loadAuth,
55
+ loadProjectId,
56
+ resolveHost,
57
+ saveAuth,
58
+ saveProjectLink
59
+ };
@@ -1,3 +1,8 @@
1
+ import {
2
+ loadAuth,
3
+ loadProjectId
4
+ } from "./chunk-B3HSTDGI.js";
5
+
1
6
  // src/diagnose.ts
2
7
  import { existsSync, readdirSync, readFileSync, statSync } from "fs";
3
8
  import { request as httpRequest } from "http";
@@ -126,7 +131,21 @@ async function diagnose(projectDir) {
126
131
  "@insitue/swc-source-attr"
127
132
  );
128
133
  const swcPluginConfigured = detectSwcPluginConfigured(projectDir.dir);
134
+ const auth = loadAuth();
135
+ const authenticated = Boolean(auth.token);
136
+ const login = auth.login ?? null;
137
+ const linkedProjectId = loadProjectId(projectDir.dir);
129
138
  const recommendations = [];
139
+ if (!authenticated) {
140
+ recommendations.push(
141
+ "Not authenticated \u2014 run `/insitue:login` (Code) or `npx @insitue/claude-plugin login` to sign in to InSitue Cloud."
142
+ );
143
+ }
144
+ if (authenticated && !linkedProjectId) {
145
+ recommendations.push(
146
+ "Signed in but no project linked \u2014 run `npx @insitue/claude-plugin link` to link this repo to a cloud project."
147
+ );
148
+ }
130
149
  if (!sdkVersion) {
131
150
  recommendations.push(
132
151
  "`@insitue/sdk` not installed in the project \u2014 `pnpm add -D @insitue/sdk`"
@@ -160,6 +179,9 @@ async function diagnose(projectDir) {
160
179
  sdkVersion,
161
180
  swcPluginVersion,
162
181
  swcPluginConfigured,
182
+ authenticated,
183
+ login,
184
+ linkedProjectId,
163
185
  recommendations
164
186
  };
165
187
  }
@@ -0,0 +1,211 @@
1
+ // src/cloud/login.ts
2
+ import { createHash, randomBytes, timingSafeEqual } from "crypto";
3
+ import { createServer } from "http";
4
+ import { spawn } from "child_process";
5
+ import { platform } from "os";
6
+ import { execFileSync } from "child_process";
7
+ function toBase64Url(buf) {
8
+ return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
9
+ }
10
+ function genPkce() {
11
+ const verifier = toBase64Url(randomBytes(32));
12
+ const challenge = toBase64Url(
13
+ createHash("sha256").update(verifier).digest()
14
+ );
15
+ return { verifier, challenge };
16
+ }
17
+ var SUCCESS_HTML = `<!DOCTYPE html>
18
+ <html lang="en">
19
+ <head><meta charset="utf-8"><title>InSitue \u2014 signed in</title>
20
+ <style>body{font-family:system-ui,sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;background:#f9fafb;}
21
+ .card{background:#fff;border-radius:12px;padding:40px 48px;text-align:center;box-shadow:0 1px 4px rgba(0,0,0,.12);}
22
+ h1{font-size:1.4rem;margin:0 0 8px;}p{color:#6b7280;margin:0;}</style></head>
23
+ <body><div class="card"><h1>You're signed in to InSitue</h1>
24
+ <p>You can close this tab and return to your terminal.</p></div></body></html>`;
25
+ var ERROR_HTML = `<!DOCTYPE html>
26
+ <html lang="en">
27
+ <head><meta charset="utf-8"><title>InSitue \u2014 error</title></head>
28
+ <body><p>Authorization failed. You may close this tab.</p></body></html>`;
29
+ function openBrowserUrl(url) {
30
+ try {
31
+ const p = platform();
32
+ const [cmd, ...args] = p === "darwin" ? ["open", url] : p === "win32" ? ["cmd", "/c", `start "" "${url}"`] : ["xdg-open", url];
33
+ spawn(cmd, [...args], { detached: true, stdio: "ignore" }).unref();
34
+ } catch {
35
+ }
36
+ }
37
+ async function startLogin(opts) {
38
+ const { host, label, repo, openBrowser = true } = opts;
39
+ const { verifier, challenge } = genPkce();
40
+ const state = toBase64Url(randomBytes(16));
41
+ let resolveCode;
42
+ let rejectCode;
43
+ const codePromise = new Promise((res, rej) => {
44
+ resolveCode = res;
45
+ rejectCode = rej;
46
+ });
47
+ const server = createServer((req, res) => {
48
+ const raw = req.url ?? "";
49
+ const u = new URL(raw, "http://127.0.0.1");
50
+ if (u.pathname !== "/callback") {
51
+ res.writeHead(404).end();
52
+ return;
53
+ }
54
+ const errorParam = u.searchParams.get("error");
55
+ if (errorParam) {
56
+ res.writeHead(200, { "content-type": "text/html" }).end(ERROR_HTML);
57
+ server.close();
58
+ rejectCode(new Error(`authorization_denied: ${errorParam}`));
59
+ return;
60
+ }
61
+ const incomingState = u.searchParams.get("state") ?? "";
62
+ let stateOk = false;
63
+ try {
64
+ const a = Buffer.from(incomingState);
65
+ const b = Buffer.from(state);
66
+ stateOk = a.length === b.length && timingSafeEqual(a, b);
67
+ } catch {
68
+ stateOk = false;
69
+ }
70
+ if (!stateOk) {
71
+ res.writeHead(400, { "content-type": "text/plain" }).end("state mismatch");
72
+ server.close();
73
+ rejectCode(new Error("state mismatch"));
74
+ return;
75
+ }
76
+ const code = u.searchParams.get("code") ?? "";
77
+ res.writeHead(200, { "content-type": "text/html" }).end(SUCCESS_HTML);
78
+ server.close();
79
+ resolveCode(code);
80
+ });
81
+ await new Promise((res, rej) => {
82
+ server.on("error", rej);
83
+ server.listen(0, "127.0.0.1", () => res());
84
+ });
85
+ const addr = server.address();
86
+ if (!addr || typeof addr === "string") {
87
+ throw new Error("Failed to bind loopback server");
88
+ }
89
+ const port = addr.port;
90
+ const redirectUri = `http://127.0.0.1:${port}/callback`;
91
+ const startRes = await fetch(`${host}/api/v1/cli/authorize/start`, {
92
+ method: "POST",
93
+ headers: { "content-type": "application/json" },
94
+ body: JSON.stringify({
95
+ code_challenge: challenge,
96
+ code_challenge_method: "S256",
97
+ redirect_uri: redirectUri,
98
+ ...label ? { label } : {},
99
+ ...repo ? { repo } : {}
100
+ })
101
+ });
102
+ if (!startRes.ok) {
103
+ server.close();
104
+ const text = await startRes.text().catch(() => "");
105
+ throw new Error(
106
+ `authorize/start failed: HTTP ${startRes.status} ${text}`
107
+ );
108
+ }
109
+ const startJson = await startRes.json();
110
+ const { request_id: requestId, user_code: userCode } = startJson;
111
+ const authorizeUrl = `${host}/cli/authorize?request_id=${encodeURIComponent(requestId)}&state=${encodeURIComponent(state)}`;
112
+ if (openBrowser) {
113
+ openBrowserUrl(authorizeUrl);
114
+ }
115
+ function cancel() {
116
+ try {
117
+ server.close();
118
+ } catch {
119
+ }
120
+ rejectCode(new Error("cancelled"));
121
+ }
122
+ async function wait() {
123
+ let timeoutHandle;
124
+ try {
125
+ const code = await Promise.race([
126
+ codePromise,
127
+ new Promise((_, rej) => {
128
+ timeoutHandle = setTimeout(
129
+ () => rej(new Error("timeout")),
130
+ 3e5
131
+ );
132
+ })
133
+ ]);
134
+ clearTimeout(timeoutHandle);
135
+ const tokenRes = await fetch(`${host}/api/v1/cli/token`, {
136
+ method: "POST",
137
+ headers: { "content-type": "application/json" },
138
+ body: JSON.stringify({ code, code_verifier: verifier, redirect_uri: redirectUri })
139
+ });
140
+ if (!tokenRes.ok) {
141
+ throw new Error("invalid_grant");
142
+ }
143
+ const tokenJson = await tokenRes.json();
144
+ return {
145
+ token: tokenJson.token,
146
+ host: tokenJson.host,
147
+ login: tokenJson.login,
148
+ projectId: tokenJson.projectId ?? null
149
+ };
150
+ } finally {
151
+ clearTimeout(timeoutHandle);
152
+ try {
153
+ server.close();
154
+ } catch {
155
+ }
156
+ }
157
+ }
158
+ return { authorizeUrl, userCode, port, wait, cancel };
159
+ }
160
+ function parseRemoteToOwnerName(remote) {
161
+ remote = remote.trim();
162
+ const sshMatch = remote.match(
163
+ /^git@[^:]+:([A-Za-z0-9._-]+\/[A-Za-z0-9._-]+?)(?:\.git)?$/
164
+ );
165
+ if (sshMatch) return sshMatch[1];
166
+ try {
167
+ const u = new URL(remote);
168
+ const parts = u.pathname.replace(/^\//, "").replace(/\.git$/, "").split("/");
169
+ if (parts.length >= 2 && parts[0] && parts[1]) {
170
+ return `${parts[0]}/${parts[1]}`;
171
+ }
172
+ } catch {
173
+ }
174
+ return null;
175
+ }
176
+ function detectGitRemote(cwd) {
177
+ try {
178
+ const out = execFileSync("git", ["-C", cwd, "remote", "get-url", "origin"], {
179
+ encoding: "utf8",
180
+ timeout: 5e3,
181
+ stdio: ["ignore", "pipe", "ignore"]
182
+ });
183
+ return parseRemoteToOwnerName(out);
184
+ } catch {
185
+ return null;
186
+ }
187
+ }
188
+ async function pickProjectIdForRepo(host, token, repo) {
189
+ const res = await fetch(`${host}/api/v1/dev/projects`, {
190
+ headers: { authorization: `Bearer ${token}` }
191
+ });
192
+ if (!res.ok) {
193
+ return { projectId: null, candidates: [] };
194
+ }
195
+ const json = await res.json();
196
+ const projects = json.projects ?? [];
197
+ const matches = projects.filter(
198
+ (p) => p.repo && p.repo.toLowerCase() === repo.toLowerCase()
199
+ );
200
+ return {
201
+ projectId: matches.length === 1 ? matches[0].id : null,
202
+ candidates: projects
203
+ };
204
+ }
205
+
206
+ export {
207
+ genPkce,
208
+ startLogin,
209
+ detectGitRemote,
210
+ pickProjectIdForRepo
211
+ };
@@ -1,10 +1,14 @@
1
1
  import {
2
2
  loadAuth,
3
3
  loadProjectId,
4
- resolveHost
5
- } from "../chunk-5APYM634.js";
4
+ resolveHost,
5
+ saveAuth,
6
+ saveProjectLink
7
+ } from "../chunk-B3HSTDGI.js";
6
8
  export {
7
9
  loadAuth,
8
10
  loadProjectId,
9
- resolveHost
11
+ resolveHost,
12
+ saveAuth,
13
+ saveProjectLink
10
14
  };
@@ -0,0 +1,12 @@
1
+ import {
2
+ detectGitRemote,
3
+ genPkce,
4
+ pickProjectIdForRepo,
5
+ startLogin
6
+ } from "../chunk-RS3B7P4N.js";
7
+ export {
8
+ detectGitRemote,
9
+ genPkce,
10
+ pickProjectIdForRepo,
11
+ startLogin
12
+ };
@@ -0,0 +1,204 @@
1
+ import {
2
+ resolveProjectDir
3
+ } from "./chunk-UNMH2DN4.js";
4
+ import {
5
+ loadAuth,
6
+ loadProjectId,
7
+ resolveHost,
8
+ saveAuth,
9
+ saveProjectLink
10
+ } from "./chunk-B3HSTDGI.js";
11
+ import {
12
+ detectGitRemote,
13
+ pickProjectIdForRepo,
14
+ startLogin
15
+ } from "./chunk-RS3B7P4N.js";
16
+
17
+ // src/cloud-cli.ts
18
+ import { hostname } from "os";
19
+ import { resolve } from "path";
20
+ function parseArgs(argv) {
21
+ const positional = [];
22
+ const flags = /* @__PURE__ */ new Map();
23
+ for (let i = 0; i < argv.length; i++) {
24
+ const a = argv[i];
25
+ if (!a.startsWith("--")) {
26
+ positional.push(a);
27
+ continue;
28
+ }
29
+ const eq = a.indexOf("=");
30
+ if (eq !== -1) {
31
+ flags.set(a.slice(2, eq), a.slice(eq + 1));
32
+ } else if (i + 1 < argv.length && !argv[i + 1].startsWith("--")) {
33
+ flags.set(a.slice(2), argv[++i]);
34
+ } else {
35
+ flags.set(a.slice(2), true);
36
+ }
37
+ }
38
+ return { positional, flags };
39
+ }
40
+ async function cmdLogin(argv) {
41
+ const { flags } = parseArgs(argv);
42
+ const tokenFlag = flags.get("token");
43
+ if (typeof tokenFlag === "string") {
44
+ const hostFlag = flags.get("host");
45
+ const auth2 = loadAuth();
46
+ const host2 = typeof hostFlag === "string" ? hostFlag : resolveHost(auth2);
47
+ saveAuth({ token: tokenFlag, host: host2 });
48
+ process.stdout.write(
49
+ `Saved token to ~/.insitue/auth.json (host: ${host2})
50
+ `
51
+ );
52
+ return 0;
53
+ }
54
+ const auth = loadAuth();
55
+ const host = resolveHost(auth);
56
+ const projectDir = resolveProjectDir();
57
+ const repo = detectGitRemote(projectDir.dir);
58
+ process.stdout.write("Opening your browser to sign in to InSitue\u2026\n");
59
+ let flow;
60
+ try {
61
+ flow = await startLogin({ host, label: hostname(), ...repo ? { repo } : {} });
62
+ } catch (err) {
63
+ process.stderr.write(
64
+ `error: could not start login: ${err.message}
65
+ `
66
+ );
67
+ return 1;
68
+ }
69
+ process.stdout.write(
70
+ `
71
+ Browser URL: ${flow.authorizeUrl}
72
+ Pairing code (confirm your browser shows this): ${flow.userCode}
73
+
74
+ Waiting for approval\u2026
75
+ `
76
+ );
77
+ let result;
78
+ try {
79
+ result = await flow.wait();
80
+ } catch (err) {
81
+ const msg = err.message ?? String(err);
82
+ if (msg === "timeout") {
83
+ process.stderr.write("error: timed out waiting for browser approval\n");
84
+ } else {
85
+ process.stderr.write(`error: ${msg}
86
+ `);
87
+ }
88
+ return 1;
89
+ }
90
+ saveAuth({ token: result.token, host: result.host, login: result.login });
91
+ process.stdout.write(`Signed in as ${result.login}.
92
+ `);
93
+ const projectDir2 = resolveProjectDir();
94
+ let linkedProjectId = result.projectId ?? null;
95
+ if (!linkedProjectId && repo) {
96
+ const { projectId } = await pickProjectIdForRepo(
97
+ result.host,
98
+ result.token,
99
+ repo
100
+ ).catch(() => ({ projectId: null, candidates: [] }));
101
+ linkedProjectId = projectId;
102
+ }
103
+ if (linkedProjectId) {
104
+ saveProjectLink(projectDir2.dir, linkedProjectId);
105
+ process.stdout.write(`Linked to project ${linkedProjectId}.
106
+ `);
107
+ } else {
108
+ process.stdout.write(
109
+ "Could not auto-link a project. Run `npx @insitue/claude-plugin link <projectId>` manually.\n"
110
+ );
111
+ }
112
+ return 0;
113
+ }
114
+ async function cmdLink(argv) {
115
+ const { positional, flags } = parseArgs(argv);
116
+ const projectFlag = flags.get("project");
117
+ const projectDir = resolveProjectDir(
118
+ typeof projectFlag === "string" ? ["--project-dir", resolve(projectFlag)] : []
119
+ );
120
+ const auth = loadAuth();
121
+ if (!auth.token) {
122
+ process.stderr.write(
123
+ "error: not signed in \u2014 run `npx @insitue/claude-plugin login` first\n"
124
+ );
125
+ return 1;
126
+ }
127
+ const host = resolveHost(auth);
128
+ const explicitId = positional[0];
129
+ if (explicitId) {
130
+ saveProjectLink(projectDir.dir, explicitId);
131
+ process.stdout.write(`Linked to project ${explicitId}.
132
+ `);
133
+ return 0;
134
+ }
135
+ const repo = detectGitRemote(projectDir.dir);
136
+ if (!repo) {
137
+ process.stderr.write(
138
+ "error: could not detect git remote origin. Pass a projectId: `npx @insitue/claude-plugin link <projectId>`\n"
139
+ );
140
+ return 1;
141
+ }
142
+ process.stdout.write(`Detecting project for repo ${repo}\u2026
143
+ `);
144
+ let projectId;
145
+ let candidates;
146
+ try {
147
+ ({ projectId, candidates } = await pickProjectIdForRepo(
148
+ host,
149
+ auth.token,
150
+ repo
151
+ ));
152
+ } catch (err) {
153
+ process.stderr.write(`error: ${err.message}
154
+ `);
155
+ return 1;
156
+ }
157
+ if (projectId) {
158
+ saveProjectLink(projectDir.dir, projectId);
159
+ process.stdout.write(`Linked to project ${projectId}.
160
+ `);
161
+ return 0;
162
+ }
163
+ if (candidates.length === 0) {
164
+ process.stderr.write(
165
+ `No projects found for this account. Visit https://app.insitue.com to create one.
166
+ `
167
+ );
168
+ } else {
169
+ process.stdout.write(
170
+ `No project matched repo "${repo}". Available projects:
171
+ `
172
+ );
173
+ for (const p of candidates) {
174
+ process.stdout.write(` ${p.id} ${p.name}${p.repo ? ` (${p.repo})` : ""}
175
+ `);
176
+ }
177
+ process.stdout.write(
178
+ `
179
+ Run: npx @insitue/claude-plugin link <projectId>
180
+ `
181
+ );
182
+ }
183
+ return 1;
184
+ }
185
+ function cmdWhoami() {
186
+ const auth = loadAuth();
187
+ const projectDir = resolveProjectDir();
188
+ const projectId = loadProjectId(projectDir.dir);
189
+ if (!auth.token) {
190
+ process.stdout.write("Not signed in.\n");
191
+ return 1;
192
+ }
193
+ process.stdout.write(
194
+ `Signed in as ${auth.login ?? "(unknown login)"} host: ${resolveHost(auth)}
195
+ Project: ${projectId ?? "(not linked)"}
196
+ `
197
+ );
198
+ return 0;
199
+ }
200
+ export {
201
+ cmdLink,
202
+ cmdLogin,
203
+ cmdWhoami
204
+ };
package/dist/diagnose.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  diagnose
3
- } from "./chunk-SGLSPTHD.js";
3
+ } from "./chunk-KGRPDRYH.js";
4
+ import "./chunk-B3HSTDGI.js";
4
5
  export {
5
6
  diagnose
6
7
  };
@@ -1,10 +1,29 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/dispatcher.ts
4
- var SUBCOMMANDS = /* @__PURE__ */ new Set(["setup", "diagnose", "help", "--help", "-h"]);
4
+ var CLI_SUBCOMMANDS = /* @__PURE__ */ new Set(["setup", "diagnose", "help", "--help", "-h"]);
5
+ var CLOUD_SUBCOMMANDS = /* @__PURE__ */ new Set(["login", "link", "whoami"]);
5
6
  var first = process.argv[2];
6
- if (first && SUBCOMMANDS.has(first)) {
7
+ if (first && CLI_SUBCOMMANDS.has(first)) {
7
8
  await import("./setup-cli.js");
9
+ } else if (first && CLOUD_SUBCOMMANDS.has(first)) {
10
+ const { cmdLogin, cmdLink, cmdWhoami } = await import("./cloud-cli.js");
11
+ const rest = process.argv.slice(3);
12
+ let code;
13
+ switch (first) {
14
+ case "login":
15
+ code = await cmdLogin(rest);
16
+ break;
17
+ case "link":
18
+ code = await cmdLink(rest);
19
+ break;
20
+ case "whoami":
21
+ code = cmdWhoami();
22
+ break;
23
+ default:
24
+ code = 0;
25
+ }
26
+ process.exit(code);
8
27
  } else {
9
28
  await import("./mcp-server.js");
10
29
  }
@@ -5,7 +5,7 @@ import {
5
5
  } from "./chunk-UNMH2DN4.js";
6
6
  import {
7
7
  diagnose
8
- } from "./chunk-SGLSPTHD.js";
8
+ } from "./chunk-KGRPDRYH.js";
9
9
  import {
10
10
  CloudApiError,
11
11
  claimIssue,
@@ -16,8 +16,15 @@ import {
16
16
  import {
17
17
  loadAuth,
18
18
  loadProjectId,
19
- resolveHost
20
- } from "./chunk-5APYM634.js";
19
+ resolveHost,
20
+ saveAuth,
21
+ saveProjectLink
22
+ } from "./chunk-B3HSTDGI.js";
23
+ import {
24
+ detectGitRemote,
25
+ pickProjectIdForRepo,
26
+ startLogin
27
+ } from "./chunk-RS3B7P4N.js";
21
28
 
22
29
  // src/mcp-server.ts
23
30
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -25,10 +32,12 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
25
32
  import { spawn } from "child_process";
26
33
  import { existsSync as existsSync2, readFileSync as readFileSync3, rmSync } from "fs";
27
34
  import { request as httpRequest } from "http";
35
+ import { hostname } from "os";
28
36
  import { dirname as dirname3, join as join3 } from "path";
29
37
  import { fileURLToPath as fileURLToPath2 } from "url";
30
38
  import WebSocket from "ws";
31
39
  import { z } from "zod";
40
+ import { PROTOCOL_VERSION } from "@insitue/capture-core";
32
41
 
33
42
  // src/file-tools.ts
34
43
  import {
@@ -388,68 +397,81 @@ var activeWs = null;
388
397
  var reconnectTimer = null;
389
398
  var disconnecting = false;
390
399
  function connectToCompanion(s) {
391
- const url = `ws://127.0.0.1:${s.port}/insitue/cli`;
392
- const ws = new WebSocket(url, {
393
- headers: { "user-agent": "insitue-claude-plugin" }
394
- });
395
- activeWs = ws;
396
- ws.on("open", () => {
397
- ws.send(
398
- JSON.stringify({
399
- t: "hello",
400
- // Pin to the companion's pinned protocol version. Bump
401
- // when the wire format breaks.
402
- protocolVersion: 5,
403
- token: s.token
404
- })
405
- );
406
- });
407
- ws.on("message", (data) => {
408
- let m;
409
- try {
410
- m = JSON.parse(String(data));
411
- } catch {
412
- return;
413
- }
414
- if (m && typeof m === "object") {
415
- const tag = m.t;
416
- if (tag === "hello-ok") {
417
- ws.send(JSON.stringify({ t: "subscribe" }));
400
+ return new Promise((subscribeResolve) => {
401
+ let subscribed = false;
402
+ const url = `ws://127.0.0.1:${s.port}/insitue/cli`;
403
+ const ws = new WebSocket(url, {
404
+ headers: { "user-agent": "insitue-claude-plugin" }
405
+ });
406
+ activeWs = ws;
407
+ ws.on("open", () => {
408
+ ws.send(
409
+ JSON.stringify({
410
+ t: "hello",
411
+ // Imported from @insitue/capture-core — the single source of
412
+ // truth for the wire protocol version. A hardcoded literal here
413
+ // would silently drift the moment the capture-core version is
414
+ // bumped (same class of bug fixed in companion 0.4.4).
415
+ protocolVersion: PROTOCOL_VERSION,
416
+ token: s.token
417
+ })
418
+ );
419
+ });
420
+ ws.on("message", (data) => {
421
+ let m;
422
+ try {
423
+ m = JSON.parse(String(data));
424
+ } catch {
418
425
  return;
419
426
  }
420
- if (tag === "broadcast-capture") {
421
- try {
422
- const summary = summariseBundle(
423
- m
424
- );
425
- buffer.push(summary);
426
- const note = summary.userNote ? summary.userNote.length > 60 ? `${summary.userNote.slice(0, 57)}\u2026` : summary.userNote : "(no description)";
427
- const where = summary.source ? `${summary.source.file}:${summary.source.line}` : summary.target;
428
- process.stderr.write(
429
- `[insitue] \u{1F4E5} pick received \u2014 "${note}" @ ${where}
427
+ if (m && typeof m === "object") {
428
+ const tag = m.t;
429
+ if (tag === "hello-ok") {
430
+ ws.send(JSON.stringify({ t: "subscribe" }));
431
+ return;
432
+ }
433
+ if (tag === "subscribe-ok") {
434
+ if (!subscribed) {
435
+ subscribed = true;
436
+ subscribeResolve(true);
437
+ }
438
+ return;
439
+ }
440
+ if (tag === "broadcast-capture") {
441
+ try {
442
+ const summary = summariseBundle(
443
+ m
444
+ );
445
+ buffer.push(summary);
446
+ const note = summary.userNote ? summary.userNote.length > 60 ? `${summary.userNote.slice(0, 57)}\u2026` : summary.userNote : "(no description)";
447
+ const where = summary.source ? `${summary.source.file}:${summary.source.line}` : summary.target;
448
+ process.stderr.write(
449
+ `[insitue] \u{1F4E5} pick received \u2014 "${note}" @ ${where}
430
450
  `
431
- );
432
- } catch (err) {
433
- process.stderr.write(
434
- `[insitue-mcp] dropped malformed pick: ${err.message}
451
+ );
452
+ } catch (err) {
453
+ process.stderr.write(
454
+ `[insitue-mcp] dropped malformed pick: ${err.message}
435
455
  `
436
- );
456
+ );
457
+ }
458
+ return;
437
459
  }
438
- return;
460
+ if (tag === "broadcast-ask") return;
439
461
  }
440
- if (tag === "broadcast-ask") return;
441
- }
442
- });
443
- ws.on("close", () => {
444
- if (activeWs === ws) activeWs = null;
445
- buffer.dropWaiters();
446
- if (disconnecting) return;
447
- process.stderr.write(
448
- "[insitue-mcp] companion link dropped \u2014 reconnecting in 2s\n"
449
- );
450
- reconnectTimer = setTimeout(() => connectToCompanion(s), 2e3);
451
- });
452
- ws.on("error", () => {
462
+ });
463
+ ws.on("close", () => {
464
+ if (!subscribed) subscribeResolve(false);
465
+ if (activeWs === ws) activeWs = null;
466
+ buffer.dropWaiters();
467
+ if (disconnecting) return;
468
+ process.stderr.write(
469
+ "[insitue-mcp] companion link dropped \u2014 reconnecting in 2s\n"
470
+ );
471
+ reconnectTimer = setTimeout(() => void connectToCompanion(s), 2e3);
472
+ });
473
+ ws.on("error", () => {
474
+ });
453
475
  });
454
476
  }
455
477
  var projectDir = resolveProjectDir();
@@ -473,7 +495,13 @@ async function ensureSubscriberAttached(opts = {}) {
473
495
  if (!session) return;
474
496
  }
475
497
  attached = true;
476
- connectToCompanion(session);
498
+ const ok = await connectToCompanion(session);
499
+ if (!ok) {
500
+ attached = false;
501
+ process.stderr.write(
502
+ "[insitue-mcp] subscriber attach failed \u2014 companion handshake did not complete\n"
503
+ );
504
+ }
477
505
  }
478
506
  function endSession() {
479
507
  disconnecting = true;
@@ -713,19 +741,124 @@ server.registerTool(
713
741
  };
714
742
  }
715
743
  );
744
+ var pendingLoginFlow = null;
745
+ server.registerTool(
746
+ "authenticate",
747
+ {
748
+ description: "Start a browser-based InSitue sign-in (PKCE). Opens your browser to the InSitue consent page and returns a `userCode` to visually confirm. After approving in the browser, call `complete_authentication` to finish.",
749
+ inputSchema: {}
750
+ },
751
+ async () => {
752
+ const auth = loadAuth();
753
+ const host = resolveHost(auth);
754
+ const repo = detectGitRemote(projectDir.dir);
755
+ const flow = await startLogin({
756
+ host,
757
+ label: hostname(),
758
+ ...repo ? { repo } : {}
759
+ });
760
+ pendingLoginFlow = flow;
761
+ return {
762
+ content: [
763
+ {
764
+ type: "text",
765
+ text: JSON.stringify({
766
+ status: "browser_opened",
767
+ url: flow.authorizeUrl,
768
+ userCode: flow.userCode,
769
+ message: "Approve in your browser; confirm the code matches, then call complete_authentication."
770
+ })
771
+ }
772
+ ]
773
+ };
774
+ }
775
+ );
776
+ server.registerTool(
777
+ "complete_authentication",
778
+ {
779
+ description: "Complete the in-progress InSitue sign-in started by `authenticate`. Waits for the browser approval (up to 5 minutes), saves credentials, and auto-links this repo to its cloud project when possible.",
780
+ inputSchema: {}
781
+ },
782
+ async () => {
783
+ const flow = pendingLoginFlow;
784
+ if (!flow) {
785
+ return {
786
+ content: [
787
+ {
788
+ type: "text",
789
+ text: JSON.stringify({
790
+ status: "error",
791
+ message: "No sign-in in progress. Call authenticate first."
792
+ })
793
+ }
794
+ ]
795
+ };
796
+ }
797
+ try {
798
+ let result;
799
+ try {
800
+ result = await flow.wait();
801
+ } catch (err) {
802
+ const msg = err.message ?? String(err);
803
+ const status = msg === "timeout" ? "timeout" : "error";
804
+ return {
805
+ content: [
806
+ {
807
+ type: "text",
808
+ text: JSON.stringify({ status, message: msg })
809
+ }
810
+ ]
811
+ };
812
+ }
813
+ saveAuth({ token: result.token, host: result.host, login: result.login });
814
+ let linkedProjectId = result.projectId ?? null;
815
+ let linked = false;
816
+ if (!linkedProjectId) {
817
+ const repo = detectGitRemote(projectDir.dir);
818
+ if (repo) {
819
+ const { projectId } = await pickProjectIdForRepo(
820
+ result.host,
821
+ result.token,
822
+ repo
823
+ ).catch(() => ({ projectId: null, candidates: [] }));
824
+ linkedProjectId = projectId;
825
+ }
826
+ }
827
+ if (linkedProjectId) {
828
+ saveProjectLink(projectDir.dir, linkedProjectId);
829
+ linked = true;
830
+ }
831
+ return {
832
+ content: [
833
+ {
834
+ type: "text",
835
+ text: JSON.stringify({
836
+ status: "ok",
837
+ login: result.login,
838
+ projectId: linkedProjectId,
839
+ linked
840
+ })
841
+ }
842
+ ]
843
+ };
844
+ } finally {
845
+ pendingLoginFlow = null;
846
+ }
847
+ }
848
+ );
716
849
  function cloudSetup() {
717
850
  const auth = loadAuth();
718
851
  if (!auth.token) {
719
852
  return {
720
853
  error: "not_logged_in",
721
- message: "Run `insitue login` (create a token at https://app.insitue.com/app/settings/developer) so InSitue can read this account's issues."
854
+ message: "Not signed in to InSitue. Run `/insitue:login` (Code) to sign in via browser, or `npx @insitue/claude-plugin login` in your terminal."
722
855
  };
723
856
  }
724
857
  const projectId = loadProjectId(projectDir.dir);
725
858
  if (!projectId) {
726
859
  return {
727
860
  error: "not_linked",
728
- message: "Link this repo to a cloud project: `insitue link <projectId>` (find the id in your InSitue dashboard project settings)."
861
+ message: "This repo is not linked to an InSitue cloud project. Run `/insitue:login` (Code) to sign in and auto-link, or `npx @insitue/claude-plugin link <projectId>` in your terminal."
729
862
  };
730
863
  }
731
864
  return { token: auth.token, host: resolveHost(auth), projectId };
@@ -921,6 +1054,32 @@ server.registerPrompt(
921
1054
  ]
922
1055
  })
923
1056
  );
1057
+ server.registerPrompt(
1058
+ "login",
1059
+ {
1060
+ title: "Sign in to InSitue",
1061
+ description: "Browser-based sign-in to InSitue Cloud via PKCE. Opens your browser, confirms a pairing code, then saves credentials and auto-links the repo."
1062
+ },
1063
+ () => ({
1064
+ messages: [
1065
+ {
1066
+ role: "user",
1067
+ content: {
1068
+ type: "text",
1069
+ text: readPkgFile("commands/login.md") ?? loginInstructions()
1070
+ }
1071
+ }
1072
+ ]
1073
+ })
1074
+ );
1075
+ function loginInstructions() {
1076
+ return `# Sign in to InSitue
1077
+
1078
+ Call \`mcp__insitue__authenticate\` to start the browser sign-in flow.
1079
+ Show the user the returned URL and userCode, then call
1080
+ \`mcp__insitue__complete_authentication\` once they approve in the browser.
1081
+ Confirm the result with "Signed in as <login>" and linked project if any.`;
1082
+ }
924
1083
  function readPkgFile(rel) {
925
1084
  const here = dirname3(fileURLToPath2(import.meta.url));
926
1085
  for (const base of [join3(here, ".."), here]) {
package/dist/setup-cli.js CHANGED
@@ -4,7 +4,8 @@ import {
4
4
  } from "./chunk-UNMH2DN4.js";
5
5
  import {
6
6
  diagnose
7
- } from "./chunk-SGLSPTHD.js";
7
+ } from "./chunk-KGRPDRYH.js";
8
+ import "./chunk-B3HSTDGI.js";
8
9
 
9
10
  // src/setup-cli.ts
10
11
  import {
@@ -189,6 +190,8 @@ async function cmdDiagnose(flags2) {
189
190
  "InSitue diagnostics",
190
191
  "\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500",
191
192
  `Project: ${report.projectDir.dir} (via ${report.projectDir.source})`,
193
+ `Auth: ${tick(report.authenticated)} ${report.authenticated ? `signed in as ${report.login ?? "(unknown)"}` : "not signed in"}`,
194
+ `Project ID: ${report.linkedProjectId ?? "(not linked)"}`,
192
195
  `Session: ${tick(report.hasSessionFile)} .insitue/session.json ${report.hasSessionFile ? "exists" : "missing"}`,
193
196
  `Companion: ${tick(report.companionReachable)} ${report.companionReachable ? `reachable on port ${report.companionPort}` : "not reachable"}`,
194
197
  `@insitue/sdk: ${report.sdkVersion ?? "(not installed)"}`,
@@ -206,7 +209,7 @@ async function cmdDiagnose(flags2) {
206
209
  }
207
210
  function cmdHelp() {
208
211
  process.stdout.write(
209
- "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"
212
+ "Usage: npx @insitue/claude-plugin <command> [flags]\n\nCommands:\n login Sign in to InSitue Cloud via browser (PKCE)\n link Link this repo to an InSitue cloud project\n whoami Show current auth + linked project\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 (login):\n --token=PAT Use a pre-created token instead of browser sign-in\n --host=URL Override the InSitue host (default: https://app.insitue.com)\n\nFlags (link):\n [projectId] Project id to link (auto-detected from git remote if omitted)\n --project=PATH Project directory (default: walk-up from cwd)\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"
210
213
  );
211
214
  return 0;
212
215
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@insitue/claude-plugin",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
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
  "keywords": [
6
6
  "insitue",
@@ -38,7 +38,8 @@
38
38
  "dependencies": {
39
39
  "@modelcontextprotocol/sdk": "^1.29.0",
40
40
  "ws": "^8.18.0",
41
- "zod": "^3.23.8"
41
+ "zod": "^3.23.8",
42
+ "@insitue/capture-core": "0.4.1"
42
43
  },
43
44
  "devDependencies": {
44
45
  "@types/ws": "^8.5.13",
@@ -52,8 +53,8 @@
52
53
  "node": ">=24"
53
54
  },
54
55
  "scripts": {
55
- "build": "tsup src/dispatcher.ts src/mcp-server.ts src/setup-cli.ts src/diagnose.ts src/cloud/api.ts src/cloud/config.ts --format esm --clean --external @modelcontextprotocol/sdk --external ws --external zod",
56
- "dev": "tsup src/dispatcher.ts src/mcp-server.ts src/setup-cli.ts src/diagnose.ts src/cloud/api.ts src/cloud/config.ts --format esm --watch --external @modelcontextprotocol/sdk --external ws --external zod",
56
+ "build": "tsup src/dispatcher.ts src/mcp-server.ts src/setup-cli.ts src/diagnose.ts src/cloud/api.ts src/cloud/config.ts src/cloud/login.ts src/cloud-cli.ts --format esm --clean --external @modelcontextprotocol/sdk --external ws --external zod",
57
+ "dev": "tsup src/dispatcher.ts src/mcp-server.ts src/setup-cli.ts src/diagnose.ts src/cloud/api.ts src/cloud/config.ts src/cloud/login.ts src/cloud-cli.ts --format esm --watch --external @modelcontextprotocol/sdk --external ws --external zod",
57
58
  "test": "node --test \"test/*.test.mjs\"",
58
59
  "typecheck": "tsc --noEmit",
59
60
  "lint": "tsc --noEmit"
@@ -1,31 +0,0 @@
1
- // src/cloud/config.ts
2
- import { homedir } from "os";
3
- import { join } from "path";
4
- import { existsSync, readFileSync } from "fs";
5
- function loadAuth() {
6
- const p = join(homedir(), ".insitue", "auth.json");
7
- if (!existsSync(p)) return {};
8
- try {
9
- return JSON.parse(readFileSync(p, "utf8"));
10
- } catch {
11
- return {};
12
- }
13
- }
14
- function loadProjectId(projectDir) {
15
- const p = join(projectDir, ".insitue", "project.json");
16
- if (!existsSync(p)) return null;
17
- try {
18
- return JSON.parse(readFileSync(p, "utf8")).projectId ?? null;
19
- } catch {
20
- return null;
21
- }
22
- }
23
- function resolveHost(cfg) {
24
- return process.env["INSITUE_API_HOST"] ?? cfg.host ?? "https://app.insitue.com";
25
- }
26
-
27
- export {
28
- loadAuth,
29
- loadProjectId,
30
- resolveHost
31
- };