@insitue/claude-plugin 0.7.2 → 0.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "insitue",
3
- "version": "0.6.2",
3
+ "version": "0.6.3",
4
4
  "description": "Drive a Claude Code session from the InSitue browser overlay. Pick an element in your app, claude reads the file and proposes the edit.",
5
5
  "mcpServers": {
6
6
  "insitue": {
package/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # @insitue/claude-plugin
2
2
 
3
+ ## 0.7.3
4
+
5
+ - **Device-flow login for SSH/remote.** `/insitue:login` (+ `authenticate` tool) auto-detects an SSH session and uses a device flow: shows a verification URL + pairing code to approve in any browser; `complete_authentication` polls for the token. `login --device` forces it. Loopback stays the default locally.
6
+ - **Project-scope awareness.** Persists the token scope; cloud errors guide you to re-login for the current project when scoped elsewhere.
7
+
3
8
  ## 0.7.2
4
9
 
5
10
  - **Consistent slash commands.** Removed the duplicate MCP prompts for connect/login/logout, which Claude Code surfaced as differently-named `…insitue:<name> (MCP)` entries alongside the clean `/insitue:*` slash commands. Now there is one consistent surface: `/insitue:connect`, `/insitue:disconnect`, `/insitue:login`, `/insitue:logout` (from `commands/*.md`). On Claude Desktop, use the equivalent tools (`start_session`, `authenticate`+`complete_authentication`, `logout`).
@@ -34,7 +34,113 @@ function openBrowserUrl(url) {
34
34
  } catch {
35
35
  }
36
36
  }
37
- async function startLogin(opts) {
37
+ function isRemoteSession() {
38
+ return !!(process.env["SSH_CONNECTION"] || process.env["SSH_TTY"]);
39
+ }
40
+ async function startDeviceFlow(opts) {
41
+ const { host, label, repo, openBrowser = true } = opts;
42
+ const { verifier, challenge } = genPkce();
43
+ const startRes = await fetch(`${host}/api/v1/cli/authorize/start`, {
44
+ method: "POST",
45
+ headers: { "content-type": "application/json" },
46
+ body: JSON.stringify({
47
+ code_challenge: challenge,
48
+ code_challenge_method: "S256",
49
+ ...label ? { label } : {},
50
+ ...repo ? { repo } : {}
51
+ })
52
+ });
53
+ if (!startRes.ok) {
54
+ const text = await startRes.text().catch(() => "");
55
+ throw new Error(`authorize/start failed: HTTP ${startRes.status} ${text}`);
56
+ }
57
+ const startJson = await startRes.json();
58
+ const { request_id: requestId, user_code: userCode, expires_in } = startJson;
59
+ const verificationUrl = `${host}/cli/authorize?request_id=${encodeURIComponent(requestId)}`;
60
+ if (openBrowser) {
61
+ openBrowserUrl(verificationUrl);
62
+ }
63
+ let cancelled = false;
64
+ let cancelFn = null;
65
+ function cancel() {
66
+ cancelled = true;
67
+ if (cancelFn) cancelFn();
68
+ }
69
+ function wait() {
70
+ return new Promise((resolve, reject) => {
71
+ const timeoutMs = Math.min((expires_in ?? 600) * 1e3, 6e5);
72
+ let pollIntervalMs = 5e3;
73
+ let timer;
74
+ let settled = false;
75
+ const overallTimeout = setTimeout(() => {
76
+ if (settled) return;
77
+ settled = true;
78
+ if (timer) clearTimeout(timer);
79
+ reject(new Error("timed out waiting for device approval (10 min)"));
80
+ }, timeoutMs);
81
+ cancelFn = () => {
82
+ if (settled) return;
83
+ settled = true;
84
+ if (timer) clearTimeout(timer);
85
+ clearTimeout(overallTimeout);
86
+ reject(new Error("cancelled"));
87
+ };
88
+ const poll = async () => {
89
+ if (settled || cancelled) return;
90
+ try {
91
+ const tokenRes = await fetch(`${host}/api/v1/cli/token`, {
92
+ method: "POST",
93
+ headers: { "content-type": "application/json" },
94
+ body: JSON.stringify({
95
+ request_id: requestId,
96
+ code_verifier: verifier
97
+ })
98
+ });
99
+ if (tokenRes.ok) {
100
+ if (settled) return;
101
+ settled = true;
102
+ clearTimeout(overallTimeout);
103
+ const tokenJson = await tokenRes.json();
104
+ resolve({
105
+ token: tokenJson.token,
106
+ host: tokenJson.host,
107
+ login: tokenJson.login,
108
+ projectId: tokenJson.projectId ?? null
109
+ });
110
+ return;
111
+ }
112
+ let errBody = {};
113
+ try {
114
+ errBody = await tokenRes.json();
115
+ } catch {
116
+ }
117
+ const errCode = errBody.error ?? "";
118
+ if (errCode === "authorization_pending") {
119
+ } else if (errCode === "slow_down") {
120
+ pollIntervalMs = Math.min(pollIntervalMs + 5e3, 3e4);
121
+ } else {
122
+ if (settled) return;
123
+ settled = true;
124
+ clearTimeout(overallTimeout);
125
+ reject(
126
+ new Error(
127
+ errCode === "invalid_grant" ? "sign-in failed: token request rejected (invalid_grant). The request may have expired." : `sign-in failed: ${errCode || `HTTP ${tokenRes.status}`}`
128
+ )
129
+ );
130
+ return;
131
+ }
132
+ } catch {
133
+ }
134
+ if (!settled && !cancelled) {
135
+ timer = setTimeout(() => void poll(), pollIntervalMs);
136
+ }
137
+ };
138
+ timer = setTimeout(() => void poll(), pollIntervalMs);
139
+ });
140
+ }
141
+ return { authorizeUrl: verificationUrl, userCode, port: 0, wait, cancel };
142
+ }
143
+ async function startLoopbackFlow(opts) {
38
144
  const { host, label, repo, openBrowser = true } = opts;
39
145
  const { verifier, challenge } = genPkce();
40
146
  const state = toBase64Url(randomBytes(16));
@@ -157,6 +263,18 @@ async function startLogin(opts) {
157
263
  }
158
264
  return { authorizeUrl, userCode, port, wait, cancel };
159
265
  }
266
+ async function startLogin(opts) {
267
+ const { mode, ...rest } = opts;
268
+ const effectiveMode = (() => {
269
+ if (mode === "device") return "device";
270
+ if (mode === "loopback") return "loopback";
271
+ return isRemoteSession() ? "device" : "loopback";
272
+ })();
273
+ if (effectiveMode === "device") {
274
+ return startDeviceFlow(rest);
275
+ }
276
+ return startLoopbackFlow(rest);
277
+ }
160
278
  function parseRemoteToOwnerName(remote) {
161
279
  remote = remote.trim();
162
280
  const sshMatch = remote.match(
@@ -3,7 +3,7 @@ import {
3
3
  genPkce,
4
4
  pickProjectIdForRepo,
5
5
  startLogin
6
- } from "../chunk-RS3B7P4N.js";
6
+ } from "../chunk-BZDEEE6O.js";
7
7
  export {
8
8
  detectGitRemote,
9
9
  genPkce,
package/dist/cloud-cli.js CHANGED
@@ -16,7 +16,7 @@ import {
16
16
  detectGitRemote,
17
17
  pickProjectIdForRepo,
18
18
  startLogin
19
- } from "./chunk-RS3B7P4N.js";
19
+ } from "./chunk-BZDEEE6O.js";
20
20
 
21
21
  // src/cloud-cli.ts
22
22
  import { hostname } from "os";
@@ -55,14 +55,14 @@ async function cmdLogin(argv) {
55
55
  );
56
56
  return 0;
57
57
  }
58
+ const mode = flags.has("device") ? "device" : flags.has("loopback") ? "loopback" : "auto";
58
59
  const auth = loadAuth();
59
60
  const host = resolveHost(auth);
60
61
  const projectDir = resolveProjectDir();
61
62
  const repo = detectGitRemote(projectDir.dir);
62
- process.stdout.write("Opening your browser to sign in to InSitue\u2026\n");
63
63
  let flow;
64
64
  try {
65
- flow = await startLogin({ host, label: hostname(), ...repo ? { repo } : {} });
65
+ flow = await startLogin({ host, label: hostname(), mode, ...repo ? { repo } : {} });
66
66
  } catch (err) {
67
67
  process.stderr.write(
68
68
  `error: could not start login: ${err.message}
@@ -70,20 +70,33 @@ async function cmdLogin(argv) {
70
70
  );
71
71
  return 1;
72
72
  }
73
- process.stdout.write(
74
- `
73
+ if (mode === "device" || mode === "auto" && flow.port === 0) {
74
+ process.stdout.write(
75
+ `
76
+ Open this URL in any browser to approve:
77
+ ${flow.authorizeUrl}
78
+ Confirm the code matches: ${flow.userCode}
79
+
80
+ Waiting for approval\u2026
81
+ `
82
+ );
83
+ } else {
84
+ process.stdout.write("Opening your browser to sign in to InSitue\u2026\n");
85
+ process.stdout.write(
86
+ `
75
87
  Browser URL: ${flow.authorizeUrl}
76
88
  Pairing code (confirm your browser shows this): ${flow.userCode}
77
89
 
78
90
  Waiting for approval\u2026
79
91
  `
80
- );
92
+ );
93
+ }
81
94
  let result;
82
95
  try {
83
96
  result = await flow.wait();
84
97
  } catch (err) {
85
98
  const msg = err.message ?? String(err);
86
- if (msg === "timeout") {
99
+ if (msg === "timeout" || msg.startsWith("timed out")) {
87
100
  process.stderr.write("error: timed out waiting for browser approval\n");
88
101
  } else {
89
102
  process.stderr.write(`error: ${msg}
@@ -91,9 +104,13 @@ Waiting for approval\u2026
91
104
  }
92
105
  return 1;
93
106
  }
94
- saveAuth({ token: result.token, host: result.host, login: result.login });
107
+ saveAuth({ token: result.token, host: result.host, login: result.login, projectId: result.projectId });
95
108
  process.stdout.write(`Signed in as ${result.login}.
96
109
  `);
110
+ if (result.projectId) {
111
+ process.stdout.write(`Token scoped to project ${result.projectId}.
112
+ `);
113
+ }
97
114
  const projectDir2 = resolveProjectDir();
98
115
  let linkedProjectId = result.projectId ?? null;
99
116
  if (!linkedProjectId && repo) {
@@ -26,7 +26,7 @@ import {
26
26
  detectGitRemote,
27
27
  pickProjectIdForRepo,
28
28
  startLogin
29
- } from "./chunk-RS3B7P4N.js";
29
+ } from "./chunk-BZDEEE6O.js";
30
30
 
31
31
  // src/mcp-server.ts
32
32
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -826,10 +826,11 @@ server.registerTool(
826
826
  }
827
827
  );
828
828
  var pendingLoginFlow = null;
829
+ var pendingLoginIsDevice = false;
829
830
  server.registerTool(
830
831
  "authenticate",
831
832
  {
832
- 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.",
833
+ description: "Start an InSitue sign-in (PKCE). Auto-detects the best method: on SSH or headless environments uses device-flow (returns a URL + code for you to open in any browser); on a local machine opens your browser directly. After approving in the browser, call `complete_authentication` to finish.",
833
834
  inputSchema: {}
834
835
  },
835
836
  async () => {
@@ -839,9 +840,26 @@ server.registerTool(
839
840
  const flow = await startLogin({
840
841
  host,
841
842
  label: hostname(),
843
+ mode: "auto",
842
844
  ...repo ? { repo } : {}
843
845
  });
844
846
  pendingLoginFlow = flow;
847
+ pendingLoginIsDevice = flow.port === 0;
848
+ if (pendingLoginIsDevice) {
849
+ return {
850
+ content: [
851
+ {
852
+ type: "text",
853
+ text: JSON.stringify({
854
+ status: "device",
855
+ verificationUrl: flow.authorizeUrl,
856
+ userCode: flow.userCode,
857
+ message: "Open the URL in any browser, confirm the code, then I'll finish."
858
+ })
859
+ }
860
+ ]
861
+ };
862
+ }
845
863
  return {
846
864
  content: [
847
865
  {
@@ -894,7 +912,7 @@ server.registerTool(
894
912
  ]
895
913
  };
896
914
  }
897
- saveAuth({ token: result.token, host: result.host, login: result.login });
915
+ saveAuth({ token: result.token, host: result.host, login: result.login, projectId: result.projectId });
898
916
  let linkedProjectId = result.projectId ?? null;
899
917
  let linked = false;
900
918
  if (!linkedProjectId) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@insitue/claude-plugin",
3
- "version": "0.7.2",
3
+ "version": "0.7.3",
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",