@insitue/claude-plugin 0.5.2 → 0.6.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,11 +1,14 @@
1
1
  {
2
2
  "name": "insitue",
3
- "version": "0.4.6",
3
+ "version": "0.5.1",
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,15 @@
1
1
  # @insitue/claude-plugin
2
2
 
3
+ ## 0.6.1
4
+
5
+ - **Fix: refuse to reuse a stale companion.** `ensureCompanion` now checks a reachable companion's version via the handshake and, if it is older than the cloud-feature floor (0.7.0), tears it down and spawns the current one — instead of silently attaching to a stale companion left on the port.
6
+
7
+ ## 0.6.0
8
+
9
+ - **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.
10
+ - **CLI subcommands.** `insitue login` / `insitue link` / `insitue whoami` for terminal use.
11
+ - **Auth-aware.** `diagnose` reports authentication + linked-project state; cloud-issue errors now point at `/insitue:login`.
12
+
3
13
  ## 0.5.2
4
14
 
5
15
  - **Fix (hardcoded protocol version):** `mcp-server.ts` was sending
@@ -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,6 +32,7 @@ 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";
@@ -283,30 +291,64 @@ var PickBuffer = class {
283
291
  this.waiters.length = 0;
284
292
  }
285
293
  };
294
+ var MIN_COMPANION = "0.7.0";
295
+ function semverGte(version, floor) {
296
+ function parse(v) {
297
+ const m = /^(\d+)\.(\d+)\.(\d+)/.exec(v);
298
+ if (!m) return [0, 0, 0];
299
+ return [parseInt(m[1], 10), parseInt(m[2], 10), parseInt(m[3], 10)];
300
+ }
301
+ const [vMaj, vMin, vPat] = parse(version);
302
+ const [fMaj, fMin, fPat] = parse(floor);
303
+ if (vMaj !== fMaj) return vMaj > fMaj;
304
+ if (vMin !== fMin) return vMin > fMin;
305
+ return vPat >= fPat;
306
+ }
286
307
  async function probeCompanion(session2) {
287
308
  try {
288
309
  process.kill(session2.pid, 0);
289
310
  } catch {
290
- return false;
311
+ return null;
291
312
  }
292
313
  return new Promise((resolve) => {
314
+ let rawBody = "";
293
315
  const req = httpRequest(
294
316
  {
295
317
  host: "127.0.0.1",
296
318
  port: session2.port,
297
319
  path: "/insitue/handshake",
298
320
  method: "GET",
321
+ // Include an Origin so the handshake endpoint returns 200 + JSON
322
+ // instead of 403 (which it returns when Origin is absent). We
323
+ // use the loopback Origin — the companion's allowLocalhost flag
324
+ // accepts any localhost:* Origin, or else this falls back to 403
325
+ // which we still treat as "reachable" (non-null return).
326
+ headers: { origin: "http://localhost:5747" },
299
327
  timeout: 1500
300
328
  },
301
329
  (res) => {
302
- res.resume();
303
- resolve(true);
330
+ res.setEncoding("utf8");
331
+ res.on("data", (chunk) => {
332
+ rawBody += chunk;
333
+ });
334
+ res.on("end", () => {
335
+ if (res.statusCode === 200) {
336
+ try {
337
+ const body = JSON.parse(rawBody);
338
+ resolve(body.companionVersion ?? "0.0.0");
339
+ } catch {
340
+ resolve("0.0.0");
341
+ }
342
+ } else {
343
+ resolve("0.0.0");
344
+ }
345
+ });
304
346
  }
305
347
  );
306
- req.on("error", () => resolve(false));
348
+ req.on("error", () => resolve(null));
307
349
  req.on("timeout", () => {
308
350
  req.destroy();
309
- resolve(false);
351
+ resolve(null);
310
352
  });
311
353
  req.end();
312
354
  });
@@ -314,12 +356,30 @@ async function probeCompanion(session2) {
314
356
  var ownedChild = null;
315
357
  async function ensureCompanion(projectDir2) {
316
358
  const existing = findSession(projectDir2);
317
- if (existing && await probeCompanion(existing.session)) {
318
- process.stderr.write(
319
- `[insitue-mcp] reusing companion at :${existing.session.port} (pid ${existing.session.pid})
359
+ if (existing) {
360
+ const companionVersion = await probeCompanion(existing.session);
361
+ if (companionVersion !== null) {
362
+ if (semverGte(companionVersion, MIN_COMPANION)) {
363
+ process.stderr.write(
364
+ `[insitue-mcp] reusing companion at :${existing.session.port} (pid ${existing.session.pid}, v${companionVersion})
320
365
  `
321
- );
322
- return existing.session;
366
+ );
367
+ return existing.session;
368
+ }
369
+ process.stderr.write(
370
+ `[insitue-mcp] replacing stale companion (v${companionVersion} < ${MIN_COMPANION})
371
+ `
372
+ );
373
+ try {
374
+ process.kill(existing.session.pid);
375
+ } catch {
376
+ }
377
+ const sessionPath = join3(projectDir2, ".insitue", "session.json");
378
+ try {
379
+ rmSync(sessionPath);
380
+ } catch {
381
+ }
382
+ }
323
383
  }
324
384
  process.stderr.write(
325
385
  `[insitue-mcp] starting companion via \`npx -y @insitue/companion@latest dev\` in ${projectDir2}\u2026
@@ -733,19 +793,124 @@ server.registerTool(
733
793
  };
734
794
  }
735
795
  );
796
+ var pendingLoginFlow = null;
797
+ server.registerTool(
798
+ "authenticate",
799
+ {
800
+ 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.",
801
+ inputSchema: {}
802
+ },
803
+ async () => {
804
+ const auth = loadAuth();
805
+ const host = resolveHost(auth);
806
+ const repo = detectGitRemote(projectDir.dir);
807
+ const flow = await startLogin({
808
+ host,
809
+ label: hostname(),
810
+ ...repo ? { repo } : {}
811
+ });
812
+ pendingLoginFlow = flow;
813
+ return {
814
+ content: [
815
+ {
816
+ type: "text",
817
+ text: JSON.stringify({
818
+ status: "browser_opened",
819
+ url: flow.authorizeUrl,
820
+ userCode: flow.userCode,
821
+ message: "Approve in your browser; confirm the code matches, then call complete_authentication."
822
+ })
823
+ }
824
+ ]
825
+ };
826
+ }
827
+ );
828
+ server.registerTool(
829
+ "complete_authentication",
830
+ {
831
+ 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.",
832
+ inputSchema: {}
833
+ },
834
+ async () => {
835
+ const flow = pendingLoginFlow;
836
+ if (!flow) {
837
+ return {
838
+ content: [
839
+ {
840
+ type: "text",
841
+ text: JSON.stringify({
842
+ status: "error",
843
+ message: "No sign-in in progress. Call authenticate first."
844
+ })
845
+ }
846
+ ]
847
+ };
848
+ }
849
+ try {
850
+ let result;
851
+ try {
852
+ result = await flow.wait();
853
+ } catch (err) {
854
+ const msg = err.message ?? String(err);
855
+ const status = msg === "timeout" ? "timeout" : "error";
856
+ return {
857
+ content: [
858
+ {
859
+ type: "text",
860
+ text: JSON.stringify({ status, message: msg })
861
+ }
862
+ ]
863
+ };
864
+ }
865
+ saveAuth({ token: result.token, host: result.host, login: result.login });
866
+ let linkedProjectId = result.projectId ?? null;
867
+ let linked = false;
868
+ if (!linkedProjectId) {
869
+ const repo = detectGitRemote(projectDir.dir);
870
+ if (repo) {
871
+ const { projectId } = await pickProjectIdForRepo(
872
+ result.host,
873
+ result.token,
874
+ repo
875
+ ).catch(() => ({ projectId: null, candidates: [] }));
876
+ linkedProjectId = projectId;
877
+ }
878
+ }
879
+ if (linkedProjectId) {
880
+ saveProjectLink(projectDir.dir, linkedProjectId);
881
+ linked = true;
882
+ }
883
+ return {
884
+ content: [
885
+ {
886
+ type: "text",
887
+ text: JSON.stringify({
888
+ status: "ok",
889
+ login: result.login,
890
+ projectId: linkedProjectId,
891
+ linked
892
+ })
893
+ }
894
+ ]
895
+ };
896
+ } finally {
897
+ pendingLoginFlow = null;
898
+ }
899
+ }
900
+ );
736
901
  function cloudSetup() {
737
902
  const auth = loadAuth();
738
903
  if (!auth.token) {
739
904
  return {
740
905
  error: "not_logged_in",
741
- message: "Run `insitue login` (create a token at https://app.insitue.com/app/settings/developer) so InSitue can read this account's issues."
906
+ message: "Not signed in to InSitue. Run `/insitue:login` (Code) to sign in via browser, or `npx @insitue/claude-plugin login` in your terminal."
742
907
  };
743
908
  }
744
909
  const projectId = loadProjectId(projectDir.dir);
745
910
  if (!projectId) {
746
911
  return {
747
912
  error: "not_linked",
748
- message: "Link this repo to a cloud project: `insitue link <projectId>` (find the id in your InSitue dashboard project settings)."
913
+ 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."
749
914
  };
750
915
  }
751
916
  return { token: auth.token, host: resolveHost(auth), projectId };
@@ -941,6 +1106,32 @@ server.registerPrompt(
941
1106
  ]
942
1107
  })
943
1108
  );
1109
+ server.registerPrompt(
1110
+ "login",
1111
+ {
1112
+ title: "Sign in to InSitue",
1113
+ 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."
1114
+ },
1115
+ () => ({
1116
+ messages: [
1117
+ {
1118
+ role: "user",
1119
+ content: {
1120
+ type: "text",
1121
+ text: readPkgFile("commands/login.md") ?? loginInstructions()
1122
+ }
1123
+ }
1124
+ ]
1125
+ })
1126
+ );
1127
+ function loginInstructions() {
1128
+ return `# Sign in to InSitue
1129
+
1130
+ Call \`mcp__insitue__authenticate\` to start the browser sign-in flow.
1131
+ Show the user the returned URL and userCode, then call
1132
+ \`mcp__insitue__complete_authentication\` once they approve in the browser.
1133
+ Confirm the result with "Signed in as <login>" and linked project if any.`;
1134
+ }
944
1135
  function readPkgFile(rel) {
945
1136
  const here = dirname3(fileURLToPath2(import.meta.url));
946
1137
  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.2",
3
+ "version": "0.6.1",
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",
@@ -53,8 +53,8 @@
53
53
  "node": ">=24"
54
54
  },
55
55
  "scripts": {
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 --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 --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",
58
58
  "test": "node --test \"test/*.test.mjs\"",
59
59
  "typecheck": "tsc --noEmit",
60
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
- };