@flareum/mcp 0.1.0 → 0.2.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.
package/README.md CHANGED
@@ -47,6 +47,40 @@ so, because "no results" reads as "no such token exists" and gets acted on.
47
47
 
48
48
  ---
49
49
 
50
+ ## Pulling the stylesheets
51
+
52
+ The tools tell an agent what a token is CALLED. The stylesheets that give those names a value come
53
+ from a Push, and this fetches them:
54
+
55
+ ```bash
56
+ npx flareum pull # into src/styles/flareum
57
+ npx flareum watch # and again every time you Push in Flareum
58
+ ```
59
+
60
+ Then import them once, in your global stylesheet:
61
+
62
+ ```scss
63
+ @use './styles/flareum/main';
64
+ ```
65
+
66
+ It copies the files a Push generated — it never rebuilds CSS from the token data, so a name in the
67
+ file and a name the tools report cannot drift.
68
+
69
+ `watch` polls for a new published version and pulls when one appears. A save in Flareum changes
70
+ nothing; only a Push does.
71
+
72
+ ### Where the key comes from
73
+
74
+ `--token`, then `FLAREUM_TOKEN`, then `token` in `.flareum/config.json`:
75
+
76
+ ```json
77
+ { "token": "pk_…", "out": "src/styles/flareum" }
78
+ ```
79
+
80
+ **Add `.flareum/` to your `.gitignore`** — that file holds a key.
81
+
82
+ ---
83
+
50
84
  ## Configuration
51
85
 
52
86
  | Variable | |
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/env node
2
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
3
+ import { dirname, join, resolve } from 'node:path';
4
+ import { FlareumClient } from './client.js';
5
+ import { CONFIG_PATH, cliErrorReport, parseArgs, resolveConfig } from './config.js';
6
+ import { pullReport, pullStyles } from './pull.js';
7
+ import { DEFAULT_INTERVAL_MS, watchPublished, watchStartedReport } from './watch.js';
8
+ const DEFAULT_OUT = 'src/styles/flareum';
9
+ const HELP = `flareum — read a Flareum project's design tokens
10
+
11
+ flareum pull [--out <dir>] [--token pk_…] [--api <url>]
12
+ Write the stylesheets the last push published into <dir>
13
+ (default: ${DEFAULT_OUT}).
14
+
15
+ flareum watch [--out <dir>] [--interval <seconds>]
16
+ Stay running and pull again every time you Push in Flareum.
17
+
18
+ The key is taken from --token, then FLAREUM_TOKEN, then "token" in ${CONFIG_PATH}.
19
+ Mint one in the project's Connect screen in Flareum.`;
20
+ const readConfigFile = async () => readFile(join(process.cwd(), CONFIG_PATH), 'utf8').then(JSON.parse).catch(() => null);
21
+ const { command, flags } = parseArgs(process.argv.slice(2));
22
+ const COMMANDS = ['pull', 'watch'];
23
+ if (!COMMANDS.includes(command)) {
24
+ console.log(HELP);
25
+ process.exit(command ? 1 : 0);
26
+ }
27
+ try {
28
+ const config = resolveConfig({ flags, env: process.env, file: await readConfigFile() });
29
+ const outDir = config.out ?? DEFAULT_OUT;
30
+ const root = resolve(process.cwd(), outDir);
31
+ const client = new FlareumClient({ token: config.token, api: config.api });
32
+ const write = async (path, contents) => {
33
+ const target = join(root, path);
34
+ await mkdir(dirname(target), { recursive: true });
35
+ await writeFile(target, contents, 'utf8');
36
+ };
37
+ const runPull = async () => {
38
+ const result = await pullStyles(client, write);
39
+ console.log(pullReport(result, outDir));
40
+ return result;
41
+ };
42
+ if (command === 'watch') {
43
+ const intervalMs = Number(flags.interval) > 0 ? Number(flags.interval) * 1000 : DEFAULT_INTERVAL_MS;
44
+ console.log(watchStartedReport(outDir, intervalMs));
45
+ let running = true;
46
+ process.on('SIGINT', () => { running = false; console.log('\nStopped watching.'); process.exit(0); });
47
+ await watchPublished({
48
+ publishedVersion: async () => (await client.files()).publishedVersionId,
49
+ onPublish: async () => { await runPull(); },
50
+ onError: error => console.error(error instanceof Error ? error.message : String(error)),
51
+ sleep: ms => new Promise(done => { setTimeout(done, ms); }),
52
+ keepGoing: () => running,
53
+ }, intervalMs);
54
+ process.exit(0);
55
+ }
56
+ const result = await runPull();
57
+ process.exit(result.failed.length ? 1 : 0);
58
+ }
59
+ catch (error) {
60
+ console.error(cliErrorReport(error));
61
+ process.exit(1);
62
+ }
package/dist/client.d.ts CHANGED
@@ -21,6 +21,9 @@ export declare class FlareumClient {
21
21
  constructor({ token, api, projectId, fetchImpl }: ClientOptions);
22
22
  catalog(updatedSince?: string): Promise<CatalogResponse>;
23
23
  search(query: string, limit?: number): Promise<SearchResponse>;
24
+ /** The stylesheets the last push published. They exist only after a push, never from a save. */
25
+ files(): Promise<FilesResponse>;
26
+ file(path: string): Promise<string>;
24
27
  variable(path: string): Promise<VariableResponse>;
25
28
  }
26
29
  export type CatalogResponse = {
@@ -62,6 +65,10 @@ export type SearchResponse = {
62
65
  truncated: boolean;
63
66
  candidates: Candidate[];
64
67
  };
68
+ export type FilesResponse = {
69
+ publishedVersionId: string;
70
+ files: string[];
71
+ };
65
72
  export type VariableMode = {
66
73
  id: string;
67
74
  name: string;
package/dist/client.js CHANGED
@@ -36,8 +36,12 @@ export class FlareumClient {
36
36
  for (const [key, value] of Object.entries(query))
37
37
  if (value)
38
38
  url.searchParams.set(key, value);
39
+ // Node reports an unreachable host as a bare "fetch failed", which in a watch loop repeats
40
+ // forever without ever saying WHICH host is down.
39
41
  const response = await this.#fetch(url.toString(), {
40
42
  headers: { Authorization: `Bearer ${this.#token}`, Accept: 'application/json' },
43
+ }).catch(error => {
44
+ throw new Error(`Could not reach ${this.#api} — ${error instanceof Error ? error.message : String(error)}`);
41
45
  });
42
46
  if (response.ok)
43
47
  return response.json();
@@ -52,12 +56,31 @@ export class FlareumClient {
52
56
  : 'Retry; if it persists, check the project is reachable.',
53
57
  });
54
58
  }
59
+ async #getText(path) {
60
+ const url = `${this.#api}/api/v1/projects/${this.projectId}${path}`;
61
+ const response = await this.#fetch(url, {
62
+ headers: { Authorization: `Bearer ${this.#token}`, Accept: 'text/plain' },
63
+ });
64
+ if (response.ok)
65
+ return response.text();
66
+ const body = await response.json().catch(() => null);
67
+ throw new FlareumApiError(body?.error ?? {
68
+ code: `HTTP_${response.status}`, message: `The Flareum API returned ${response.status}.`, hint: '',
69
+ });
70
+ }
55
71
  catalog(updatedSince = '') {
56
72
  return this.#get('/catalog', { updatedSince });
57
73
  }
58
74
  search(query, limit) {
59
75
  return this.#get('/search', { q: query, ...(limit ? { limit: String(limit) } : {}) });
60
76
  }
77
+ /** The stylesheets the last push published. They exist only after a push, never from a save. */
78
+ files() {
79
+ return this.#get('/files');
80
+ }
81
+ file(path) {
82
+ return this.#getText(`/files/${path.split('/').map(encodeURIComponent).join('/')}`);
83
+ }
61
84
  variable(path) {
62
85
  return this.#get(`/variables/${path.split('/').map(encodeURIComponent).join('/')}`);
63
86
  }
@@ -0,0 +1,22 @@
1
+ export type Resolved = {
2
+ token: string;
3
+ api?: string;
4
+ out?: string;
5
+ };
6
+ export type ConfigSources = {
7
+ flags: Record<string, string>;
8
+ env: Record<string, string | undefined>;
9
+ file: Partial<Resolved> | null;
10
+ };
11
+ export declare const CONFIG_PATH = ".flareum/config.json";
12
+ export declare class MissingToken extends Error {
13
+ constructor();
14
+ }
15
+ /** A flag beats the environment beats the file — nearest to the command wins. */
16
+ export declare const resolveConfig: ({ flags, env, file }: ConfigSources) => Resolved;
17
+ /** `--out dir --token x` and `--out=dir`, both. Anything else is a positional. */
18
+ export declare const parseArgs: (argv: string[]) => {
19
+ command: string;
20
+ flags: Record<string, string>;
21
+ };
22
+ export declare const cliErrorReport: (error: unknown) => string;
package/dist/config.js ADDED
@@ -0,0 +1,58 @@
1
+ import { FlareumApiError } from './client.js';
2
+ export const CONFIG_PATH = '.flareum/config.json';
3
+ export class MissingToken extends Error {
4
+ constructor() {
5
+ // Names every place it looked, so the reader does not have to guess which one to set.
6
+ super('No Flareum key. Give it one of:\n'
7
+ + ' --token pk_…\n'
8
+ + ' FLAREUM_TOKEN=pk_… in the environment\n'
9
+ + ` a "token" in ${CONFIG_PATH}\n`
10
+ + 'Mint a key in the project\'s Connect screen in Flareum.');
11
+ this.name = 'MissingToken';
12
+ }
13
+ }
14
+ /** A flag beats the environment beats the file — nearest to the command wins. */
15
+ export const resolveConfig = ({ flags, env, file }) => {
16
+ const token = flags.token || env.FLAREUM_TOKEN || file?.token;
17
+ if (!token)
18
+ throw new MissingToken();
19
+ return {
20
+ token,
21
+ api: flags.api || env.FLAREUM_API || file?.api,
22
+ out: flags.out || file?.out,
23
+ };
24
+ };
25
+ /** `--out dir --token x` and `--out=dir`, both. Anything else is a positional. */
26
+ export const parseArgs = (argv) => {
27
+ const flags = {};
28
+ const positional = [];
29
+ for (let i = 0; i < argv.length; i += 1) {
30
+ const arg = argv[i];
31
+ if (!arg.startsWith('--')) {
32
+ positional.push(arg);
33
+ continue;
34
+ }
35
+ const [name, inline] = arg.slice(2).split('=');
36
+ if (inline !== undefined) {
37
+ flags[name] = inline;
38
+ continue;
39
+ }
40
+ const next = argv[i + 1];
41
+ if (next && !next.startsWith('--')) {
42
+ flags[name] = next;
43
+ i += 1;
44
+ }
45
+ else
46
+ flags[name] = 'true';
47
+ }
48
+ return { command: positional[0] ?? '', flags };
49
+ };
50
+ // Every CLI failure reaches a user through this, so an unhandled one must read as a sentence
51
+ // rather than the Node stack trace a rethrow prints.
52
+ export const cliErrorReport = (error) => {
53
+ if (error instanceof MissingToken)
54
+ return error.message;
55
+ if (error instanceof FlareumApiError)
56
+ return `${error.message}\n${error.hint}`;
57
+ return error instanceof Error ? error.message : String(error);
58
+ };
package/dist/pull.d.ts ADDED
@@ -0,0 +1,12 @@
1
+ import type { FlareumClient } from './client.js';
2
+ export type PullResult = {
3
+ publishedVersionId: string;
4
+ written: string[];
5
+ failed: Array<{
6
+ path: string;
7
+ reason: string;
8
+ }>;
9
+ };
10
+ export type Writer = (path: string, contents: string) => Promise<void>;
11
+ export declare const pullStyles: (client: Pick<FlareumClient, "files" | "file">, write: Writer) => Promise<PullResult>;
12
+ export declare const pullReport: ({ publishedVersionId, written, failed }: PullResult, outDir: string) => string;
package/dist/pull.js ADDED
@@ -0,0 +1,30 @@
1
+ // Copies the ARTIFACT a push generated; never rebuilds CSS from the catalog, so a name here and a
2
+ // name the API reports cannot disagree.
3
+ export const pullStyles = async (client, write) => {
4
+ const { publishedVersionId, files } = await client.files();
5
+ const written = [];
6
+ const failed = [];
7
+ for (const path of files)
8
+ try {
9
+ await write(path, await client.file(path));
10
+ written.push(path);
11
+ }
12
+ catch (error) {
13
+ // One unreadable file must not lose the rest, and must not be reported as written.
14
+ failed.push({ path, reason: error instanceof Error ? error.message : String(error) });
15
+ }
16
+ return { publishedVersionId, written, failed };
17
+ };
18
+ export const pullReport = ({ publishedVersionId, written, failed }, outDir) => {
19
+ const lines = [`Pulled ${written.length} stylesheet(s) from version ${publishedVersionId} into ${outDir}`];
20
+ for (const path of written)
21
+ lines.push(` ${path}`);
22
+ if (failed.length) {
23
+ lines.push(`${failed.length} could NOT be written:`);
24
+ for (const { path, reason } of failed)
25
+ lines.push(` ${path} — ${reason}`);
26
+ }
27
+ if (!written.length && !failed.length)
28
+ lines.push(' nothing — this version published no stylesheets.');
29
+ return lines.join('\n');
30
+ };
@@ -0,0 +1,11 @@
1
+ export type WatchDeps = {
2
+ /** The published version right now, or null when nothing is published yet. */
3
+ publishedVersion: () => Promise<string | null>;
4
+ onPublish: (version: string) => Promise<void>;
5
+ onError: (error: unknown) => void;
6
+ sleep: (ms: number) => Promise<void>;
7
+ keepGoing: () => boolean;
8
+ };
9
+ export declare const DEFAULT_INTERVAL_MS = 15000;
10
+ export declare const watchPublished: ({ publishedVersion, onPublish, onError, sleep, keepGoing }: WatchDeps, intervalMs?: number) => Promise<void>;
11
+ export declare const watchStartedReport: (outDir: string, intervalMs: number) => string;
package/dist/watch.js ADDED
@@ -0,0 +1,24 @@
1
+ export const DEFAULT_INTERVAL_MS = 15_000;
2
+ // Re-pulls when the PUBLISHED version changes — a push, never a save, so a designer mid-edit cannot
3
+ // rewrite a file the developer is looking at.
4
+ export const watchPublished = async ({ publishedVersion, onPublish, onError, sleep, keepGoing }, intervalMs = DEFAULT_INTERVAL_MS) => {
5
+ let seen = null;
6
+ while (keepGoing()) {
7
+ try {
8
+ const version = await publishedVersion();
9
+ if (version && version !== seen) {
10
+ // Marked seen only AFTER the pull lands: a failed write must be retried, not skipped.
11
+ await onPublish(version);
12
+ seen = version;
13
+ }
14
+ }
15
+ catch (error) {
16
+ // A server restart or a dropped connection must not end the watch — the next tick retries.
17
+ onError(error);
18
+ }
19
+ if (keepGoing())
20
+ await sleep(intervalMs);
21
+ }
22
+ };
23
+ export const watchStartedReport = (outDir, intervalMs) => `Watching for a Push in Flareum — pulling into ${outDir} every ${Math.round(intervalMs / 1000)}s.`
24
+ + '\nPress Ctrl+C to stop.';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flareum/mcp",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Connect a coding agent to a Flareum project's design tokens.",
5
5
  "license": "MIT",
6
6
  "author": "Flareum",
@@ -20,6 +20,7 @@
20
20
  ],
21
21
  "type": "module",
22
22
  "bin": {
23
+ "flareum": "dist/cli.js",
23
24
  "flareum-mcp": "dist/server.js"
24
25
  },
25
26
  "files": [