stoaken 0.1.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +78 -3
  2. package/dist/index.js +560 -22
  3. package/package.json +5 -5
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # stoaken
2
2
 
3
- The official CLI for [Stoaken](https://stoaken.com) -- a design token management platform (Brands, Token Sets, Themes, DTCG-conformant token storage, versioned Releases with an approval gate) that AI coding agents and MCP-capable tools can read and propose changes to directly.
3
+ The official CLI for [Stoaken](https://www.stoaken.com) -- a design token management platform (Brands, Token Sets, Themes, DTCG-conformant token storage, versioned Releases with an approval gate) that AI coding agents and MCP-capable tools can read and propose changes to directly.
4
4
 
5
5
  ## Quickstart
6
6
 
@@ -13,10 +13,35 @@ Run it in the root of the project you want connected to a Stoaken Brand. It will
13
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`.
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
- 4. Generate `.stoaken/SKILL.md` -- a short brief for AI coding agents (Claude Code, Cursor) explaining how to connect to this project's Stoaken MCP server and what its tools do.
16
+ 4. Ask which AI tool to connect (or take `--client`) and write that tool's MCP config.
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.
17
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
20
 
21
+ ## Connect an AI tool
22
+
23
+ ```
24
+ npx stoaken init --client cursor
25
+ ```
26
+
27
+ | `--client` | What init writes |
28
+ | --- | --- |
29
+ | `claude-code` | `.mcp.json` (`mcpServers.stoaken`, `type: http`) |
30
+ | `cursor` | `.cursor/mcp.json` (`mcpServers.stoaken`) |
31
+ | `codex` | `.codex/config.toml` (`[mcp_servers.stoaken]`; loads for trusted projects only) |
32
+ | `copilot` | `.vscode/mcp.json` (`servers.stoaken`, VS Code agent mode) |
33
+ | `claude-desktop` | Nothing: prints the steps to add a custom connector (Customize > Connectors) |
34
+ | `v0` | Nothing: prints the steps to add a custom MCP server in v0 (+ > MCPs) |
35
+
36
+ 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:
39
+
40
+ - 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).
42
+ - 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).
44
+
20
45
  ## Authentication
21
46
 
22
47
  Two paths, both landing on the same bearer credential:
@@ -24,9 +49,59 @@ Two paths, both landing on the same bearer credential:
24
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.
25
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.
26
51
 
52
+ ## Export your tokens as code
53
+
54
+ `stoaken export` writes a theme's live release as a file: CSS variables, W3C JSON, Style
55
+ Dictionary v4 JSON, or a JS/TS module. There are no timestamps in the output, so committed files
56
+ give clean diffs.
57
+
58
+ ```sh
59
+ npx stoaken export --theme Default --format css --out src/styles/tokens.css
60
+ npx stoaken export --theme Default --format json-dtcg --mode color-scheme:dark --out tokens/dark.json
61
+ npx stoaken export --theme Default --format ts --out src/tokens.ts
62
+ npx stoaken export --theme Default --out - # to stdout
63
+ ```
64
+
65
+ | Option | |
66
+ | --- | --- |
67
+ | `--theme <name\|id>` | Which theme (needed when the Brand has more than one) |
68
+ | `--format` | `css` (default), `json-dtcg`, `json-sd4`, `js`, `ts` |
69
+ | `--mode <key>` | A mode combination for the one-mode formats; CSS includes every mode |
70
+ | `--prefix <prefix>` | CSS only: a prefix for every variable name |
71
+ | `--release <id>` | One release (e.g. one still in review) instead of the live one |
72
+ | `--out <file\|->` | Where to write; default is the server's file name |
73
+ | `--check` | Write nothing; exit 1 if the file on disk is out of date |
74
+
75
+ Save your targets in `stoaken.config.json` and a plain `npx stoaken export` runs them all
76
+ (`npx stoaken export --check` checks them all):
77
+
78
+ ```json
79
+ {
80
+ "brandId": "…",
81
+ "brandName": "Acme",
82
+ "serverUrl": "https://www.stoaken.com",
83
+ "exports": [
84
+ { "theme": "Default", "format": "css", "out": "src/styles/tokens.css" },
85
+ { "theme": "Default", "format": "ts", "out": "src/tokens.ts" }
86
+ ]
87
+ }
88
+ ```
89
+
90
+ **In CI**, set `STOAKEN_TOKEN` to an API key (Settings > API keys); it's used instead of the
91
+ sign-in saved by `stoaken init`. For example, to fail a build when the committed tokens are behind
92
+ the live release:
93
+
94
+ ```sh
95
+ STOAKEN_TOKEN=${{ secrets.STOAKEN_TOKEN }} npx stoaken export --check
96
+ ```
97
+
98
+ `--check` ignores CRLF vs LF line endings (unreleased change, Stage 332), so Windows runners
99
+ that check files out as CRLF don't fail it. Ready-made pipelines for GitHub Actions, GitLab CI,
100
+ Bitbucket Pipelines, Azure Pipelines and Vercel builds: see /docs/webhooks#ci and /docs/cli#ci.
101
+
27
102
  ## What this connects to
28
103
 
29
- Once set up, any MCP-capable client (Claude Code, Cursor, Claude Desktop) or a direct HTTP client can use the credential in `.stoaken/credentials.json` as a Bearer token against Stoaken's MCP server, which exposes four tools:
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):
30
105
 
31
106
  - `list_brands` -- every Brand you hold at least Viewer on.
32
107
  - `get_theme_tokens(themeId)` -- a Theme's cascade-merged token tree.
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import * as p3 from "@clack/prompts";
4
+ import * as p4 from "@clack/prompts";
5
5
 
6
6
  // src/commands/init.ts
7
7
  import * as p2 from "@clack/prompts";
@@ -44,6 +44,14 @@ async function writeCredentials(cwd, credentials) {
44
44
  await writeFile(path, `${JSON.stringify(credentials, null, 2)}
45
45
  `, { encoding: "utf8", mode: 384 });
46
46
  }
47
+ async function readCredentials(cwd) {
48
+ try {
49
+ const raw = await readFile(join(cwd, CREDENTIALS_FILE), "utf8");
50
+ return JSON.parse(raw);
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
47
55
  async function readOAuthClient(cwd) {
48
56
  try {
49
57
  const raw = await readFile(join(cwd, OAUTH_CLIENT_FILE), "utf8");
@@ -121,7 +129,7 @@ function generatePkce() {
121
129
  return { verifier, challenge };
122
130
  }
123
131
  function waitForCallback() {
124
- return new Promise((resolve, reject) => {
132
+ return new Promise((resolve2, reject) => {
125
133
  const server = createServer((req, res) => {
126
134
  const url = new URL(req.url ?? "/", REDIRECT_URI);
127
135
  if (url.pathname !== "/callback") {
@@ -140,7 +148,7 @@ function waitForCallback() {
140
148
  } else if (!code) {
141
149
  reject(new Error("No authorization code in the callback"));
142
150
  } else {
143
- resolve(code);
151
+ resolve2(code);
144
152
  }
145
153
  });
146
154
  server.on("error", reject);
@@ -189,8 +197,8 @@ async function promptForApiKey() {
189
197
  const key = await p.password({
190
198
  message: "Paste your Stoaken API key (from /settings/api-keys):",
191
199
  validate: (value) => {
192
- if (!value?.startsWith("twk_")) {
193
- return "That doesn't look like a Stoaken API key (should start with twk_)";
200
+ if (!value?.startsWith("stk_") && !value?.startsWith("twk_")) {
201
+ return "That doesn't look like a Stoaken API key (should start with stk_)";
194
202
  }
195
203
  }
196
204
  });
@@ -201,13 +209,12 @@ async function promptForApiKey() {
201
209
  }
202
210
 
203
211
  // src/mcp-client.ts
204
- import { Client } from "@modelcontextprotocol/sdk/client/index.js";
205
- import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
212
+ import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
206
213
  async function listBrands(serverUrl, credential) {
207
214
  const transport = new StreamableHTTPClientTransport(new URL("/mcp", serverUrl), {
208
215
  requestInit: { headers: { Authorization: `Bearer ${credential}` } }
209
216
  });
210
- const client = new Client({ name: "stoaken-cli", version: "0.1.0" });
217
+ const client = new Client({ name: "stoaken-cli", version: "0.4.0" });
211
218
  await client.connect(transport);
212
219
  try {
213
220
  const result = await client.callTool({ name: "list_brands", arguments: {} });
@@ -221,20 +228,178 @@ async function listBrands(serverUrl, credential) {
221
228
  }
222
229
  }
223
230
 
231
+ // src/client-config.ts
232
+ var CLIENTS = ["claude-code", "claude-desktop", "cursor", "codex", "copilot", "v0"];
233
+ var SERVER_NAME = "stoaken";
234
+ var API_KEY_ENV = "STOAKEN_API_KEY";
235
+ var CLIENT_LABELS = {
236
+ "claude-code": "Claude Code",
237
+ "claude-desktop": "Claude Desktop / claude.ai",
238
+ cursor: "Cursor",
239
+ codex: "OpenAI Codex",
240
+ copilot: "GitHub Copilot (VS Code)",
241
+ v0: "v0 (Vercel)"
242
+ };
243
+ function isClient(value) {
244
+ return CLIENTS.includes(value);
245
+ }
246
+ function mcpUrl(serverUrl) {
247
+ return `${serverUrl.replace(/\/+$/, "")}/mcp`;
248
+ }
249
+ function planFor(client, auth, serverUrl) {
250
+ const url = mcpUrl(serverUrl);
251
+ const keyStep = `Set ${API_KEY_ENV} in your shell to an API key from Settings > API keys (never commit it).`;
252
+ switch (client) {
253
+ case "claude-code":
254
+ return {
255
+ file: ".mcp.json",
256
+ nextSteps: auth === "oauth" ? ["Start Claude Code, approve the project server, then run /mcp and choose stoaken to sign in."] : [keyStep, "Start Claude Code and approve the project server."]
257
+ };
258
+ case "cursor":
259
+ return {
260
+ file: ".cursor/mcp.json",
261
+ nextSteps: auth === "oauth" ? ["Open Cursor Settings > MCP, enable stoaken and click Connect to sign in."] : [keyStep, "Restart Cursor so it picks up the variable, then enable stoaken in Settings > MCP."]
262
+ };
263
+ case "codex":
264
+ return {
265
+ file: ".codex/config.toml",
266
+ nextSteps: auth === "oauth" ? ["Trust this project in Codex (project config only loads for trusted projects).", "Run: codex mcp login stoaken"] : [keyStep, "Trust this project in Codex (project config only loads for trusted projects)."]
267
+ };
268
+ case "copilot":
269
+ return {
270
+ 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."]
272
+ };
273
+ case "claude-desktop":
274
+ return {
275
+ file: null,
276
+ nextSteps: [
277
+ "Claude Desktop and claude.ai add remote servers as connectors, not through a project file.",
278
+ "Customize > Connectors > + Add > Add custom connector.",
279
+ `Name: Stoaken. URL: ${url}. Sign in with OAuth (Register automatically), or add the header Authorization: Bearer <API key>.`,
280
+ "On Team/Enterprise only an Owner can add it. Connections come from Anthropic's cloud, so this needs the public server."
281
+ ]
282
+ };
283
+ case "v0":
284
+ return {
285
+ file: null,
286
+ nextSteps: [
287
+ "v0 adds MCP servers in its own UI, not through a project file.",
288
+ "In a v0 chat: + > MCPs > custom server.",
289
+ `URL: ${url}. Auth: OAuth, or Bearer Token with an API key from Settings > API keys.`,
290
+ "v0 uses the tools while it generates; the app it builds cannot call them."
291
+ ]
292
+ };
293
+ }
294
+ }
295
+ function jsonEntry(client, auth, serverUrl) {
296
+ const url = mcpUrl(serverUrl);
297
+ if (client === "claude-code") {
298
+ return auth === "oauth" ? { type: "http", url } : { type: "http", url, headers: { Authorization: `Bearer \${${API_KEY_ENV}}` } };
299
+ }
300
+ if (client === "cursor") {
301
+ return auth === "oauth" ? { url } : { url, headers: { Authorization: `Bearer \${env:${API_KEY_ENV}}` } };
302
+ }
303
+ return auth === "oauth" ? { type: "http", url } : { type: "http", url, headers: { Authorization: "Bearer ${input:stoaken-api-key}" } };
304
+ }
305
+ var ConfigMergeError = class extends Error {
306
+ };
307
+ function parseObject(existing, file) {
308
+ if (existing === null || existing.trim() === "") return {};
309
+ let parsed;
310
+ try {
311
+ parsed = JSON.parse(existing);
312
+ } catch {
313
+ throw new ConfigMergeError(`${file} isn't plain JSON (comments?), so it was left alone. Add the stoaken entry by hand.`);
314
+ }
315
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
316
+ throw new ConfigMergeError(`${file} isn't a JSON object, so it was left alone.`);
317
+ }
318
+ return parsed;
319
+ }
320
+ var COPILOT_INPUT = {
321
+ type: "promptString",
322
+ id: "stoaken-api-key",
323
+ description: "Stoaken API key (Settings > API keys)",
324
+ password: true
325
+ };
326
+ function mergeJsonConfig(client, existing, auth, serverUrl) {
327
+ const file = planFor(client, auth, serverUrl).file;
328
+ const doc = parseObject(existing, file);
329
+ const key = client === "copilot" ? "servers" : "mcpServers";
330
+ const current = doc[key];
331
+ if (current !== void 0 && (typeof current !== "object" || current === null || Array.isArray(current))) {
332
+ throw new ConfigMergeError(`${file} has a "${key}" that isn't an object, so it was left alone.`);
333
+ }
334
+ doc[key] = { ...current, [SERVER_NAME]: jsonEntry(client, auth, serverUrl) };
335
+ if (client === "copilot") {
336
+ const inputs = Array.isArray(doc.inputs) ? doc.inputs : [];
337
+ const others = inputs.filter((i) => !(typeof i === "object" && i !== null && i.id === COPILOT_INPUT.id));
338
+ if (auth === "api_key") doc.inputs = [...others, COPILOT_INPUT];
339
+ else if (doc.inputs !== void 0) doc.inputs = others;
340
+ }
341
+ return `${JSON.stringify(doc, null, 2)}
342
+ `;
343
+ }
344
+ function codexTable(auth, serverUrl) {
345
+ const lines = [`[mcp_servers.${SERVER_NAME}]`, `url = ${JSON.stringify(mcpUrl(serverUrl))}`];
346
+ if (auth === "api_key") lines.push(`bearer_token_env_var = "${API_KEY_ENV}"`);
347
+ return `${lines.join("\n")}
348
+ `;
349
+ }
350
+ function mergeCodexToml(existing, auth, serverUrl) {
351
+ const table = codexTable(auth, serverUrl);
352
+ if (existing === null || existing.trim() === "") return table;
353
+ const header = /^\s*\[\s*mcp_servers\.(?:stoaken|"stoaken")\s*(?:\]|\.)/;
354
+ const anyHeader = /^\s*\[/;
355
+ const kept = [];
356
+ let skipping = false;
357
+ for (const line of existing.split("\n")) {
358
+ if (header.test(line)) {
359
+ skipping = true;
360
+ continue;
361
+ }
362
+ if (skipping && anyHeader.test(line)) skipping = false;
363
+ if (!skipping) kept.push(line);
364
+ }
365
+ const body = kept.join("\n").replace(/\n+$/, "");
366
+ return body === "" ? table : `${body}
367
+
368
+ ${table}`;
369
+ }
370
+
224
371
  // src/skill-template.ts
225
- function generateSkillFile(config) {
372
+ function connectSection(target, serverUrl) {
373
+ if (!target) {
374
+ return `Connect with the credential in \`.stoaken/credentials.json\` as a Bearer token (never commit that
375
+ file -- it's already in \`.gitignore\`).
376
+ `;
377
+ }
378
+ const { client, auth } = target;
379
+ 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`;
381
+ 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
+ return `${where}
383
+
384
+ ${plan.nextSteps.map((step) => `- ${step}`).join("\n")}
385
+ `;
386
+ }
387
+ function generateSkillFile(config, client) {
226
388
  return `# Working with Stoaken
227
389
 
228
390
  This project is connected to the **${config.brandName}** Brand on Stoaken (\`${config.serverUrl}\`).
229
391
 
230
- Stoaken's MCP server exposes four tools. Connect to it at \`${config.serverUrl}/mcp\` using
231
- the credential in \`.stoaken/credentials.json\` as a Bearer token (never commit that file --
232
- it's already in \`.gitignore\`).
233
-
392
+ Stoaken's MCP server exposes the tools below at \`${mcpUrl(config.serverUrl)}\`.
393
+ ${connectSection(client, config.serverUrl)}
234
394
  ## Tools
235
395
 
236
396
  - **list_brands** -- every Brand you hold at least Viewer on.
237
- - **get_theme_tokens(themeId)** -- a Theme's cascade-merged token tree.
397
+ - **get_theme_tokens(themeId)** -- a Theme's cascade-merged token tree (aliases left raw).
398
+ - **get_resolved_theme_tokens(themeId, mode?)** -- a Theme's fully resolved tokens (aliases followed,
399
+ inheritance applied) for every mode-combination, or just \`mode\`.
400
+ - **list_deprecated_tokens(themeId, mode?)** -- deprecated tokens, each with its notice, its named
401
+ replacement, and the tokens still referencing it. **Don't use a deprecated token in new code --
402
+ use its replacement.** A resolved token carrying \`$deprecated\` is deprecated.
238
403
  - **list_releases(themeId)** -- a Theme's Releases with version, status, and change summary.
239
404
  - **propose_token_change(themeId, tokenSetId, document, version, versionType)** -- updates a Token
240
405
  Set's document and submits a new Release for approval. **This never publishes.** The Release
@@ -242,6 +407,16 @@ it's already in \`.gitignore\`).
242
407
  Command Center before it goes live -- exactly the same gate a person editing through the UI
243
408
  goes through. There is no tool that publishes a Release; propose changes, then ask a human to
244
409
  review them.
410
+ - **list_webhooks / create_webhook / update_webhook / rotate_webhook_secret / ping_webhook** -- manage a Brand's webhooks (Admin on the Brand; Team plan). A created or rotated secret is returned once; after a rotation the old secret keeps signing for 24 hours.
411
+ - **create_theme / update_theme / delete_theme** -- manage a Brand's themes: which token sets they stack and in what order (Editor on the Brand; Team plan). create_theme can copy another theme's modes. Settings, not token changes: no release needed. Only never-released themes can be deleted.
412
+ - **export_tokens(themeId | releaseId, format, mode?, prefix?)** -- read-only: a live release as a file (css, css-auto, scss, tailwind, tailwind4, swift, android, compose, json-dtcg, json-sd4, js, ts). Use it to update a project's committed token file.
413
+ - **propose_token_edits(themeId, tokenSetId, operations, version, versionType, baseHash?)** -- the
414
+ same review gate, but with targeted edits instead of a whole document: \`set\`, \`add\`,
415
+ \`rename\` (references follow), \`delete\`, \`rename_group\` and \`describe\`, applied all or
416
+ nothing. Prefer it to propose_token_change for small changes.
417
+ - **propose_token_deprecation(themeId, tokenPath, action, replacement?, note?, version)** --
418
+ deprecates (or un-deprecates) one token, naming its replacement, and submits a Release for review
419
+ the same way. Deprecation is a reviewed change, never a silent edit.
245
420
 
246
421
  ## This Brand
247
422
 
@@ -249,12 +424,21 @@ it's already in \`.gitignore\`).
249
424
  - Brand name: ${config.brandName}
250
425
  - Server: ${config.serverUrl}
251
426
 
427
+ ## Tokens as code
428
+
429
+ \`npx stoaken export --theme <name> --format css|css-auto|scss|tailwind|tailwind4|swift|android|compose|json-dtcg|json-sd4|js|ts --out <file>\` writes a
430
+ theme's live release as a file (no timestamps, so diffs stay clean). \`--check\` fails when the
431
+ committed file is out of date. In CI, set \`STOAKEN_TOKEN\` to an API key.
432
+
252
433
  Re-run \`stoaken init\` if you need to switch Brands or re-authenticate.
253
434
  `;
254
435
  }
255
436
 
256
437
  // src/commands/init.ts
257
- var DEFAULT_SERVER_URL = "https://stoaken.com";
438
+ import { existsSync as existsSync2 } from "fs";
439
+ import { mkdir as mkdir2, readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
440
+ import { dirname, join as join2 } from "path";
441
+ var DEFAULT_SERVER_URL = "https://www.stoaken.com";
258
442
  async function runInit(options) {
259
443
  const cwd = process.cwd();
260
444
  const serverUrl = options.server ?? DEFAULT_SERVER_URL;
@@ -340,13 +524,354 @@ async function runInit(options) {
340
524
  await writeCredentials(cwd, { kind: credentialKind, token: credential });
341
525
  skipToWrite = { brandId: brand.id, brandName: brand.name };
342
526
  }
343
- const config = { brandId: skipToWrite.brandId, brandName: skipToWrite.brandName, serverUrl };
527
+ const keepExports = existing?.brandId === skipToWrite.brandId && existing.exports !== void 0;
528
+ const config = {
529
+ brandId: skipToWrite.brandId,
530
+ brandName: skipToWrite.brandName,
531
+ serverUrl,
532
+ ...keepExports ? { exports: existing.exports } : {}
533
+ };
344
534
  await writeConfig(cwd, config);
345
535
  await ensureGitignored(cwd);
346
- await writeSkillFile(cwd, generateSkillFile(config));
536
+ let client = null;
537
+ if (options.client) {
538
+ client = options.client;
539
+ } else {
540
+ const pick = await p2.select({
541
+ message: "Which AI tool should connect to Stoaken?",
542
+ options: [
543
+ ...CLIENTS.map((c) => ({ value: c, label: CLIENT_LABELS[c] })),
544
+ { value: "none", label: "None for now (just write .stoaken/SKILL.md)" }
545
+ ]
546
+ });
547
+ if (p2.isCancel(pick)) {
548
+ p2.cancel("Cancelled.");
549
+ return;
550
+ }
551
+ client = pick === "none" ? null : pick;
552
+ }
553
+ const auth = (await readCredentials(cwd))?.kind === "api_key" ? "api_key" : "oauth";
554
+ if (client) {
555
+ const plan = planFor(client, auth, serverUrl);
556
+ if (plan.file) {
557
+ const path = join2(cwd, plan.file);
558
+ const existingText = existsSync2(path) ? await readFile2(path, "utf8") : null;
559
+ try {
560
+ const next = client === "codex" ? mergeCodexToml(existingText, auth, serverUrl) : mergeJsonConfig(client, existingText, auth, serverUrl);
561
+ await mkdir2(dirname(path), { recursive: true });
562
+ await writeFile2(path, next, "utf8");
563
+ p2.log.success(`${existingText === null ? "Wrote" : "Updated"} ${plan.file} (other servers kept).`);
564
+ } catch (error) {
565
+ if (!(error instanceof ConfigMergeError)) throw error;
566
+ p2.log.warn(error.message);
567
+ }
568
+ }
569
+ p2.note(plan.nextSteps.map((s) => `- ${s}`).join("\n"), `Next, in ${CLIENT_LABELS[client]}`);
570
+ }
571
+ await writeSkillFile(cwd, generateSkillFile(config, client ? { client, auth } : void 0));
347
572
  p2.outro(`Done -- this project is set up for ${config.brandName}.`);
348
573
  }
349
574
 
575
+ // src/commands/export.ts
576
+ import { mkdir as mkdir3, readFile as readFile3, writeFile as writeFile3 } from "fs/promises";
577
+ import { dirname as dirname2, relative, resolve } from "path";
578
+ import * as p3 from "@clack/prompts";
579
+
580
+ // src/api.ts
581
+ var ApiProblem = class extends Error {
582
+ constructor(problem) {
583
+ super(problem.detail ?? problem.title ?? `HTTP ${problem.status}`);
584
+ this.problem = problem;
585
+ this.name = "ApiProblem";
586
+ }
587
+ problem;
588
+ };
589
+ async function call(serverUrl, token, path) {
590
+ let response;
591
+ try {
592
+ response = await fetch(new URL(path, serverUrl), {
593
+ headers: { Authorization: `Bearer ${token}`, "User-Agent": "stoaken-cli/0.4.0" }
594
+ });
595
+ } catch (error) {
596
+ throw new Error(`Couldn't reach ${serverUrl}: ${error instanceof Error ? error.message : String(error)}`);
597
+ }
598
+ if (!response.ok) {
599
+ let body = {};
600
+ try {
601
+ body = await response.json();
602
+ } catch {
603
+ }
604
+ throw new ApiProblem({ ...body, status: response.status });
605
+ }
606
+ return response;
607
+ }
608
+ async function listThemes(serverUrl, token, brandId) {
609
+ const themes = [];
610
+ let cursor = null;
611
+ for (let page = 0; page < 50; page++) {
612
+ const query = new URLSearchParams({ limit: "100" });
613
+ if (cursor) query.set("cursor", cursor);
614
+ const response = await call(serverUrl, token, `/api/brands/${encodeURIComponent(brandId)}/themes?${query}`);
615
+ const body = await response.json();
616
+ themes.push(...body.data.map((t) => ({ id: t.id, name: t.name })));
617
+ cursor = body.next_cursor;
618
+ if (!cursor) break;
619
+ }
620
+ return themes;
621
+ }
622
+ async function fetchExport(serverUrl, token, target) {
623
+ const query = new URLSearchParams({ format: target.format });
624
+ if (target.mode) query.set("mode", target.mode);
625
+ if (target.prefix) query.set("prefix", target.prefix);
626
+ const path = target.releaseId ? `/api/releases/${encodeURIComponent(target.releaseId)}/export?${query}` : `/api/themes/${encodeURIComponent(target.themeId ?? "")}/export?${query}`;
627
+ const response = await call(serverUrl, token, path);
628
+ return {
629
+ text: await response.text(),
630
+ filename: response.headers.get("content-disposition"),
631
+ warnings: Number(response.headers.get("stoaken-export-warnings") ?? 0) || 0
632
+ };
633
+ }
634
+
635
+ // src/export-plan.ts
636
+ var EXPORT_FORMATS = ["css", "css-auto", "json-dtcg", "json-sd4", "js", "ts", "scss", "tailwind", "swift", "android", "tailwind4", "compose"];
637
+ var ExportUsageError = class extends Error {
638
+ constructor(message) {
639
+ super(message);
640
+ this.name = "ExportUsageError";
641
+ }
642
+ };
643
+ var VALUE_FLAGS = ["theme", "format", "mode", "prefix", "release", "out", "server"];
644
+ function isExportFormat(value) {
645
+ return typeof value === "string" && EXPORT_FORMATS.includes(value);
646
+ }
647
+ function parseExportArgs(argv) {
648
+ const values = {};
649
+ let check = false;
650
+ for (let i = 0; i < argv.length; i++) {
651
+ const arg = argv[i];
652
+ if (arg === "--check") {
653
+ check = true;
654
+ continue;
655
+ }
656
+ const match = /^--([a-z]+)(?:=(.*))?$/.exec(arg);
657
+ const name = match?.[1];
658
+ if (!match || !name || !VALUE_FLAGS.includes(name)) {
659
+ throw new ExportUsageError(`Unknown option: ${arg}. Run "stoaken export --help" for the options.`);
660
+ }
661
+ let value = match[2];
662
+ if (value === void 0) {
663
+ value = argv[i + 1];
664
+ i++;
665
+ }
666
+ if (value === void 0 || value.startsWith("--") && value !== "-") {
667
+ throw new ExportUsageError(`--${name} needs a value.`);
668
+ }
669
+ values[name] = value;
670
+ }
671
+ if (values.format !== void 0 && !isExportFormat(values.format)) {
672
+ throw new ExportUsageError(`Unknown format "${values.format}". Use one of: ${EXPORT_FORMATS.join(", ")}.`);
673
+ }
674
+ const hasTarget = ["theme", "format", "mode", "prefix", "release", "out"].some((k) => values[k] !== void 0);
675
+ return {
676
+ check,
677
+ server: values.server,
678
+ target: hasTarget ? {
679
+ theme: values.theme,
680
+ format: values.format ?? "css",
681
+ mode: values.mode,
682
+ prefix: values.prefix,
683
+ release: values.release,
684
+ out: values.out
685
+ } : null
686
+ };
687
+ }
688
+ function readSavedTargets(exportsValue) {
689
+ if (exportsValue === void 0 || exportsValue === null) return [];
690
+ if (!Array.isArray(exportsValue)) throw new ExportUsageError('"exports" in stoaken.config.json must be a list.');
691
+ return exportsValue.map((raw, i) => {
692
+ const where = `"exports" item ${i + 1} in stoaken.config.json`;
693
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new ExportUsageError(`${where} must be an object.`);
694
+ const item = raw;
695
+ for (const key of Object.keys(item)) {
696
+ if (!["theme", "format", "mode", "prefix", "release", "out"].includes(key)) {
697
+ throw new ExportUsageError(`${where} has an unknown field "${key}".`);
698
+ }
699
+ if (typeof item[key] !== "string" || item[key] === "") throw new ExportUsageError(`${where}: "${key}" must be text.`);
700
+ }
701
+ const format = item.format ?? "css";
702
+ if (!isExportFormat(format)) throw new ExportUsageError(`${where}: unknown format "${String(format)}". Use one of: ${EXPORT_FORMATS.join(", ")}.`);
703
+ return {
704
+ theme: item.theme,
705
+ format,
706
+ mode: item.mode,
707
+ prefix: item.prefix,
708
+ release: item.release,
709
+ out: item.out
710
+ };
711
+ });
712
+ }
713
+ function chooseTheme(themes, wanted, brandName, interactive) {
714
+ const names = themes.map((t) => t.name).join(", ");
715
+ if (themes.length === 0) throw new ExportUsageError(`${brandName} has no themes yet.`);
716
+ if (wanted !== void 0) {
717
+ const byId = themes.find((t) => t.id === wanted);
718
+ if (byId) return { kind: "theme", theme: byId };
719
+ const byName = themes.filter((t) => t.name.trim().toLowerCase() === wanted.trim().toLowerCase());
720
+ if (byName.length === 1) return { kind: "theme", theme: byName[0] };
721
+ if (byName.length > 1) throw new ExportUsageError(`More than one theme is called "${wanted}". Use its id instead.`);
722
+ throw new ExportUsageError(`${brandName} has no theme called "${wanted}". Themes: ${names}.`);
723
+ }
724
+ if (themes.length === 1) return { kind: "theme", theme: themes[0] };
725
+ if (interactive) return { kind: "ask", themes };
726
+ throw new ExportUsageError(`${brandName} has ${themes.length} themes, so name one with --theme. Themes: ${names}.`);
727
+ }
728
+ function filenameFrom(disposition, fallback) {
729
+ const match = disposition ? /filename="?([^";]+)"?/i.exec(disposition) : null;
730
+ const name = (match?.[1] ?? fallback).replace(/[/\\]/g, "-").replace(/^\.+/, "");
731
+ return name || fallback;
732
+ }
733
+ function problemMessage(problem, context) {
734
+ const what = context.themeName ? `"${context.themeName}"` : "this theme";
735
+ switch (problem.status) {
736
+ case 401:
737
+ return context.usingEnvToken ? "Stoaken didn't accept STOAKEN_TOKEN. Check it's a current API key (Settings > API keys)." : `Stoaken didn't accept your saved sign-in. Run "stoaken init" again, or set STOAKEN_TOKEN to an API key.`;
738
+ case 403:
739
+ case 404:
740
+ return `You can't see ${what}, or it doesn't exist. Check the Brand in stoaken.config.json and your access.`;
741
+ case 409:
742
+ return problem.detail ? `${context.themeName ? `"${context.themeName}": ` : ""}${problem.detail}` : `${context.themeName ? `"${context.themeName}" has` : "This has"} nothing to export yet: approve a release first.`;
743
+ case 400: {
744
+ const modes = problem.available_modes?.filter((m) => m !== "");
745
+ if (problem.available_modes && modes?.length === 0) return "This theme has no modes, so leave out --mode.";
746
+ const extra = modes?.length ? ` Modes: ${modes.join(", ")}.` : problem.available_formats?.length ? ` Formats: ${problem.available_formats.join(", ")}.` : "";
747
+ return `${problem.detail ?? problem.title ?? "Stoaken refused the request."}${extra}`;
748
+ }
749
+ case 429:
750
+ return "Too many requests. Wait a minute and try again.";
751
+ default:
752
+ return `Stoaken answered ${problem.status}${problem.detail ? `: ${problem.detail}` : problem.title ? `: ${problem.title}` : "."}`;
753
+ }
754
+ }
755
+ function isUpToDate(onDisk, live) {
756
+ if (onDisk === null) return false;
757
+ const lf = (s) => s.replace(/\r\n/g, "\n");
758
+ return lf(onDisk) === lf(live);
759
+ }
760
+ function formatBytes(n) {
761
+ return n < 1024 ? `${n} B` : `${(n / 1024).toFixed(1)} KB`;
762
+ }
763
+
764
+ // src/commands/export.ts
765
+ var EXPORT_HELP = [
766
+ "stoaken export -- write your tokens as code",
767
+ "",
768
+ "Usage:",
769
+ " stoaken export [options]",
770
+ "",
771
+ "Options:",
772
+ " --theme <name|id> Which theme (needed when the Brand has more than one)",
773
+ ` --format <format> ${EXPORT_FORMATS.join(", ")} (default css)`,
774
+ " --mode <key> A mode combination for the one-mode formats, e.g. color-scheme:dark",
775
+ " --prefix <prefix> CSS only: a prefix for every variable name",
776
+ " --release <id> Export one release instead of the theme's live one",
777
+ " --out <file|-> Where to write it (- for stdout); default: the server's file name",
778
+ " --check Write nothing; fail if the file on disk is out of date",
779
+ " --server <url> Override the server in stoaken.config.json",
780
+ "",
781
+ `With no options, every target in stoaken.config.json's "exports" list runs.`,
782
+ 'Credentials: STOAKEN_TOKEN (an API key, for CI), else the sign-in from "stoaken init".'
783
+ ].join("\n");
784
+ var say = (text) => process.stderr.write(`${text}
785
+ `);
786
+ async function runExport(argv) {
787
+ if (argv.includes("--help") || argv.includes("-h")) {
788
+ say(EXPORT_HELP);
789
+ return 0;
790
+ }
791
+ const cwd = process.cwd();
792
+ try {
793
+ const args = parseExportArgs(argv);
794
+ const config = await readConfig(cwd);
795
+ if (!config) throw new ExportUsageError('No stoaken.config.json here. Run "stoaken init" in your project first.');
796
+ const serverUrl = args.server ?? config.serverUrl;
797
+ const envToken = process.env.STOAKEN_TOKEN?.trim();
798
+ const token = envToken || (await readCredentials(cwd))?.token;
799
+ if (!token) throw new ExportUsageError('No credentials. Run "stoaken init", or set STOAKEN_TOKEN to an API key.');
800
+ const targets = args.target ? [args.target] : readSavedTargets(config.exports);
801
+ if (targets.length === 0) targets.push({ format: "css" });
802
+ let themes = null;
803
+ let failed = 0;
804
+ for (const target of targets) {
805
+ let themeName;
806
+ try {
807
+ let themeId;
808
+ if (!target.release) {
809
+ themes ??= await listThemes(serverUrl, token, config.brandId);
810
+ const choice = chooseTheme(themes, target.theme, config.brandName, Boolean(process.stdin.isTTY && process.stderr.isTTY));
811
+ const theme = choice.kind === "theme" ? choice.theme : await askForTheme(choice.themes);
812
+ if (!theme) return 1;
813
+ themeId = theme.id;
814
+ themeName = theme.name;
815
+ }
816
+ await exportOne(serverUrl, token, target, { themeId, themeName }, args.check, cwd);
817
+ } catch (error) {
818
+ failed++;
819
+ say(`\u2716 ${describe(error, themeName, Boolean(envToken))}`);
820
+ }
821
+ }
822
+ return failed > 0 ? 1 : 0;
823
+ } catch (error) {
824
+ say(`\u2716 ${describe(error, void 0, Boolean(process.env.STOAKEN_TOKEN?.trim()))}`);
825
+ return 1;
826
+ }
827
+ }
828
+ async function askForTheme(themes) {
829
+ const choice = await p3.select({ message: "Which theme?", options: themes.map((t) => ({ value: t.id, label: t.name })) });
830
+ if (p3.isCancel(choice)) {
831
+ p3.cancel("Cancelled.");
832
+ return null;
833
+ }
834
+ return themes.find((t) => t.id === choice) ?? null;
835
+ }
836
+ async function exportOne(serverUrl, token, target, theme, check, cwd) {
837
+ const file = await fetchExport(serverUrl, token, {
838
+ themeId: theme.themeId,
839
+ releaseId: target.release,
840
+ format: target.format,
841
+ mode: target.mode,
842
+ prefix: target.prefix
843
+ });
844
+ const label = theme.themeName ?? `release ${target.release}`;
845
+ const warnings = file.warnings > 0 ? ` (${file.warnings} warning${file.warnings === 1 ? "" : "s"}: some values can't be written in this format)` : "";
846
+ if (target.out === "-") {
847
+ if (check) throw new ExportUsageError("--check needs a file to compare with, not --out -.");
848
+ process.stdout.write(file.text);
849
+ if (warnings) say(`! ${label}${warnings}`);
850
+ return;
851
+ }
852
+ const path = resolve(cwd, target.out ?? filenameFrom(file.filename, `tokens.${target.format}`));
853
+ const shown = relative(cwd, path) || path;
854
+ if (check) {
855
+ let current = null;
856
+ try {
857
+ current = await readFile3(path, "utf8");
858
+ } catch {
859
+ }
860
+ if (!isUpToDate(current, file.text)) {
861
+ throw new ExportUsageError(`${shown} is out of date with ${label}${current === null ? " (it doesn't exist)" : ""}. Run "stoaken export" and commit the result.`);
862
+ }
863
+ say(`\u2714 ${shown} is up to date with ${label}`);
864
+ return;
865
+ }
866
+ await mkdir3(dirname2(path), { recursive: true });
867
+ await writeFile3(path, file.text, "utf8");
868
+ say(`\u2714 ${label} \u2192 ${shown} (${target.format}, ${formatBytes(Buffer.byteLength(file.text))})${warnings}`);
869
+ }
870
+ function describe(error, themeName, usingEnvToken) {
871
+ if (error instanceof ApiProblem) return problemMessage(error.problem, { themeName, usingEnvToken });
872
+ return error instanceof Error ? error.message : String(error);
873
+ }
874
+
350
875
  // src/index.ts
351
876
  var [command, ...rest] = process.argv.slice(2);
352
877
  function flagValue(args, name) {
@@ -354,22 +879,35 @@ function flagValue(args, name) {
354
879
  return index === -1 ? void 0 : args[index + 1];
355
880
  }
356
881
  switch (command) {
357
- case "init":
358
- await runInit({ server: flagValue(rest, "server") });
882
+ case "init": {
883
+ const client = flagValue(rest, "client");
884
+ if (client !== void 0 && !isClient(client)) {
885
+ p4.log.error(`Unknown --client "${client}". Use one of: ${CLIENTS.join(", ")}`);
886
+ process.exitCode = 1;
887
+ break;
888
+ }
889
+ await runInit({ server: flagValue(rest, "server"), client });
890
+ break;
891
+ }
892
+ case "export":
893
+ process.exitCode = await runExport(rest);
359
894
  break;
360
895
  case void 0:
361
896
  case "--help":
362
897
  case "-h":
363
- p3.log.message(
898
+ p4.log.message(
364
899
  [
365
900
  "stoaken -- Stoaken CLI",
366
901
  "",
367
902
  "Usage:",
368
- " stoaken init [--server <url>] Authenticate, pick a Brand, and set up this project"
903
+ " stoaken init [--server <url>] [--client <name>]",
904
+ " Authenticate, pick a Brand, and connect an AI tool",
905
+ ` (${CLIENTS.join(", ")})`,
906
+ " stoaken export [options] Write your tokens as code (stoaken export --help)"
369
907
  ].join("\n")
370
908
  );
371
909
  break;
372
910
  default:
373
- p3.log.error(`Unknown command: ${command}`);
911
+ p4.log.error(`Unknown command: ${command}`);
374
912
  process.exitCode = 1;
375
913
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "stoaken",
3
- "version": "0.1.0",
4
- "description": "Stoaken CLI -- authenticate, pick a Brand, and get set up for MCP/API access in one command.",
3
+ "version": "0.4.0",
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",
7
7
  "bin": {
@@ -29,7 +29,7 @@
29
29
  ],
30
30
  "author": "Stoaken",
31
31
  "engines": {
32
- "node": ">=18"
32
+ "node": ">=20"
33
33
  },
34
34
  "scripts": {
35
35
  "build": "tsup src/index.ts --format esm --clean --shims",
@@ -39,8 +39,8 @@
39
39
  },
40
40
  "dependencies": {
41
41
  "@clack/prompts": "^1.8.0",
42
- "@modelcontextprotocol/sdk": "^1.30.0",
43
- "open": "^11.0.2"
42
+ "open": "^11.0.2",
43
+ "@modelcontextprotocol/client": "^2.4.0"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@types/node": "^26.5.0",