@zvndev/circular-mcp 0.1.2 → 0.1.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.
- package/README.md +30 -10
- package/bin/circular-mcp.mjs +4 -4
- package/lib/server.mjs +1 -1
- package/lib/vendor/client.mjs +119 -29
- package/lib/vendor/config.mjs +71 -20
- package/lib/vendor/connections.mjs +149 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -84,9 +84,25 @@ pulling at the same moment can pick another candidate if a reservation loses.
|
|
|
84
84
|
|
|
85
85
|
## Authentication
|
|
86
86
|
|
|
87
|
-
Identical to the CLI.
|
|
88
|
-
|
|
89
|
-
|
|
87
|
+
Identical to the CLI. The normal path is managed browser sign-in:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
npx -y @zvndev/circular-cli login --runtime codex
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
That command opens Circular, asks the signed-in human to approve a named agent,
|
|
94
|
+
then stores the managed connection locally in `~/.circular/connections.json`.
|
|
95
|
+
No API key has to be copied into an agent prompt or MCP config.
|
|
96
|
+
|
|
97
|
+
For long-running MCP processes, pin the connection id returned by `login`:
|
|
98
|
+
|
|
99
|
+
```
|
|
100
|
+
CIRCULAR_CONNECTION_ID=key_or_connection_id_from_login
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Owners/admins can still provide an explicit **team API key** (`circ_tk_…`) for
|
|
104
|
+
ongoing workspace agents or legacy automation. MCP clients inject these through
|
|
105
|
+
the server's `env` block:
|
|
90
106
|
|
|
91
107
|
```
|
|
92
108
|
CIRCULAR_API_KEY=circ_tk_xxx
|
|
@@ -95,7 +111,9 @@ CIRCULAR_TEAM_ID=team_xxx
|
|
|
95
111
|
CIRCULAR_BASE_URL=https://gocircular.dev # optional; default
|
|
96
112
|
```
|
|
97
113
|
|
|
98
|
-
`~/.circular/config.json` also works as a fallback.
|
|
114
|
+
`~/.circular/config.json` also works as a fallback for explicit keys. An
|
|
115
|
+
explicit key always wins over managed login; a Desktop-bound agent identity does
|
|
116
|
+
not borrow a managed or global fallback credential.
|
|
99
117
|
|
|
100
118
|
### Minting a team API key
|
|
101
119
|
|
|
@@ -113,9 +131,7 @@ below with `node /ABS/PATH/mcp/bin/circular-mcp.mjs`.
|
|
|
113
131
|
|
|
114
132
|
```bash
|
|
115
133
|
claude mcp add circular \
|
|
116
|
-
-e
|
|
117
|
-
-e CIRCULAR_WORKSPACE_ID=ws_xxx \
|
|
118
|
-
-e CIRCULAR_TEAM_ID=team_xxx \
|
|
134
|
+
-e CIRCULAR_CONNECTION_ID=key_or_connection_id_from_login \
|
|
119
135
|
-- npx -y @zvndev/circular-mcp
|
|
120
136
|
```
|
|
121
137
|
|
|
@@ -131,9 +147,7 @@ Via the CLI:
|
|
|
131
147
|
|
|
132
148
|
```bash
|
|
133
149
|
codex mcp add circular \
|
|
134
|
-
--env
|
|
135
|
-
--env CIRCULAR_WORKSPACE_ID=ws_xxx \
|
|
136
|
-
--env CIRCULAR_TEAM_ID=team_xxx \
|
|
150
|
+
--env CIRCULAR_CONNECTION_ID=key_or_connection_id_from_login \
|
|
137
151
|
-- npx -y @zvndev/circular-mcp
|
|
138
152
|
```
|
|
139
153
|
|
|
@@ -146,6 +160,12 @@ args = ["-y", "@zvndev/circular-mcp"]
|
|
|
146
160
|
env = { CIRCULAR_API_KEY = "circ_tk_xxx", CIRCULAR_WORKSPACE_ID = "ws_xxx", CIRCULAR_TEAM_ID = "team_xxx" }
|
|
147
161
|
```
|
|
148
162
|
|
|
163
|
+
For a managed connection, use:
|
|
164
|
+
|
|
165
|
+
```toml
|
|
166
|
+
env = { CIRCULAR_CONNECTION_ID = "key_or_connection_id_from_login" }
|
|
167
|
+
```
|
|
168
|
+
|
|
149
169
|
Verify with `codex mcp list` / `codex mcp get circular`.
|
|
150
170
|
|
|
151
171
|
### Cursor
|
package/bin/circular-mcp.mjs
CHANGED
|
@@ -10,10 +10,10 @@
|
|
|
10
10
|
* Transport: MCP stdio (newline-delimited JSON-RPC 2.0). Protocol messages go on
|
|
11
11
|
* stdin/stdout; everything human-facing goes to stderr.
|
|
12
12
|
*
|
|
13
|
-
* Auth is identical to the CLI:
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
13
|
+
* Auth is identical to the CLI: a managed browser login from
|
|
14
|
+
* ~/.circular/connections.json, optionally pinned with CIRCULAR_CONNECTION_ID,
|
|
15
|
+
* or an explicit CIRCULAR_API_KEY plus workspace/team ids for legacy/service
|
|
16
|
+
* agents.
|
|
17
17
|
*/
|
|
18
18
|
import { loadConfig } from "../lib/vendor/config.mjs";
|
|
19
19
|
import { dispatch, SERVER_INFO } from "../lib/server.mjs";
|
package/lib/server.mjs
CHANGED
|
@@ -21,7 +21,7 @@ const SUPPORTED_PROTOCOL_VERSIONS = new Set([
|
|
|
21
21
|
"2024-11-05",
|
|
22
22
|
]);
|
|
23
23
|
|
|
24
|
-
export const SERVER_INFO = { name: "circular-mcp", version: "0.1.
|
|
24
|
+
export const SERVER_INFO = { name: "circular-mcp", version: "0.1.3" };
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
27
|
* The briefing every client sees on connect. This is the only onboarding an
|
package/lib/vendor/client.mjs
CHANGED
|
@@ -1,28 +1,12 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* VENDORED from cli/lib/client.mjs. Do not edit here.
|
|
3
|
-
*
|
|
4
|
-
* The MCP server is published as a standalone package and advertises itself as
|
|
5
|
-
* dependency-free, which is the whole point of `npx -y @zvndev/circular-mcp`:
|
|
6
|
-
* nothing to resolve, nothing to install. It used to import this straight out
|
|
7
|
-
* of `../../cli/lib`, a path that exists in the repo and in no published
|
|
8
|
-
* tarball, so `npm publish` would have produced a package that crashed on its
|
|
9
|
-
* first import.
|
|
10
|
-
*
|
|
11
|
-
* Everything below this header is a byte-for-byte copy of the CLI's file, and
|
|
12
|
-
* test/package.test.mjs fails if the two ever drift.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* Thin HTTP client for the Circular Agent API. Authenticates with the team API
|
|
17
|
-
* key via the Authorization: Bearer header. Returns parsed JSON; throws an
|
|
18
|
-
* ApiError (with status + body) on non-2xx so the CLI can print + exit non-zero.
|
|
19
|
-
*/
|
|
20
1
|
/**
|
|
21
2
|
* Thin HTTP client for the Circular Agent API. Authenticates with the team API
|
|
22
3
|
* key via the Authorization: Bearer header. Returns parsed JSON; throws an
|
|
23
4
|
* ApiError (with status + body) on non-2xx so the CLI can print + exit non-zero.
|
|
24
5
|
*/
|
|
25
6
|
import { readConfigFile } from "./config.mjs";
|
|
7
|
+
import { readConnectionsFile, selectConnection } from "./connections.mjs";
|
|
8
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
|
|
9
|
+
|
|
26
10
|
export class ApiError extends Error {
|
|
27
11
|
constructor(message, status, body) {
|
|
28
12
|
super(message);
|
|
@@ -33,12 +17,18 @@ export class ApiError extends Error {
|
|
|
33
17
|
}
|
|
34
18
|
|
|
35
19
|
export function teamBase(config) {
|
|
36
|
-
if (!config.apiKey) throw new Error("
|
|
20
|
+
if (!config.apiKey) throw new Error("Not signed in. Run `circular login` or set CIRCULAR_API_KEY.");
|
|
37
21
|
if (!config.workspaceId) throw new Error("Missing workspace. Set CIRCULAR_WORKSPACE_ID or --workspace.");
|
|
38
22
|
if (!config.teamId) throw new Error("Missing team. Set CIRCULAR_TEAM_ID or --team.");
|
|
39
23
|
return `${config.baseUrl}/api/workspaces/${config.workspaceId}/teams/${config.teamId}`;
|
|
40
24
|
}
|
|
41
25
|
|
|
26
|
+
export function workspaceBase(config) {
|
|
27
|
+
if (!config.apiKey) throw new Error("Not signed in. Run `circular login` or set CIRCULAR_API_KEY.");
|
|
28
|
+
if (!config.workspaceId) throw new Error("Missing workspace. Set CIRCULAR_WORKSPACE_ID or --workspace.");
|
|
29
|
+
return `${config.baseUrl}/api/workspaces/${config.workspaceId}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
42
32
|
/**
|
|
43
33
|
* The key on disk, if it is not the one that was just refused.
|
|
44
34
|
*
|
|
@@ -68,14 +58,45 @@ function rotatedKey(usedKey, readFile = readConfigFile) {
|
|
|
68
58
|
}
|
|
69
59
|
|
|
70
60
|
async function send(config, method, url, body) {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
61
|
+
const controller = new AbortController();
|
|
62
|
+
const timeout = setTimeout(() => controller.abort(), config.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS);
|
|
63
|
+
try {
|
|
64
|
+
return await fetch(url, {
|
|
65
|
+
method,
|
|
66
|
+
redirect: "manual",
|
|
67
|
+
signal: controller.signal,
|
|
68
|
+
headers: {
|
|
69
|
+
Authorization: `Bearer ${config.apiKey}`,
|
|
70
|
+
...(body ? { "Content-Type": "application/json" } : {}),
|
|
71
|
+
},
|
|
72
|
+
...(body ? { body: JSON.stringify(body) } : {}),
|
|
73
|
+
});
|
|
74
|
+
} finally {
|
|
75
|
+
clearTimeout(timeout);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function refreshManagedConnection(config, readStore = readConnectionsFile) {
|
|
80
|
+
if (!config.managedConnectionId) return config;
|
|
81
|
+
const store = readStore();
|
|
82
|
+
const connection = selectConnection(store, config.managedConnectionId);
|
|
83
|
+
if (!connection) {
|
|
84
|
+
throw new Error(`Circular connection ${config.managedConnectionId} is not saved locally. The MCP process is pinned to this connection and will not fall back to another account.`);
|
|
85
|
+
}
|
|
86
|
+
if (stripTrailingSlash(connection.baseUrl) !== stripTrailingSlash(config.baseUrl)) {
|
|
87
|
+
throw new Error(`Circular connection ${config.managedConnectionId} no longer matches the pinned API origin.`);
|
|
88
|
+
}
|
|
89
|
+
if (connection.workspaceId !== config.workspaceId) {
|
|
90
|
+
throw new Error(`Circular connection ${config.managedConnectionId} no longer matches the pinned workspace.`);
|
|
91
|
+
}
|
|
92
|
+
if (config.agentParticipantId && connection.agentParticipantId !== config.agentParticipantId) {
|
|
93
|
+
throw new Error(`Circular connection ${config.managedConnectionId} no longer matches the pinned agent identity.`);
|
|
94
|
+
}
|
|
95
|
+
if (config.authorizingUserId && connection.authorizingUserId !== config.authorizingUserId) {
|
|
96
|
+
throw new Error(`Circular connection ${config.managedConnectionId} no longer matches the pinned authorizing user.`);
|
|
97
|
+
}
|
|
98
|
+
config.apiKey = connection.token;
|
|
99
|
+
return config;
|
|
79
100
|
}
|
|
80
101
|
|
|
81
102
|
/**
|
|
@@ -89,7 +110,9 @@ export async function apiRequest(
|
|
|
89
110
|
path,
|
|
90
111
|
{ query, body } = {},
|
|
91
112
|
readFile = readConfigFile,
|
|
113
|
+
readConnectionStore = readConnectionsFile,
|
|
92
114
|
) {
|
|
115
|
+
refreshManagedConnection(config, readConnectionStore);
|
|
93
116
|
const url = new URL(`${teamBase(config)}${path}`);
|
|
94
117
|
if (query) {
|
|
95
118
|
for (const [key, value] of Object.entries(query)) {
|
|
@@ -105,7 +128,7 @@ export async function apiRequest(
|
|
|
105
128
|
// The config object is mutated so the session uses the live key afterward:
|
|
106
129
|
// recovering one request and leaving the next twenty to fail would be worse
|
|
107
130
|
// than not recovering at all, because the failure would look intermittent.
|
|
108
|
-
if (response.status === 401 && !config.agentParticipantId) {
|
|
131
|
+
if (response.status === 401 && !config.agentParticipantId && !config.managedConnectionId) {
|
|
109
132
|
const rotated = rotatedKey(config.apiKey, readFile);
|
|
110
133
|
if (rotated) {
|
|
111
134
|
config.apiKey = rotated;
|
|
@@ -129,6 +152,13 @@ export async function apiRequest(
|
|
|
129
152
|
parsed,
|
|
130
153
|
);
|
|
131
154
|
}
|
|
155
|
+
if (response.status === 401 && config.managedConnectionId) {
|
|
156
|
+
throw new ApiError(
|
|
157
|
+
`${method} ${path} failed (401): This managed Circular connection expired or was revoked. Run \`circular login\` again and update CIRCULAR_CONNECTION_ID to the new connection id; no fallback API key was used.`,
|
|
158
|
+
response.status,
|
|
159
|
+
parsed,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
132
162
|
const detail =
|
|
133
163
|
parsed && typeof parsed === "object" && parsed.error ? parsed.error : response.statusText;
|
|
134
164
|
throw new ApiError(`${method} ${path} failed (${response.status}): ${detail}`, response.status, parsed);
|
|
@@ -136,3 +166,63 @@ export async function apiRequest(
|
|
|
136
166
|
|
|
137
167
|
return parsed;
|
|
138
168
|
}
|
|
169
|
+
|
|
170
|
+
export async function workspaceRequest(
|
|
171
|
+
config,
|
|
172
|
+
method,
|
|
173
|
+
path,
|
|
174
|
+
{ query, body } = {},
|
|
175
|
+
readFile = readConfigFile,
|
|
176
|
+
readConnectionStore = readConnectionsFile,
|
|
177
|
+
) {
|
|
178
|
+
refreshManagedConnection(config, readConnectionStore);
|
|
179
|
+
const url = new URL(`${workspaceBase(config)}${path}`);
|
|
180
|
+
if (query) {
|
|
181
|
+
for (const [key, value] of Object.entries(query)) {
|
|
182
|
+
if (value !== undefined && value !== null) url.searchParams.set(key, String(value));
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
let response = await send(config, method, url, body);
|
|
187
|
+
if (response.status === 401 && !config.agentParticipantId && !config.managedConnectionId) {
|
|
188
|
+
const rotated = rotatedKey(config.apiKey, readFile);
|
|
189
|
+
if (rotated) {
|
|
190
|
+
config.apiKey = rotated;
|
|
191
|
+
response = await send(config, method, url, body);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const text = await response.text();
|
|
196
|
+
let parsed;
|
|
197
|
+
try {
|
|
198
|
+
parsed = text ? JSON.parse(text) : null;
|
|
199
|
+
} catch {
|
|
200
|
+
parsed = text;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (!response.ok) {
|
|
204
|
+
if (response.status === 401 && config.agentParticipantId) {
|
|
205
|
+
throw new ApiError(
|
|
206
|
+
`${method} ${path} failed (401): This agent session credential expired or was revoked. Reconnect the session in Circular to authorize the same agent again; the global account key was not used.`,
|
|
207
|
+
response.status,
|
|
208
|
+
parsed,
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
if (response.status === 401 && config.managedConnectionId) {
|
|
212
|
+
throw new ApiError(
|
|
213
|
+
`${method} ${path} failed (401): This managed Circular connection expired or was revoked. Run \`circular login\` again and update CIRCULAR_CONNECTION_ID to the new connection id; no fallback API key was used.`,
|
|
214
|
+
response.status,
|
|
215
|
+
parsed,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
const detail =
|
|
219
|
+
parsed && typeof parsed === "object" && parsed.error ? parsed.error : response.statusText;
|
|
220
|
+
throw new ApiError(`${method} ${path} failed (${response.status}): ${detail}`, response.status, parsed);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return parsed;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function stripTrailingSlash(url) {
|
|
227
|
+
return typeof url === "string" ? url.replace(/\/+$/, "") : url;
|
|
228
|
+
}
|
package/lib/vendor/config.mjs
CHANGED
|
@@ -1,17 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* VENDORED from cli/lib/config.mjs. Do not edit here.
|
|
3
|
-
*
|
|
4
|
-
* The MCP server is published as a standalone package and advertises itself as
|
|
5
|
-
* dependency-free, which is the whole point of `npx -y @zvndev/circular-mcp`:
|
|
6
|
-
* nothing to resolve, nothing to install. It used to import this straight out
|
|
7
|
-
* of `../../cli/lib`, a path that exists in the repo and in no published
|
|
8
|
-
* tarball, so `npm publish` would have produced a package that crashed on its
|
|
9
|
-
* first import.
|
|
10
|
-
*
|
|
11
|
-
* Everything below this header is a byte-for-byte copy of the CLI's file, and
|
|
12
|
-
* test/package.test.mjs fails if the two ever drift.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
1
|
/**
|
|
16
2
|
* Config resolution for the Circular CLI.
|
|
17
3
|
*
|
|
@@ -22,6 +8,7 @@
|
|
|
22
8
|
import { readFileSync } from "node:fs";
|
|
23
9
|
import { homedir } from "node:os";
|
|
24
10
|
import { join } from "node:path";
|
|
11
|
+
import { findConnection, hasConnectionId, readConnectionsFile, tryReadConnectionsFile } from "./connections.mjs";
|
|
25
12
|
|
|
26
13
|
export const DEFAULT_BASE_URL = "https://gocircular.dev";
|
|
27
14
|
|
|
@@ -41,7 +28,7 @@ export function readConfigFile(path = configFilePath()) {
|
|
|
41
28
|
* Merge flags, env, and file into the effective config. `flags` are the parsed
|
|
42
29
|
* CLI flags; `env` defaults to process.env; `file` is the parsed config file.
|
|
43
30
|
*/
|
|
44
|
-
export function resolveConfig(flags = {}, env = process.env, file = {}) {
|
|
31
|
+
export function resolveConfig(flags = {}, env = process.env, file = {}, connectionStore = {}) {
|
|
45
32
|
const agentParticipantId = env.CIRCULAR_AGENT_PARTICIPANT_ID?.trim();
|
|
46
33
|
const authorizingUserId = env.CIRCULAR_AUTHORIZING_USER_ID?.trim();
|
|
47
34
|
const pick = (flagKey, envKey, fileKey, fallback) => {
|
|
@@ -51,21 +38,85 @@ export function resolveConfig(flags = {}, env = process.env, file = {}) {
|
|
|
51
38
|
return fallback;
|
|
52
39
|
};
|
|
53
40
|
|
|
41
|
+
const explicitRuntimeApiKey = flags["api-key"] !== undefined && flags["api-key"] !== true
|
|
42
|
+
? flags["api-key"]
|
|
43
|
+
: env.CIRCULAR_API_KEY || undefined;
|
|
44
|
+
const legacyFileApiKey = !agentParticipantId ? file.apiKey : undefined;
|
|
45
|
+
const requestedBaseUrlOverride = flags["base-url"] !== undefined && flags["base-url"] !== true
|
|
46
|
+
? flags["base-url"]
|
|
47
|
+
: env.CIRCULAR_BASE_URL || undefined;
|
|
48
|
+
const requestedWorkspaceOverride = flags.workspace !== undefined && flags.workspace !== true
|
|
49
|
+
? flags.workspace
|
|
50
|
+
: env.CIRCULAR_WORKSPACE_ID || undefined;
|
|
51
|
+
const requestedTeamOverride = flags.team !== undefined && flags.team !== true
|
|
52
|
+
? flags.team
|
|
53
|
+
: env.CIRCULAR_TEAM_ID || undefined;
|
|
54
|
+
const requestedBaseUrl = stripTrailingSlash(
|
|
55
|
+
pick("base-url", "CIRCULAR_BASE_URL", "baseUrl", undefined)
|
|
56
|
+
);
|
|
57
|
+
const requestedWorkspaceId = pick("workspace", "CIRCULAR_WORKSPACE_ID", "workspaceId", undefined);
|
|
58
|
+
const requestedTeamId = pick("team", "CIRCULAR_TEAM_ID", "teamId", undefined);
|
|
59
|
+
const connectionId = flags.connection !== undefined && flags.connection !== true
|
|
60
|
+
? flags.connection
|
|
61
|
+
: env.CIRCULAR_CONNECTION_ID || file.connectionId || undefined;
|
|
62
|
+
const managedConnection = !explicitRuntimeApiKey && !agentParticipantId
|
|
63
|
+
? findConnection(connectionStore, {
|
|
64
|
+
id: connectionId,
|
|
65
|
+
baseUrl: stripTrailingSlash(requestedBaseUrlOverride),
|
|
66
|
+
workspaceId: requestedWorkspaceOverride,
|
|
67
|
+
})
|
|
68
|
+
: null;
|
|
69
|
+
|
|
70
|
+
const hasManagedConnectionFilter = Boolean(requestedBaseUrlOverride || requestedWorkspaceOverride);
|
|
71
|
+
const hasSavedConnections = Array.isArray(connectionStore?.connections) && connectionStore.connections.length > 0;
|
|
72
|
+
if ((connectionId || (hasManagedConnectionFilter && hasSavedConnections)) && !managedConnection && !explicitRuntimeApiKey && !agentParticipantId) {
|
|
73
|
+
if (!connectionId) {
|
|
74
|
+
throw new Error("No saved Circular connection matches the requested origin/workspace/team. Run circular status or circular login.");
|
|
75
|
+
}
|
|
76
|
+
const reason = hasConnectionId(connectionStore, connectionId)
|
|
77
|
+
? "but it does not match the requested origin/workspace/team"
|
|
78
|
+
: "and it is not saved locally";
|
|
79
|
+
throw new Error(`Circular connection ${connectionId} was requested ${reason}. Run circular status or circular login.`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (managedConnection) {
|
|
83
|
+
return {
|
|
84
|
+
apiKey: managedConnection.token,
|
|
85
|
+
baseUrl: stripTrailingSlash(managedConnection.baseUrl),
|
|
86
|
+
workspaceId: managedConnection.workspaceId,
|
|
87
|
+
teamId: requestedTeamOverride ?? managedConnection.teamId,
|
|
88
|
+
managedConnectionId: managedConnection.id,
|
|
89
|
+
...(managedConnection.agentParticipantId ? { agentParticipantId: managedConnection.agentParticipantId } : {}),
|
|
90
|
+
...(managedConnection.authorizingUserId ? { authorizingUserId: managedConnection.authorizingUserId } : {}),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
54
94
|
return {
|
|
55
95
|
// A bound session cannot borrow the operator's global credential, including
|
|
56
96
|
// when its delegated key is missing rather than rejected by the server.
|
|
57
|
-
apiKey:
|
|
97
|
+
apiKey: explicitRuntimeApiKey ?? legacyFileApiKey,
|
|
58
98
|
baseUrl: stripTrailingSlash(
|
|
59
99
|
pick("base-url", "CIRCULAR_BASE_URL", "baseUrl", DEFAULT_BASE_URL)
|
|
60
100
|
),
|
|
61
|
-
workspaceId:
|
|
62
|
-
teamId:
|
|
101
|
+
workspaceId: requestedWorkspaceId,
|
|
102
|
+
teamId: requestedTeamId,
|
|
63
103
|
...(agentParticipantId ? { agentParticipantId, ...(authorizingUserId ? { authorizingUserId } : {}) } : {}),
|
|
64
104
|
};
|
|
65
105
|
}
|
|
66
106
|
|
|
67
|
-
export function loadConfig(flags = {}) {
|
|
68
|
-
|
|
107
|
+
export function loadConfig(flags = {}, options = {}) {
|
|
108
|
+
const file = readConfigFile();
|
|
109
|
+
if (options.allowMissingManagedConnection) {
|
|
110
|
+
return resolveConfig(flags, process.env, file, { version: 1, currentConnectionId: null, connections: [] });
|
|
111
|
+
}
|
|
112
|
+
const hasExplicitRuntimeApiKey = flags["api-key"] !== undefined && flags["api-key"] !== true
|
|
113
|
+
? true
|
|
114
|
+
: Boolean(process.env.CIRCULAR_API_KEY);
|
|
115
|
+
const hasBoundDesktopIdentity = Boolean(process.env.CIRCULAR_AGENT_PARTICIPANT_ID?.trim());
|
|
116
|
+
const connectionStore = hasExplicitRuntimeApiKey || hasBoundDesktopIdentity
|
|
117
|
+
? tryReadConnectionsFile()
|
|
118
|
+
: readConnectionsFile();
|
|
119
|
+
return resolveConfig(flags, process.env, file, connectionStore);
|
|
69
120
|
}
|
|
70
121
|
|
|
71
122
|
function stripTrailingSlash(url) {
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync, chmodSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
|
|
5
|
+
export function connectionsFilePath() {
|
|
6
|
+
if (process.env.CIRCULAR_CONNECTIONS_FILE) return process.env.CIRCULAR_CONNECTIONS_FILE;
|
|
7
|
+
return join(homedir(), ".circular", "connections.json");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function isTrustedCircularOrigin(value) {
|
|
11
|
+
try {
|
|
12
|
+
const url = new URL(value);
|
|
13
|
+
if (url.protocol === "https:") return true;
|
|
14
|
+
return url.protocol === "http:" && ["localhost", "127.0.0.1", "::1"].includes(url.hostname);
|
|
15
|
+
} catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function readConnectionsFile(path = connectionsFilePath()) {
|
|
21
|
+
let parsed;
|
|
22
|
+
try {
|
|
23
|
+
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
24
|
+
} catch (error) {
|
|
25
|
+
if (error && typeof error === "object" && error.code === "ENOENT") {
|
|
26
|
+
return normalizeStore({});
|
|
27
|
+
}
|
|
28
|
+
throw new Error(`Could not read Circular connections file at ${path}. Fix or move the corrupt file before writing a new managed connection.`);
|
|
29
|
+
}
|
|
30
|
+
assertValidStore(parsed, path);
|
|
31
|
+
return normalizeStore(parsed);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function tryReadConnectionsFile(path = connectionsFilePath()) {
|
|
35
|
+
try {
|
|
36
|
+
return readConnectionsFile(path);
|
|
37
|
+
} catch {
|
|
38
|
+
return normalizeStore({});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function writeConnectionsFile(store, path = connectionsFilePath()) {
|
|
43
|
+
assertValidStore(store, path);
|
|
44
|
+
const normalized = normalizeStore(store);
|
|
45
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
46
|
+
const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
47
|
+
writeFileSync(tmp, `${JSON.stringify(normalized, null, 2)}\n`, { mode: 0o600 });
|
|
48
|
+
chmodSync(tmp, 0o600);
|
|
49
|
+
renameSync(tmp, path);
|
|
50
|
+
try {
|
|
51
|
+
chmodSync(path, 0o600);
|
|
52
|
+
} catch {
|
|
53
|
+
// Best effort on non-POSIX filesystems.
|
|
54
|
+
}
|
|
55
|
+
return normalized;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function normalizeStore(store) {
|
|
59
|
+
const connections = Array.isArray(store?.connections)
|
|
60
|
+
? store.connections.filter(isConnection)
|
|
61
|
+
: [];
|
|
62
|
+
const currentConnectionId = typeof store?.currentConnectionId === "string"
|
|
63
|
+
&& connections.some((connection) => connection.id === store.currentConnectionId)
|
|
64
|
+
? store.currentConnectionId
|
|
65
|
+
: null;
|
|
66
|
+
return { version: 1, currentConnectionId, connections };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function selectConnection(store, id) {
|
|
70
|
+
const normalized = normalizeStore(store);
|
|
71
|
+
const connectionId = id || normalized.currentConnectionId;
|
|
72
|
+
if (!connectionId) return null;
|
|
73
|
+
return normalized.connections.find((connection) => connection.id === connectionId) ?? null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function findConnection(store, { id, baseUrl, workspaceId, teamId } = {}) {
|
|
77
|
+
const normalized = normalizeStore(store);
|
|
78
|
+
const candidates = id
|
|
79
|
+
? normalized.connections.filter((connection) => connection.id === id)
|
|
80
|
+
: normalized.connections.filter((connection) => connection.id === normalized.currentConnectionId);
|
|
81
|
+
return candidates.find((connection) =>
|
|
82
|
+
(!baseUrl || stripTrailingSlash(connection.baseUrl) === stripTrailingSlash(baseUrl)) &&
|
|
83
|
+
(!workspaceId || connection.workspaceId === workspaceId) &&
|
|
84
|
+
(!teamId || connection.teamId === teamId)
|
|
85
|
+
) ?? null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function hasConnectionId(store, id) {
|
|
89
|
+
return normalizeStore(store).connections.some((connection) => connection.id === id);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function upsertConnection(store, connection) {
|
|
93
|
+
if (!isConnection(connection)) throw new Error("Invalid Circular connection");
|
|
94
|
+
const normalized = normalizeStore(store);
|
|
95
|
+
const connections = [
|
|
96
|
+
connection,
|
|
97
|
+
...normalized.connections.filter((entry) => entry.id !== connection.id),
|
|
98
|
+
];
|
|
99
|
+
return { version: 1, currentConnectionId: connection.id, connections };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function removeConnection(store, id) {
|
|
103
|
+
const normalized = normalizeStore(store);
|
|
104
|
+
const connections = normalized.connections.filter((entry) => entry.id !== id);
|
|
105
|
+
return {
|
|
106
|
+
version: 1,
|
|
107
|
+
currentConnectionId: normalized.currentConnectionId === id ? null : normalized.currentConnectionId,
|
|
108
|
+
connections,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function publicConnectionRecord(connection) {
|
|
113
|
+
if (!connection) return null;
|
|
114
|
+
const { token: _token, ...safe } = connection;
|
|
115
|
+
return safe;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function stripTrailingSlash(url) {
|
|
119
|
+
return typeof url === "string" ? url.replace(/\/+$/, "") : url;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function assertValidStore(store, path) {
|
|
123
|
+
if (!store || typeof store !== "object" || Array.isArray(store)) {
|
|
124
|
+
throw new Error(`Invalid Circular connections file at ${path}. Expected a versioned connection store.`);
|
|
125
|
+
}
|
|
126
|
+
if (store.version !== 1 || !Array.isArray(store.connections)) {
|
|
127
|
+
throw new Error(`Invalid Circular connections file at ${path}. Expected version 1 with a connections array.`);
|
|
128
|
+
}
|
|
129
|
+
if (!(store.currentConnectionId === null || store.currentConnectionId === undefined || typeof store.currentConnectionId === "string")) {
|
|
130
|
+
throw new Error(`Invalid Circular connections file at ${path}. currentConnectionId must be a string or null.`);
|
|
131
|
+
}
|
|
132
|
+
const invalid = store.connections.find((connection) => !isConnection(connection));
|
|
133
|
+
if (invalid) {
|
|
134
|
+
throw new Error(`Invalid Circular connections file at ${path}. Connection entries must include id, name, trusted baseUrl, token, workspaceId and teamId.`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function isConnection(value) {
|
|
139
|
+
return Boolean(
|
|
140
|
+
value &&
|
|
141
|
+
typeof value.id === "string" &&
|
|
142
|
+
typeof value.name === "string" &&
|
|
143
|
+
typeof value.baseUrl === "string" &&
|
|
144
|
+
isTrustedCircularOrigin(value.baseUrl) &&
|
|
145
|
+
typeof value.token === "string" &&
|
|
146
|
+
typeof value.workspaceId === "string" &&
|
|
147
|
+
typeof value.teamId === "string",
|
|
148
|
+
);
|
|
149
|
+
}
|
package/package.json
CHANGED