stoaken 0.4.0 → 0.5.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.
Files changed (3) hide show
  1. package/README.md +29 -9
  2. package/dist/index.js +329 -64
  3. package/package.json +7 -4
package/README.md CHANGED
@@ -10,13 +10,13 @@ npx stoaken init
10
10
 
11
11
  Run it in the root of the project you want connected to a Stoaken Brand. It will:
12
12
 
13
- 1. Ask how you'd like to sign in -- open your browser for a real OAuth 2.1 login, or let you paste an API key you've already created at `/settings/api-keys`.
13
+ 1. Ask how you'd like to sign in -- in your browser (OAuth 2.1), or by pasting an API key you've already created in Settings › API keys.
14
14
  2. List the real Brands your account can see, and let you pick one.
15
15
  3. Write `stoaken.config.json` (which Brand, which server -- safe to commit, holds no secret) and `.stoaken/credentials.json` (your bearer credential, `chmod 600`, automatically added to `.gitignore`).
16
16
  4. Ask which AI tool to connect (or take `--client`) and write that tool's MCP config.
17
17
  5. Generate `.stoaken/SKILL.md` -- a short brief for AI coding agents explaining how this project connects to Stoaken's MCP server and what its tools do.
18
18
 
19
- Run `stoaken init` again in an already-configured directory to continue with the same Brand, switch to a different one, or re-authenticate.
19
+ Run `stoaken init` again in an already-configured directory to continue with the same Brand, switch to a different one, or sign in again.
20
20
 
21
21
  ## Connect an AI tool
22
22
 
@@ -32,22 +32,42 @@ npx stoaken init --client cursor
32
32
  | `copilot` | `.vscode/mcp.json` (`servers.stoaken`, VS Code agent mode) |
33
33
  | `claude-desktop` | Nothing: prints the steps to add a custom connector (Customize > Connectors) |
34
34
  | `v0` | Nothing: prints the steps to add a custom MCP server in v0 (+ > MCPs) |
35
+ | `windsurf` | `.devin/mcp_config.json` (`mcpServers.stoaken`, URL only). Windsurf is now Devin Desktop; this is for its Devin Local agent and Devin CLI |
36
+ | `zed` | `.zed/settings.json` (`context_servers.stoaken`, URL only; OAuth). With an API key: nothing, prints how to add it to your user settings |
37
+ | `gemini-cli` | `.gemini/settings.json` (`mcpServers.stoaken.httpUrl`; loads in trusted folders only) |
38
+ | `jetbrains` | `.junie/mcp/mcp.json` (`mcpServers.stoaken`, URL only; Junie). With an API key: nothing, prints how to add it to `~/.junie/mcp/mcp.json` |
39
+ | `cline` | Nothing: prints the steps for Cline's Remote Servers tab |
40
+ | `lovable` | Nothing: prints the steps to add a custom MCP server (Connectors > Add custom) |
41
+ | `bolt` | Nothing: prints the steps to add a custom connector (+ > Connectors) |
42
+ | `replit` | Nothing: prints a one-click Replit install link and the manual steps |
35
43
 
36
44
  init merges: other servers in the file are kept and only the `stoaken` entry is replaced. If the
37
- file isn't plain JSON (for example `.vscode/mcp.json` with comments) it is left alone and you're told.
38
- No secret goes into these files, so they are safe to commit:
45
+ file isn't plain JSON (for example `.vscode/mcp.json` or `.zed/settings.json` with comments) it is
46
+ left alone and you're told. No secret goes into these files, so they are safe to commit:
39
47
 
40
48
  - Signed in with the browser: the entry has just the URL, and the tool signs you in itself with
41
- OAuth (Claude Code `/mcp`, Cursor Connect, `codex mcp login stoaken`, VS Code Start).
49
+ OAuth (Claude Code `/mcp`, Cursor Connect, `codex mcp login stoaken`, VS Code Start,
50
+ `/mcp auth stoaken` in Gemini CLI, Authenticate in Devin Local, Zed's sign-in prompt, Junie's
51
+ `/mcp` Authorize).
42
52
  - Pasted an API key: the entry reads the `STOAKEN_API_KEY` environment variable (VS Code instead
43
- asks for the key once and stores it securely).
53
+ asks for the key once and stores it securely). Devin Local, Zed and Junie document no variable
54
+ expansion in headers, so init never writes the key for them: Devin gets a gitignored
55
+ `.devin/mcp_config.local.json` override (`devin mcp add -s local ... -H`), and Zed and Junie are
56
+ told to use a user-level file outside the repo.
44
57
 
45
58
  ## Authentication
46
59
 
47
60
  Two paths, both landing on the same bearer credential:
48
61
 
49
- - **Log in with browser** -- a real OAuth 2.1 Authorization Code + PKCE flow. The CLI discovers Stoaken's Authorization Server automatically (via its `/.well-known/oauth-protected-resource` metadata), registers itself as an OAuth client on first run, and opens your browser to sign in.
50
- - **Paste an API key** -- for CI, scripts, or anywhere a browser isn't available. Create one at `/settings/api-keys` in the Stoaken app.
62
+ - **Sign in with browser** -- a real OAuth 2.1 Authorization Code + PKCE flow. The CLI discovers Stoaken's Authorization Server automatically (via its `/.well-known/oauth-protected-resource` metadata) and opens your browser. You sign in as you do on the web, then a consent screen asks whether **Stoaken CLI** may use your account: check the name, then allow it. The terminal then says `Signed in as you@example.com.`
63
+ - From 0.5.1 the CLI identifies itself with a Client ID Metadata Document (`https://www.stoaken.com/oauth/cli-client.json`), so nothing is registered; if the server doesn't support that, it registers itself once (Dynamic Client Registration) and reuses that registration. Its redirect is `http://127.0.0.1:51820/callback`.
64
+ - It asks for a token for `https://www.stoaken.com/mcp` with `offline_access`, and saves the refresh token (with the server it came from) in `.stoaken/credentials.json`, `chmod 600`. `stoaken export` renews an expired sign-in itself; when that's no longer possible it says "Your Stoaken sign-in ended." Run `stoaken init` to sign in again.
65
+ - Browser sign-in has to be switched on for Stoaken's sign-in service. Until it is, use an API key.
66
+ - **Paste an API key** -- for CI, scripts, or anywhere a browser isn't available. Create one in Settings › API keys in the Stoaken app.
67
+
68
+ ## Changes
69
+
70
+ - **0.5.1** -- Browser sign-in uses a Client ID Metadata Document (Dynamic Client Registration as the fallback), asks for the /mcp resource and a refresh token, and renews itself. "Sign in" wording throughout.
51
71
 
52
72
  ## Export your tokens as code
53
73
 
@@ -101,7 +121,7 @@ Bitbucket Pipelines, Azure Pipelines and Vercel builds: see /docs/webhooks#ci an
101
121
 
102
122
  ## What this connects to
103
123
 
104
- Once set up, any MCP-capable client (Claude Code, Claude Desktop, Cursor, Codex, Copilot, v0) or a direct HTTP client can reach Stoaken's MCP server at `/mcp`. Its main tools (full list at /docs/mcp):
124
+ Once set up, any MCP-capable client (Claude Code, Claude Desktop, Cursor, Codex, Copilot, v0, Devin Desktop, Zed, Gemini CLI, Junie, Cline, Lovable, Bolt, Replit) or a direct HTTP client can reach Stoaken's MCP server at `/mcp`. Its main tools (full list at /docs/mcp):
105
125
 
106
126
  - `list_brands` -- every Brand you hold at least Viewer on.
107
127
  - `get_theme_tokens(themeId)` -- a Theme's cascade-merged token tree.
package/dist/index.js CHANGED
@@ -14,7 +14,7 @@ import open from "open";
14
14
 
15
15
  // src/config.ts
16
16
  import { existsSync } from "fs";
17
- import { appendFile, mkdir, readFile, writeFile } from "fs/promises";
17
+ import { appendFile, chmod, mkdir, readFile, rename, writeFile } from "fs/promises";
18
18
  import { join } from "path";
19
19
  var CONFIG_FILE = "stoaken.config.json";
20
20
  var STOAKEN_DIR = ".stoaken";
@@ -40,9 +40,14 @@ async function ensureStoakenDir(cwd) {
40
40
  }
41
41
  async function writeCredentials(cwd, credentials) {
42
42
  await ensureStoakenDir(cwd);
43
- const path = join(cwd, CREDENTIALS_FILE);
44
- await writeFile(path, `${JSON.stringify(credentials, null, 2)}
45
- `, { encoding: "utf8", mode: 384 });
43
+ await writePrivate(join(cwd, CREDENTIALS_FILE), `${JSON.stringify(credentials, null, 2)}
44
+ `);
45
+ }
46
+ async function writePrivate(path, text) {
47
+ const tmp = `${path}.${process.pid}.tmp`;
48
+ await writeFile(tmp, text, { encoding: "utf8", mode: 384, flag: "wx" });
49
+ await chmod(tmp, 384);
50
+ await rename(tmp, path);
46
51
  }
47
52
  async function readCredentials(cwd) {
48
53
  try {
@@ -52,18 +57,23 @@ async function readCredentials(cwd) {
52
57
  return null;
53
58
  }
54
59
  }
55
- async function readOAuthClient(cwd) {
60
+ async function readOAuthClient(cwd, authServer) {
56
61
  try {
57
62
  const raw = await readFile(join(cwd, OAUTH_CLIENT_FILE), "utf8");
58
- return JSON.parse(raw);
63
+ const client = JSON.parse(raw);
64
+ return client.clientId && sameServer(client.authServer, authServer) ? client : null;
59
65
  } catch {
60
66
  return null;
61
67
  }
62
68
  }
63
69
  async function writeOAuthClient(cwd, client) {
64
70
  await ensureStoakenDir(cwd);
65
- await writeFile(join(cwd, OAUTH_CLIENT_FILE), `${JSON.stringify(client, null, 2)}
66
- `, "utf8");
71
+ await writePrivate(join(cwd, OAUTH_CLIENT_FILE), `${JSON.stringify(client, null, 2)}
72
+ `);
73
+ }
74
+ function sameServer(a, b) {
75
+ const norm = (u) => (u ?? "").trim().replace(/\/+$/, "").toLowerCase();
76
+ return Boolean(norm(a)) && norm(a) === norm(b);
67
77
  }
68
78
  async function writeSkillFile(cwd, content) {
69
79
  await ensureStoakenDir(cwd);
@@ -86,9 +96,18 @@ async function ensureGitignored(cwd) {
86
96
  `, "utf8");
87
97
  }
88
98
 
89
- // src/auth.ts
99
+ // src/oauth-client.ts
90
100
  var CALLBACK_PORT = 51820;
91
101
  var REDIRECT_URI = `http://127.0.0.1:${CALLBACK_PORT}/callback`;
102
+ var CLIENT_METADATA_PATH = "/oauth/cli-client.json";
103
+ function cimdClientId(origin) {
104
+ return `${new URL(origin).origin}${CLIENT_METADATA_PATH}`;
105
+ }
106
+ var OAUTH_SCOPE = "openid offline_access";
107
+
108
+ // src/auth.ts
109
+ var SIGN_IN_ENDED = 'Your Stoaken sign-in ended. Run "stoaken init" to sign in again, or set STOAKEN_TOKEN to an API key.';
110
+ var CALLBACK_TIMEOUT_MS = 5 * 60 * 1e3;
92
111
  async function discoverAuthorizationServer(serverUrl) {
93
112
  const metadataUrl = new URL("/.well-known/oauth-protected-resource", serverUrl);
94
113
  const response = await fetch(metadataUrl);
@@ -96,26 +115,44 @@ async function discoverAuthorizationServer(serverUrl) {
96
115
  throw new Error(`Failed to discover the authorization server (${response.status}) at ${metadataUrl}`);
97
116
  }
98
117
  const metadata = await response.json();
99
- const authServer = metadata.authorization_servers?.[0];
100
- if (!authServer) {
118
+ const issuer = metadata.authorization_servers?.[0]?.replace(/\/+$/, "");
119
+ if (!issuer) {
101
120
  throw new Error(`${metadataUrl} did not name an authorization server`);
102
121
  }
103
- return authServer;
122
+ const as = await authServerMetadata(issuer);
123
+ return {
124
+ issuer,
125
+ resource: metadata.resource || new URL("/mcp", serverUrl).toString(),
126
+ authorize: as.authorization_endpoint ?? `${issuer}/oauth2/authorize`,
127
+ token: as.token_endpoint ?? `${issuer}/oauth2/token`,
128
+ register: as.registration_endpoint ?? `${issuer}/oauth2/register`,
129
+ cimd: as.client_id_metadata_document_supported
130
+ };
131
+ }
132
+ async function authServerMetadata(issuer) {
133
+ try {
134
+ const response = await fetch(`${issuer}/.well-known/oauth-authorization-server`);
135
+ return response.ok ? await response.json() : {};
136
+ } catch {
137
+ return {};
138
+ }
104
139
  }
105
- async function registerOAuthClient(authServer) {
106
- const response = await fetch(`${authServer}/oauth2/register`, {
140
+ var RegistrationRefused = class extends Error {
141
+ };
142
+ async function registerOAuthClient(server) {
143
+ const response = await fetch(server.register, {
107
144
  method: "POST",
108
145
  headers: { "Content-Type": "application/json" },
109
146
  body: JSON.stringify({
110
147
  client_name: "Stoaken CLI",
111
148
  redirect_uris: [REDIRECT_URI],
112
- grant_types: ["authorization_code"],
149
+ grant_types: ["authorization_code", "refresh_token"],
113
150
  response_types: ["code"],
114
151
  token_endpoint_auth_method: "none"
115
152
  })
116
153
  });
117
154
  if (!response.ok) {
118
- throw new Error(`Dynamic Client Registration failed (${response.status}): ${await response.text()}`);
155
+ throw new RegistrationRefused(`Dynamic Client Registration failed (${response.status}): ${await response.text()}`);
119
156
  }
120
157
  const data = await response.json();
121
158
  return data.client_id;
@@ -128,8 +165,15 @@ function generatePkce() {
128
165
  const challenge = base64url(createHash("sha256").update(verifier).digest());
129
166
  return { verifier, challenge };
130
167
  }
131
- function waitForCallback() {
168
+ var ClientRefused = class extends Error {
169
+ };
170
+ var CLIENT_ERRORS = /* @__PURE__ */ new Set(["invalid_client", "unauthorized_client"]);
171
+ function waitForCallback(state) {
132
172
  return new Promise((resolve2, reject) => {
173
+ const timer = setTimeout(() => {
174
+ server.close();
175
+ reject(new Error('Sign-in timed out. Run "stoaken init" to try again.'));
176
+ }, CALLBACK_TIMEOUT_MS);
133
177
  const server = createServer((req, res) => {
134
178
  const url = new URL(req.url ?? "/", REDIRECT_URI);
135
179
  if (url.pathname !== "/callback") {
@@ -138,64 +182,148 @@ function waitForCallback() {
138
182
  }
139
183
  const code = url.searchParams.get("code");
140
184
  const error = url.searchParams.get("error");
185
+ if (url.searchParams.get("state") !== state) {
186
+ res.writeHead(400, { "Content-Type": "text/html" }).end("<p>That isn't the answer to this sign-in.</p>");
187
+ return;
188
+ }
141
189
  res.writeHead(200, { "Content-Type": "text/html" });
142
190
  res.end(
143
- error ? "<p>Login failed. You can close this tab and return to the terminal.</p>" : "<p>Login complete -- you can close this tab and return to the terminal.</p>"
191
+ error || !code ? "<p>Sign-in failed. You can close this tab and return to the terminal.</p>" : "<p>Signed in. You can close this tab and return to the terminal.</p>"
144
192
  );
193
+ clearTimeout(timer);
145
194
  server.close();
146
195
  if (error) {
147
- reject(new Error(`WorkOS returned an error: ${error}`));
196
+ const why = `WorkOS returned an error: ${error}`;
197
+ reject(CLIENT_ERRORS.has(error) ? new ClientRefused(why) : new Error(why));
148
198
  } else if (!code) {
149
199
  reject(new Error("No authorization code in the callback"));
150
200
  } else {
151
201
  resolve2(code);
152
202
  }
153
203
  });
154
- server.on("error", reject);
204
+ server.on("error", (err) => {
205
+ clearTimeout(timer);
206
+ reject(err);
207
+ });
155
208
  server.listen(CALLBACK_PORT, "127.0.0.1");
156
209
  });
157
210
  }
158
- async function exchangeCodeForToken(authServer, clientId, code, verifier) {
159
- const response = await fetch(`${authServer}/oauth2/token`, {
211
+ async function tokenRequest(server, params) {
212
+ const response = await fetch(server.token, {
160
213
  method: "POST",
161
214
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
162
- body: new URLSearchParams({
163
- grant_type: "authorization_code",
164
- code,
165
- redirect_uri: REDIRECT_URI,
166
- client_id: clientId,
167
- code_verifier: verifier
168
- })
215
+ body: new URLSearchParams({ ...params, resource: server.resource })
169
216
  });
170
217
  if (!response.ok) {
171
- throw new Error(`Token exchange failed (${response.status}): ${await response.text()}`);
218
+ const text = await response.text();
219
+ const why = `Token exchange failed (${response.status}): ${text}`;
220
+ throw CLIENT_ERRORS.has(errorCode(text)) ? new ClientRefused(why) : new Error(why);
172
221
  }
173
- const data = await response.json();
174
- return data.access_token;
222
+ return await response.json();
175
223
  }
176
- async function loginWithBrowser(cwd, serverUrl) {
177
- const authServer = await discoverAuthorizationServer(serverUrl);
178
- let clientId = (await readOAuthClient(cwd))?.clientId;
179
- if (!clientId) {
180
- clientId = await registerOAuthClient(authServer);
181
- await writeOAuthClient(cwd, { clientId });
224
+ function errorCode(text) {
225
+ try {
226
+ return String(JSON.parse(text).error ?? "");
227
+ } catch {
228
+ return "";
229
+ }
230
+ }
231
+ function credentialsFrom(data, server, clientId, serverUrl, now = Date.now()) {
232
+ return {
233
+ kind: "oauth",
234
+ token: data.access_token,
235
+ ...data.refresh_token ? { refreshToken: data.refresh_token } : {},
236
+ ...typeof data.expires_in === "number" ? { expiresAt: now + data.expires_in * 1e3 } : {},
237
+ authServer: server.issuer,
238
+ clientId,
239
+ serverUrl
240
+ };
241
+ }
242
+ function tokenEmail(token) {
243
+ try {
244
+ const payload = JSON.parse(Buffer.from(token.split(".")[1] ?? "", "base64url").toString("utf8"));
245
+ return typeof payload.email === "string" ? payload.email : null;
246
+ } catch {
247
+ return null;
182
248
  }
249
+ }
250
+ async function authorizeWith(server, clientId) {
183
251
  const { verifier, challenge } = generatePkce();
184
- const authorizeUrl = new URL("/oauth2/authorize", authServer);
252
+ const state = base64url(randomBytes(16));
253
+ const authorizeUrl = new URL(server.authorize);
185
254
  authorizeUrl.searchParams.set("client_id", clientId);
186
255
  authorizeUrl.searchParams.set("redirect_uri", REDIRECT_URI);
187
256
  authorizeUrl.searchParams.set("response_type", "code");
188
257
  authorizeUrl.searchParams.set("code_challenge", challenge);
189
258
  authorizeUrl.searchParams.set("code_challenge_method", "S256");
190
- const callbackPromise = waitForCallback();
259
+ authorizeUrl.searchParams.set("state", state);
260
+ authorizeUrl.searchParams.set("scope", OAUTH_SCOPE);
261
+ authorizeUrl.searchParams.set("resource", server.resource);
262
+ const callbackPromise = waitForCallback(state);
191
263
  await open(authorizeUrl.toString());
192
- p.log.info(`Opened your browser to sign in. Waiting for you to finish...`);
193
- const code = await callbackPromise;
194
- return exchangeCodeForToken(authServer, clientId, code, verifier);
264
+ return { code: await callbackPromise, verifier };
265
+ }
266
+ async function dcrClient(cwd, server) {
267
+ const cached = await readOAuthClient(cwd, server.issuer);
268
+ if (cached) return cached.clientId;
269
+ let clientId;
270
+ try {
271
+ clientId = await registerOAuthClient(server);
272
+ } catch (error) {
273
+ if (error instanceof RegistrationRefused) {
274
+ throw new Error("Browser sign-in isn't switched on for this Stoaken server yet. Paste an API key instead (Settings \u203A API keys).");
275
+ }
276
+ throw error;
277
+ }
278
+ await writeOAuthClient(cwd, { clientId, authServer: server.issuer });
279
+ return clientId;
280
+ }
281
+ async function loginWithBrowser(cwd, serverUrl) {
282
+ const server = await discoverAuthorizationServer(serverUrl);
283
+ const signIn = async (clientId) => {
284
+ const { code, verifier } = await authorizeWith(server, clientId);
285
+ const data = await tokenRequest(server, { grant_type: "authorization_code", code, redirect_uri: REDIRECT_URI, client_id: clientId, code_verifier: verifier });
286
+ return credentialsFrom(data, server, clientId, serverUrl);
287
+ };
288
+ let credentials;
289
+ if (server.cimd) {
290
+ try {
291
+ credentials = await signIn(cimdClientId(new URL(server.resource).origin));
292
+ } catch (error) {
293
+ if (!(error instanceof ClientRefused)) throw error;
294
+ p.log.info("Stoaken's sign-in didn't accept the CLI's published client. Trying again with a registered one...");
295
+ credentials = await signIn(await dcrClient(cwd, server));
296
+ }
297
+ } else {
298
+ credentials = await signIn(await dcrClient(cwd, server));
299
+ }
300
+ return { credentials, email: tokenEmail(credentials.token) };
301
+ }
302
+ function needsRefresh(credentials, now = Date.now()) {
303
+ return credentials.kind === "oauth" && typeof credentials.expiresAt === "number" && now >= credentials.expiresAt - 6e4;
304
+ }
305
+ async function freshCredentials(cwd, serverUrl) {
306
+ const saved = await readCredentials(cwd);
307
+ if (!saved || saved.kind !== "oauth") return saved;
308
+ if (saved.serverUrl && !sameServer(saved.serverUrl, serverUrl)) {
309
+ throw new Error(`Your saved sign-in is for ${saved.serverUrl}, not ${serverUrl}. Run "stoaken init" to sign in again, or set STOAKEN_TOKEN to an API key.`);
310
+ }
311
+ if (!needsRefresh(saved)) return saved;
312
+ if (!saved.refreshToken || !saved.authServer || !saved.clientId) throw new Error(SIGN_IN_ENDED);
313
+ try {
314
+ const server = await discoverAuthorizationServer(saved.serverUrl ?? serverUrl);
315
+ if (!sameServer(server.issuer, saved.authServer)) throw new Error("authorization server changed");
316
+ const data = await tokenRequest(server, { grant_type: "refresh_token", refresh_token: saved.refreshToken, client_id: saved.clientId });
317
+ const next = credentialsFrom({ refresh_token: saved.refreshToken, ...data }, server, saved.clientId, saved.serverUrl ?? serverUrl);
318
+ await writeCredentials(cwd, next);
319
+ return next;
320
+ } catch {
321
+ throw new Error(SIGN_IN_ENDED);
322
+ }
195
323
  }
196
324
  async function promptForApiKey() {
197
325
  const key = await p.password({
198
- message: "Paste your Stoaken API key (from /settings/api-keys):",
326
+ message: "Paste your Stoaken API key (Settings \u203A API keys):",
199
327
  validate: (value) => {
200
328
  if (!value?.startsWith("stk_") && !value?.startsWith("twk_")) {
201
329
  return "That doesn't look like a Stoaken API key (should start with stk_)";
@@ -214,7 +342,7 @@ async function listBrands(serverUrl, credential) {
214
342
  const transport = new StreamableHTTPClientTransport(new URL("/mcp", serverUrl), {
215
343
  requestInit: { headers: { Authorization: `Bearer ${credential}` } }
216
344
  });
217
- const client = new Client({ name: "stoaken-cli", version: "0.4.0" });
345
+ const client = new Client({ name: "stoaken-cli", version: "0.5.1" });
218
346
  await client.connect(transport);
219
347
  try {
220
348
  const result = await client.callTool({ name: "list_brands", arguments: {} });
@@ -229,7 +357,22 @@ async function listBrands(serverUrl, credential) {
229
357
  }
230
358
 
231
359
  // src/client-config.ts
232
- var CLIENTS = ["claude-code", "claude-desktop", "cursor", "codex", "copilot", "v0"];
360
+ var CLIENTS = [
361
+ "claude-code",
362
+ "claude-desktop",
363
+ "cursor",
364
+ "codex",
365
+ "copilot",
366
+ "v0",
367
+ "windsurf",
368
+ "zed",
369
+ "gemini-cli",
370
+ "jetbrains",
371
+ "cline",
372
+ "lovable",
373
+ "bolt",
374
+ "replit"
375
+ ];
233
376
  var SERVER_NAME = "stoaken";
234
377
  var API_KEY_ENV = "STOAKEN_API_KEY";
235
378
  var CLIENT_LABELS = {
@@ -238,17 +381,43 @@ var CLIENT_LABELS = {
238
381
  cursor: "Cursor",
239
382
  codex: "OpenAI Codex",
240
383
  copilot: "GitHub Copilot (VS Code)",
241
- v0: "v0 (Vercel)"
384
+ v0: "v0 (Vercel)",
385
+ windsurf: "Windsurf / Devin Desktop",
386
+ zed: "Zed",
387
+ "gemini-cli": "Gemini CLI",
388
+ jetbrains: "JetBrains (Junie)",
389
+ cline: "Cline",
390
+ lovable: "Lovable",
391
+ bolt: "Bolt (bolt.new)",
392
+ replit: "Replit Agent"
393
+ };
394
+ var JSON_CLIENTS = ["claude-code", "cursor", "copilot", "windsurf", "zed", "gemini-cli", "jetbrains"];
395
+ var SERVERS_KEY = {
396
+ "claude-code": "mcpServers",
397
+ cursor: "mcpServers",
398
+ copilot: "servers",
399
+ windsurf: "mcpServers",
400
+ zed: "context_servers",
401
+ "gemini-cli": "mcpServers",
402
+ jetbrains: "mcpServers"
242
403
  };
243
404
  function isClient(value) {
244
405
  return CLIENTS.includes(value);
245
406
  }
407
+ function isJsonClient(client) {
408
+ return JSON_CLIENTS.includes(client);
409
+ }
246
410
  function mcpUrl(serverUrl) {
247
411
  return `${serverUrl.replace(/\/+$/, "")}/mcp`;
248
412
  }
413
+ function replitInstallLink(serverUrl) {
414
+ const json = JSON.stringify({ displayName: "Stoaken", baseUrl: mcpUrl(serverUrl) });
415
+ return `https://replit.com/integrations?mcp=${Buffer.from(json, "utf8").toString("base64")}`;
416
+ }
249
417
  function planFor(client, auth, serverUrl) {
250
418
  const url = mcpUrl(serverUrl);
251
419
  const keyStep = `Set ${API_KEY_ENV} in your shell to an API key from Settings > API keys (never commit it).`;
420
+ const oauthInstead = "Or run init again, sign in with the browser, and use OAuth instead.";
252
421
  switch (client) {
253
422
  case "claude-code":
254
423
  return {
@@ -268,7 +437,8 @@ function planFor(client, auth, serverUrl) {
268
437
  case "copilot":
269
438
  return {
270
439
  file: ".vscode/mcp.json",
271
- nextSteps: auth === "oauth" ? ["In VS Code open .vscode/mcp.json, click Start above stoaken and sign in in the browser.", "Use Copilot Chat in Agent mode."] : ["In VS Code open .vscode/mcp.json, click Start above stoaken and paste an API key when asked (VS Code stores it securely).", "Use Copilot Chat in Agent mode."]
440
+ nextSteps: auth === "oauth" ? ["In VS Code open .vscode/mcp.json, click Start above stoaken and sign in in the browser.", "Use Copilot Chat in Agent mode."] : ["In VS Code open .vscode/mcp.json, click Start above stoaken and paste an API key when asked (VS Code stores it securely).", "Use Copilot Chat in Agent mode."],
441
+ keyNote: "VS Code asks for the key once and stores it securely"
272
442
  };
273
443
  case "claude-desktop":
274
444
  return {
@@ -290,10 +460,104 @@ function planFor(client, auth, serverUrl) {
290
460
  "v0 uses the tools while it generates; the app it builds cannot call them."
291
461
  ]
292
462
  };
463
+ case "windsurf":
464
+ return {
465
+ file: ".devin/mcp_config.json",
466
+ nextSteps: auth === "oauth" ? [
467
+ "Open this project in Devin Desktop (formerly Windsurf) with the Devin Local agent. Devin CLI reads the same file.",
468
+ "When stoaken shows Needs auth in the MCP list, click Authenticate (or run: devin mcp login stoaken)."
469
+ ] : [
470
+ keyStep,
471
+ `Devin documents no variable expansion in MCP headers, so add the key to the gitignored local override: devin mcp add -s local stoaken ${url} -H "Authorization: Bearer $${API_KEY_ENV}"`,
472
+ "That override replaces the project entry for you only. The legacy Cascade agent ignores project files (see the guide)."
473
+ ],
474
+ keyNote: "the key goes in the gitignored `.devin/mcp_config.local.json` override"
475
+ };
476
+ case "zed":
477
+ return auth === "oauth" ? {
478
+ file: ".zed/settings.json",
479
+ nextSteps: [
480
+ "Trust this project in Zed: in Restricted Mode project settings and their MCP servers don't load.",
481
+ "Zed asks you to sign in to stoaken (MCP OAuth). Settings > AI > MCP Servers shows a green dot when it's active."
482
+ ]
483
+ } : {
484
+ file: null,
485
+ nextSteps: [
486
+ "Zed documents no variable expansion in headers, so the key can't go in a project file.",
487
+ `Run "zed: open settings file" and add under "context_servers": "stoaken": { "url": "${url}", "headers": { "Authorization": "Bearer <API key>" } }`,
488
+ oauthInstead
489
+ ]
490
+ };
491
+ case "gemini-cli":
492
+ return {
493
+ file: ".gemini/settings.json",
494
+ nextSteps: auth === "oauth" ? ["Trust this folder (gemini trust): untrusted folders ignore .gemini/settings.json.", "In Gemini CLI run /mcp auth stoaken to sign in in the browser."] : [keyStep, "Trust this folder (gemini trust): untrusted folders ignore .gemini/settings.json."]
495
+ };
496
+ case "jetbrains":
497
+ return auth === "oauth" ? {
498
+ file: ".junie/mcp/mcp.json",
499
+ nextSteps: [
500
+ "Junie in the IDE: Settings > Tools > Junie > MCP Settings lists stoaken and its status.",
501
+ "Junie CLI: run /mcp, select stoaken, choose Authorize and sign in in the browser."
502
+ ]
503
+ } : {
504
+ file: null,
505
+ nextSteps: [
506
+ "Junie has no secret storage or variable expansion for MCP configs, so the key can't go in a project file.",
507
+ `In your user file ~/.junie/mcp/mcp.json, under "mcpServers", add "stoaken": { "url": "${url}", "headers": { "Authorization": "Bearer <API key>" } }`,
508
+ oauthInstead
509
+ ]
510
+ };
511
+ case "cline":
512
+ return {
513
+ file: null,
514
+ nextSteps: [
515
+ "Cline keeps MCP servers in its own settings, not a project file.",
516
+ ...auth === "oauth" ? [
517
+ `Cline panel > MCP Servers > Remote Servers: name stoaken, URL ${url}, transport Streamable HTTP, Add Server.`,
518
+ "If it asks you to authenticate, sign in in the browser (Cline CLI: cline mcp > Authorize OAuth)."
519
+ ] : [
520
+ `Cline panel > MCP Servers > Configure > Configure MCP Servers, and add under "mcpServers": "stoaken": { "type": "streamableHttp", "url": "${url}", "headers": { "Authorization": "Bearer <API key>" } }`
521
+ ]
522
+ ]
523
+ };
524
+ case "lovable":
525
+ return {
526
+ file: null,
527
+ nextSteps: [
528
+ "Lovable adds MCP servers in its own UI, not through a project file.",
529
+ "Connectors > Add custom > MCP server. Server name: Stoaken.",
530
+ `Server URL: ${url}. Authentication: OAuth (Add & authorize), or Bearer token with an API key.`,
531
+ "The connection is yours alone and only for Lovable's chat; the app it publishes can't call it."
532
+ ]
533
+ };
534
+ case "bolt":
535
+ return {
536
+ file: null,
537
+ nextSteps: [
538
+ "Bolt adds MCP servers in its own UI, not through a project file.",
539
+ "In the chatbox: + > Connectors > Manage connectors > Custom MCP server.",
540
+ `Name: Stoaken. URL: ${url}. Transport type: HTTP. Authentication: MCP OAuth.`,
541
+ "Then turn the connector on for the project (+ > Connectors)."
542
+ ]
543
+ };
544
+ case "replit":
545
+ return {
546
+ file: null,
547
+ nextSteps: [
548
+ "Replit adds MCP servers in its own UI, not through a project file.",
549
+ `Open ${replitInstallLink(serverUrl)} (or Integrations > MCP Servers > + Add MCP server, URL ${url}).`,
550
+ auth === "oauth" ? "Click Test & save and sign in when Replit runs the OAuth flow." : "Under Advanced settings add the header Authorization: Bearer <API key>, then Test & save."
551
+ ]
552
+ };
293
553
  }
294
554
  }
295
555
  function jsonEntry(client, auth, serverUrl) {
296
556
  const url = mcpUrl(serverUrl);
557
+ if (client === "windsurf" || client === "zed" || client === "jetbrains") return { url };
558
+ if (client === "gemini-cli") {
559
+ return auth === "oauth" ? { httpUrl: url } : { httpUrl: url, headers: { Authorization: `Bearer \${${API_KEY_ENV}}` } };
560
+ }
297
561
  if (client === "claude-code") {
298
562
  return auth === "oauth" ? { type: "http", url } : { type: "http", url, headers: { Authorization: `Bearer \${${API_KEY_ENV}}` } };
299
563
  }
@@ -325,8 +589,9 @@ var COPILOT_INPUT = {
325
589
  };
326
590
  function mergeJsonConfig(client, existing, auth, serverUrl) {
327
591
  const file = planFor(client, auth, serverUrl).file;
592
+ if (file === null) throw new ConfigMergeError(`${CLIENT_LABELS[client]} gets no project file with an API key; follow the steps instead.`);
328
593
  const doc = parseObject(existing, file);
329
- const key = client === "copilot" ? "servers" : "mcpServers";
594
+ const key = SERVERS_KEY[client];
330
595
  const current = doc[key];
331
596
  if (current !== void 0 && (typeof current !== "object" || current === null || Array.isArray(current))) {
332
597
  throw new ConfigMergeError(`${file} has a "${key}" that isn't an object, so it was left alone.`);
@@ -377,7 +642,7 @@ file -- it's already in \`.gitignore\`).
377
642
  }
378
643
  const { client, auth } = target;
379
644
  const plan = planFor(client, auth, serverUrl);
380
- const how = auth === "oauth" ? "the client signs in with OAuth" : `it reads the \`${API_KEY_ENV}\` environment variable`;
645
+ const how = auth === "oauth" ? "the client signs in with OAuth" : plan.keyNote ?? `it reads the \`${API_KEY_ENV}\` environment variable`;
381
646
  const where = plan.file ? `It is configured for ${CLIENT_LABELS[client]} in \`${plan.file}\` (no secret in that file: ${how}).` : `${CLIENT_LABELS[client]} connects through its own settings, not a project file.`;
382
647
  return `${where}
383
648
 
@@ -451,7 +716,7 @@ async function runInit(options) {
451
716
  options: [
452
717
  { value: "continue", label: `Continue with ${existing.brandName}` },
453
718
  { value: "switch", label: "Switch Brand" },
454
- { value: "reauth", label: "Re-authenticate" }
719
+ { value: "reauth", label: "Sign in again" }
455
720
  ]
456
721
  });
457
722
  if (p2.isCancel(choice)) {
@@ -462,13 +727,12 @@ async function runInit(options) {
462
727
  skipToWrite = { brandId: existing.brandId, brandName: existing.brandName };
463
728
  }
464
729
  }
465
- let credential;
466
- let credentialKind;
730
+ let saved;
467
731
  if (!skipToWrite) {
468
732
  const authChoice = await p2.select({
469
733
  message: "How would you like to sign in?",
470
734
  options: [
471
- { value: "browser", label: "Log in with browser" },
735
+ { value: "browser", label: "Sign in with browser" },
472
736
  { value: "key", label: "Paste an API key" }
473
737
  ]
474
738
  });
@@ -477,12 +741,13 @@ async function runInit(options) {
477
741
  return;
478
742
  }
479
743
  if (authChoice === "browser") {
744
+ p2.log.info("Opening your browser to sign in to Stoaken. It will ask whether Stoaken CLI may use your account. Check the name, then allow it.");
480
745
  const s2 = p2.spinner();
481
746
  s2.start("Waiting for you to finish signing in...");
482
747
  try {
483
- credential = await loginWithBrowser(cwd, serverUrl);
484
- credentialKind = "oauth";
485
- s2.stop("Signed in.");
748
+ const { credentials, email } = await loginWithBrowser(cwd, serverUrl);
749
+ saved = credentials;
750
+ s2.stop(email ? `Signed in as ${email}.` : "Signed in.");
486
751
  } catch (error) {
487
752
  s2.stop("Sign-in failed.");
488
753
  p2.log.error(error instanceof Error ? error.message : String(error));
@@ -494,14 +759,13 @@ async function runInit(options) {
494
759
  p2.cancel("Cancelled.");
495
760
  return;
496
761
  }
497
- credential = key;
498
- credentialKind = "api_key";
762
+ saved = { kind: "api_key", token: key };
499
763
  }
500
764
  const s = p2.spinner();
501
765
  s.start("Fetching your Brands...");
502
766
  let brands;
503
767
  try {
504
- brands = await listBrands(serverUrl, credential);
768
+ brands = await listBrands(serverUrl, saved.token);
505
769
  } catch (error) {
506
770
  s.stop("Failed to reach Stoaken.");
507
771
  p2.log.error(error instanceof Error ? error.message : String(error));
@@ -521,7 +785,7 @@ async function runInit(options) {
521
785
  return;
522
786
  }
523
787
  const brand = brands.find((b) => b.id === brandChoice);
524
- await writeCredentials(cwd, { kind: credentialKind, token: credential });
788
+ await writeCredentials(cwd, saved);
525
789
  skipToWrite = { brandId: brand.id, brandName: brand.name };
526
790
  }
527
791
  const keepExports = existing?.brandId === skipToWrite.brandId && existing.exports !== void 0;
@@ -557,6 +821,7 @@ async function runInit(options) {
557
821
  const path = join2(cwd, plan.file);
558
822
  const existingText = existsSync2(path) ? await readFile2(path, "utf8") : null;
559
823
  try {
824
+ if (client !== "codex" && !isJsonClient(client)) throw new Error(`No file writer for ${client}.`);
560
825
  const next = client === "codex" ? mergeCodexToml(existingText, auth, serverUrl) : mergeJsonConfig(client, existingText, auth, serverUrl);
561
826
  await mkdir2(dirname(path), { recursive: true });
562
827
  await writeFile2(path, next, "utf8");
@@ -590,7 +855,7 @@ async function call(serverUrl, token, path) {
590
855
  let response;
591
856
  try {
592
857
  response = await fetch(new URL(path, serverUrl), {
593
- headers: { Authorization: `Bearer ${token}`, "User-Agent": "stoaken-cli/0.4.0" }
858
+ headers: { Authorization: `Bearer ${token}`, "User-Agent": "stoaken-cli/0.5.1" }
594
859
  });
595
860
  } catch (error) {
596
861
  throw new Error(`Couldn't reach ${serverUrl}: ${error instanceof Error ? error.message : String(error)}`);
@@ -795,7 +1060,7 @@ async function runExport(argv) {
795
1060
  if (!config) throw new ExportUsageError('No stoaken.config.json here. Run "stoaken init" in your project first.');
796
1061
  const serverUrl = args.server ?? config.serverUrl;
797
1062
  const envToken = process.env.STOAKEN_TOKEN?.trim();
798
- const token = envToken || (await readCredentials(cwd))?.token;
1063
+ const token = envToken || (await freshCredentials(cwd, serverUrl))?.token;
799
1064
  if (!token) throw new ExportUsageError('No credentials. Run "stoaken init", or set STOAKEN_TOKEN to an API key.');
800
1065
  const targets = args.target ? [args.target] : readSavedTargets(config.exports);
801
1066
  if (targets.length === 0) targets.push({ format: "css" });
@@ -901,7 +1166,7 @@ switch (command) {
901
1166
  "",
902
1167
  "Usage:",
903
1168
  " stoaken init [--server <url>] [--client <name>]",
904
- " Authenticate, pick a Brand, and connect an AI tool",
1169
+ " Sign in, pick a Brand, and connect an AI tool",
905
1170
  ` (${CLIENTS.join(", ")})`,
906
1171
  " stoaken export [options] Write your tokens as code (stoaken export --help)"
907
1172
  ].join("\n")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stoaken",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "Stoaken CLI -- set up a project for MCP/API access, and export your design tokens as code.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -39,13 +39,16 @@
39
39
  },
40
40
  "dependencies": {
41
41
  "@clack/prompts": "^1.8.0",
42
- "open": "^11.0.2",
43
- "@modelcontextprotocol/client": "^2.4.0"
42
+ "@modelcontextprotocol/client": "^2.4.0",
43
+ "open": "^11.0.2"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@types/node": "^26.5.0",
47
- "tsup": "^8.5.1",
47
+ "tsup": "^9.0.0",
48
48
  "tsx": "^4.23.13",
49
49
  "typescript": "^7.0.2"
50
+ },
51
+ "overrides": {
52
+ "esbuild": "0.28.2"
50
53
  }
51
54
  }