@flareum/mcp 0.2.2 → 0.2.4

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
@@ -50,6 +50,24 @@ npm install -g @flareum/mcp@latest
50
50
 
51
51
  ---
52
52
 
53
+ ## Removing it
54
+
55
+ ```bash
56
+ claude mcp remove flareum
57
+ ```
58
+
59
+ Run it in the same project directory you added it from — `claude mcp add` registers the server for
60
+ that directory, so a removal elsewhere finds nothing. `claude mcp list` shows what is registered. If
61
+ you added it with `-s user` or `-s project`, pass the same scope to remove it.
62
+
63
+ That takes the key with it: it lived in the `--env FLAREUM_TOKEN=…` of the entry it just deleted.
64
+ There is nothing else on disk unless you wrote a `.flareum/config.json` yourself.
65
+
66
+ **The key still works.** Removing the server is a local change — anyone holding that string can
67
+ still read the project's tokens. If it leaked, or the machine is gone, revoke it too.
68
+
69
+ ---
70
+
53
71
  ## What your agent can do with it
54
72
 
55
73
  Ask in plain language — the agent picks the tool:
@@ -108,6 +126,40 @@ nothing; only a Push does.
108
126
 
109
127
  ---
110
128
 
129
+ ## Revoking a key
130
+
131
+ A key grants read access to one project's tokens until you revoke it. Revoke as soon as one is
132
+ pasted into a chat, a ticket, a screenshot, or a repository — and when a laptop or a contractor
133
+ leaves.
134
+
135
+ **1. In Flareum**, open **Project settings → Connect**. Each connected editor is a row showing the
136
+ last four characters of its key, when it was added and when it was last used. Click the delete
137
+ button on the row you want gone.
138
+
139
+ It takes effect immediately: every later call with that key answers `401`, and the key cannot be
140
+ un-revoked. A revoked row disappears from the list rather than lingering as history — it is not a
141
+ connection any more.
142
+
143
+ If you cannot tell two rows apart, the key hint matches the last four characters of the key the
144
+ editor is using, and **Last used** tells you which one is live.
145
+
146
+ **2. On the machine that had it**, remove what is still holding the key — revoking stops it
147
+ working, it does not delete the copy:
148
+
149
+ ```bash
150
+ claude mcp remove flareum # the entry, and the key inside it
151
+ rm .flareum/config.json # only if you wrote one by hand
152
+ ```
153
+
154
+ Skip this and everything keeps trying the dead key, which reads as a broken integration rather than
155
+ a revoked one.
156
+
157
+ **3. To reconnect**, mint a new key from the same Connect screen and run the command it gives you.
158
+ Keys are per editor, not per person — connecting a second machine means minting a second key, so
159
+ losing one never means rotating the other.
160
+
161
+ ---
162
+
111
163
  ## Configuration
112
164
 
113
165
  | Variable | |
@@ -115,10 +167,24 @@ nothing; only a Push does.
115
167
  | `FLAREUM_TOKEN` | **required.** The project is read from the key itself. |
116
168
  | `FLAREUM_API` | optional. Defaults to production. Set it to point at a local Flareum. |
117
169
  | `FLAREUM_PROJECT` | optional. Overrides the project in the key. |
170
+ | `FLAREUM_OUT` | optional. Where stylesheets are written. Same meaning as `out` in the config file, which it beats. |
171
+ | `FLAREUM_AUTO_PULL` | optional. `off` stops the first-connection pull below. Anything else leaves it on. |
172
+
173
+ On start it writes `.claude/skills/flareum/` into the working directory — the rules your agent needs
174
+ to apply the tokens correctly. `SKILL.md` is the workflow and the router: how to read a search
175
+ result, and where to go for the area being worked in. Each `references/*.md` beside it is one such
176
+ area — `naming.md` for how a token name is built, `typography.md` for applying a text style with
177
+ its class rather than the mixin behind it. The whole folder is rewritten every run, so do not edit
178
+ it, and it prints where it wrote (or why it could not) on stderr.
179
+
180
+ On the first connection it also **pulls the stylesheets**, once. It looks for the folder this project
181
+ already keeps its styles in — `src/styles`, `app/styles`, `scss`, and the rest — and writes them to
182
+ `<that folder>/flareum`, falling back to `styles/flareum` in a project that has none. It says on
183
+ stderr where they landed.
118
184
 
119
- On start it writes `.claude/skills/flareum/SKILL.md` into the working directory the naming rules
120
- your agent needs to propose a token name correctly. It is regenerated every run, so do not edit it,
121
- and it prints where it wrote (or why it could not) on stderr.
185
+ It runs only when that folder does not exist yet. After that the files are yours: `flareum pull`
186
+ updates them on demand and `flareum watch` on every Push, both into the same folder. A failed pull
187
+ is reported and nothing else the token tools work without it. `FLAREUM_AUTO_PULL=off` turns it off.
122
188
 
123
189
  ---
124
190
 
@@ -181,7 +247,9 @@ to fetch.
181
247
  | `src/tools.ts` | The TEXT each tool returns. This is what the agent reads, so its wording is guarded. |
182
248
  | `src/server.ts` | MCP stdio transport. Glue only — no behaviour lives here. |
183
249
  | `src/skill.ts` | Writes the skill into the consuming project. |
184
- | `skill/SKILL.md` | The naming rules shipped to the agent. |
250
+ | `src/first-pull.ts` | Chooses the styles folder, and pulls into it on a first connection. |
251
+ | `skill/SKILL.md` | The router shipped to the agent: the workflow, and where each rule lives. |
252
+ | `skill/references/` | One page per rule area. Every file here must be linked from SKILL.md. |
185
253
 
186
254
  `server.ts` has no tests because it holds no logic; everything it calls is covered. Imports inside
187
255
  the package carry a `.js` extension — TypeScript does not rewrite specifiers, and Node's ESM loader
package/dist/cli.js CHANGED
@@ -1,64 +1,93 @@
1
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';
2
+ import { existsSync } from 'node:fs';
3
+ import { readFile } from 'node:fs/promises';
4
+ import { join, resolve } from 'node:path';
5
+ import { FlareumApiError, FlareumClient } from './client.js';
5
6
  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';
7
+ import { chooseStylesDir, fileWriter } from './first-pull.js';
8
+ import { isRetryable, pullReport, pullStyles } from './pull.js';
9
+ import { watchIntervalMs, watchPublished, watchStartedReport } from './watch.js';
10
+ // Where the styles go is decided in ONE place, so the CLI and the server never disagree about it.
11
+ const defaultOut = () => chooseStylesDir(path => existsSync(resolve(process.cwd(), path)));
9
12
  // Read rather than restated: a hardcoded version is wrong the moment `npm version` runs.
10
13
  const VERSION = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8')).version;
11
14
  const HELP = `flareum — read a Flareum project's design tokens
12
15
 
13
16
  flareum pull [--out <dir>] [--token pk_…] [--api <url>]
14
17
  Write the stylesheets the last push published into <dir>
15
- (default: ${DEFAULT_OUT}).
18
+ (default: the folder this project keeps its styles in — here, ${defaultOut()}).
16
19
 
17
20
  flareum watch [--out <dir>] [--interval <seconds>]
18
21
  Stay running and pull again every time you Push in Flareum.
19
22
 
20
23
  The key is taken from --token, then FLAREUM_TOKEN, then "token" in ${CONFIG_PATH}.
21
24
  Mint one in the project's Connect screen in Flareum.`;
22
- const readConfigFile = async () => readFile(join(process.cwd(), CONFIG_PATH), 'utf8').then(JSON.parse).catch(() => null);
23
- const { command, flags } = parseArgs(process.argv.slice(2));
25
+ // An absent config is normal; one that fails to parse is a typo the user is staring at, and
26
+ // collapsing both to null reported "No Flareum key" about the file holding the key.
27
+ const readConfigFile = async () => {
28
+ const raw = await readFile(join(process.cwd(), CONFIG_PATH), 'utf8').catch(() => null);
29
+ if (raw === null)
30
+ return null;
31
+ try {
32
+ return JSON.parse(raw);
33
+ }
34
+ catch (error) {
35
+ throw new Error(`Could not read ${CONFIG_PATH} — ${error instanceof Error ? error.message : String(error)}`);
36
+ }
37
+ };
24
38
  const COMMANDS = ['pull', 'watch'];
25
- // Read from flags, not `command`: parseArgs takes every leading `--x` as a flag, so checking the
26
- // command alone printed the help instead.
27
- if (flags.version || command === '-v') {
28
- console.log(VERSION);
29
- process.exit(0);
30
- }
31
- if (flags.help || command === '-h') {
32
- console.log(HELP);
33
- process.exit(0);
34
- }
35
- if (!COMMANDS.includes(command)) {
36
- console.log(HELP);
37
- process.exit(command ? 1 : 0);
38
- }
39
+ // Everything the user can get wrong lives inside this one try, argument parsing included: a throw
40
+ // above it printed a Node stack trace, which is the failure cliErrorReport exists to prevent.
39
41
  try {
42
+ const { command, flags } = parseArgs(process.argv.slice(2));
43
+ // Read from flags, not `command`: parseArgs takes every leading `--x` as a flag, so checking the
44
+ // command alone printed the help instead.
45
+ if (flags.version || command === '-v') {
46
+ console.log(VERSION);
47
+ process.exit(0);
48
+ }
49
+ if (flags.help || command === '-h') {
50
+ console.log(HELP);
51
+ process.exit(0);
52
+ }
53
+ if (!COMMANDS.includes(command)) {
54
+ console.log(HELP);
55
+ process.exit(command ? 1 : 0);
56
+ }
40
57
  const config = resolveConfig({ flags, env: process.env, file: await readConfigFile() });
41
- const outDir = config.out ?? DEFAULT_OUT;
42
- const root = resolve(process.cwd(), outDir);
58
+ const outDir = config.out ?? defaultOut();
43
59
  const client = new FlareumClient({ token: config.token, api: config.api });
44
- const write = async (path, contents) => {
45
- const target = join(root, path);
46
- await mkdir(dirname(target), { recursive: true });
47
- await writeFile(target, contents, 'utf8');
48
- };
60
+ const write = fileWriter(resolve(process.cwd(), outDir));
49
61
  const runPull = async () => {
50
62
  const result = await pullStyles(client, write);
51
63
  console.log(pullReport(result, outDir));
52
64
  return result;
53
65
  };
54
66
  if (command === 'watch') {
55
- const intervalMs = Number(flags.interval) > 0 ? Number(flags.interval) * 1000 : DEFAULT_INTERVAL_MS;
67
+ const intervalMs = watchIntervalMs(flags.interval);
56
68
  console.log(watchStartedReport(outDir, intervalMs));
57
69
  let running = true;
58
70
  process.on('SIGINT', () => { running = false; console.log('\nStopped watching.'); process.exit(0); });
59
71
  await watchPublished({
60
- publishedVersion: async () => (await client.files()).publishedVersionId,
61
- onPublish: async () => { await runPull(); },
72
+ // watch treats null as "nothing published yet, wait quietly". Without this the CLI threw
73
+ // the 409 instead, so a never-pushed project printed an error on every tick forever.
74
+ publishedVersion: async () => {
75
+ try {
76
+ return (await client.published()).publishedVersionId;
77
+ }
78
+ catch (error) {
79
+ if (error instanceof FlareumApiError && error.code === 'NOT_PUBLISHED')
80
+ return null;
81
+ throw error;
82
+ }
83
+ },
84
+ // Throwing is what stops watch marking the version done: a file that failed for a
85
+ // retryable reason must be fetched again, not skipped until the next push.
86
+ onPublish: async () => {
87
+ const result = await runPull();
88
+ if (isRetryable(result))
89
+ throw new Error(`${result.failed.length} file(s) failed — retrying on the next tick.`);
90
+ },
62
91
  onError: error => console.error(error instanceof Error ? error.message : String(error)),
63
92
  sleep: ms => new Promise(done => { setTimeout(done, ms); }),
64
93
  keepGoing: () => running,
package/dist/client.d.ts CHANGED
@@ -22,6 +22,7 @@ export declare class FlareumClient {
22
22
  catalog(updatedSince?: string): Promise<CatalogResponse>;
23
23
  search(query: string, limit?: number): Promise<SearchResponse>;
24
24
  /** The stylesheets the last push published. They exist only after a push, never from a save. */
25
+ published(): Promise<PublishedResponse>;
25
26
  files(): Promise<FilesResponse>;
26
27
  file(path: string): Promise<string>;
27
28
  variable(path: string): Promise<VariableResponse>;
@@ -69,6 +70,9 @@ export type FilesResponse = {
69
70
  publishedVersionId: string;
70
71
  files: string[];
71
72
  };
73
+ export type PublishedResponse = {
74
+ publishedVersionId: string;
75
+ };
72
76
  export type VariableMode = {
73
77
  id: string;
74
78
  name: string;
package/dist/client.js CHANGED
@@ -60,6 +60,8 @@ export class FlareumClient {
60
60
  const url = `${this.#api}/api/v1/projects/${this.projectId}${path}`;
61
61
  const response = await this.#fetch(url, {
62
62
  headers: { Authorization: `Bearer ${this.#token}`, Accept: 'text/plain' },
63
+ }).catch(error => {
64
+ throw new Error(`Could not reach ${this.#api} — ${error instanceof Error ? error.message : String(error)}`);
63
65
  });
64
66
  if (response.ok)
65
67
  return response.text();
@@ -75,6 +77,19 @@ export class FlareumClient {
75
77
  return this.#get('/search', { q: query, ...(limit ? { limit: String(limit) } : {}) });
76
78
  }
77
79
  /** The stylesheets the last push published. They exist only after a push, never from a save. */
80
+ // The watch's every-tick question. `/files` answers it too, but lists the whole bucket first — so
81
+ // ask the cheap endpoint, and fall back for a server older than it.
82
+ async published() {
83
+ try {
84
+ return await this.#get('/published');
85
+ }
86
+ catch (error) {
87
+ if (!(error instanceof FlareumApiError) || error.code !== 'HTTP_404')
88
+ throw error;
89
+ const { publishedVersionId } = await this.files();
90
+ return { publishedVersionId };
91
+ }
92
+ }
78
93
  files() {
79
94
  return this.#get('/files');
80
95
  }
package/dist/config.d.ts CHANGED
@@ -12,6 +12,9 @@ export declare const CONFIG_PATH = ".flareum/config.json";
12
12
  export declare class MissingToken extends Error {
13
13
  constructor();
14
14
  }
15
+ export declare class BareFlag extends Error {
16
+ constructor(name: string);
17
+ }
15
18
  /** A flag beats the environment beats the file — nearest to the command wins. */
16
19
  export declare const resolveConfig: ({ flags, env, file }: ConfigSources) => Resolved;
17
20
  /** `--out dir --token x` and `--out=dir`, both. Anything else is a positional. */
package/dist/config.js CHANGED
@@ -11,17 +11,29 @@ export class MissingToken extends Error {
11
11
  this.name = 'MissingToken';
12
12
  }
13
13
  }
14
+ export class BareFlag extends Error {
15
+ constructor(name) {
16
+ super(`--${name} needs a value, e.g. --${name} <value> or --${name}=<value>.`);
17
+ this.name = 'BareFlag';
18
+ }
19
+ }
14
20
  /** A flag beats the environment beats the file — nearest to the command wins. */
15
21
  export const resolveConfig = ({ flags, env, file }) => {
16
22
  const token = flags.token || env.FLAREUM_TOKEN || file?.token;
17
23
  if (!token)
18
24
  throw new MissingToken();
25
+ // The file's host travels only with the file's token. A checked-in config is somebody else's
26
+ // input, and pointing an ambient FLAREUM_TOKEN at their host is how the key leaves the machine.
27
+ const fileApi = file?.token && token === file.token ? file.api : undefined;
19
28
  return {
20
29
  token,
21
- api: flags.api || env.FLAREUM_API || file?.api,
30
+ api: flags.api || env.FLAREUM_API || fileApi,
22
31
  out: flags.out || file?.out,
23
32
  };
24
33
  };
34
+ // Flags that mean nothing without a value. --version / --help are deliberately absent: a bare one
35
+ // is the whole point of them.
36
+ const VALUE_FLAGS = new Set(['out', 'token', 'api', 'interval']);
25
37
  /** `--out dir --token x` and `--out=dir`, both. Anything else is a positional. */
26
38
  export const parseArgs = (argv) => {
27
39
  const flags = {};
@@ -42,6 +54,8 @@ export const parseArgs = (argv) => {
42
54
  flags[name] = next;
43
55
  i += 1;
44
56
  }
57
+ else if (VALUE_FLAGS.has(name))
58
+ throw new BareFlag(name);
45
59
  else
46
60
  flags[name] = 'true';
47
61
  }
@@ -0,0 +1,31 @@
1
+ import type { FlareumClient } from './client.js';
2
+ import { type PullResult } from './pull.js';
3
+ /** Does this path exist, relative to the project root? Injected so the choice is testable. */
4
+ export type Probe = (path: string) => boolean;
5
+ /**
6
+ * Where this project's Flareum stylesheets belong. ONE decision, used by the CLI's default and by
7
+ * the server's first pull — two answers would write the same files to two places.
8
+ */
9
+ export declare const chooseStylesDir: (exists: Probe, configured?: string) => string;
10
+ export declare const autoPullEnabled: (env: Record<string, string | undefined>) => boolean;
11
+ /** Only ever the FIRST time: an existing directory is the developer's, and `flareum pull` owns updates. */
12
+ export declare const needsFirstPull: (exists: Probe, dir: string) => boolean;
13
+ export type FirstPull = {
14
+ ran: false;
15
+ reason: 'disabled' | 'already-pulled';
16
+ dir: string;
17
+ } | {
18
+ ran: true;
19
+ dir: string;
20
+ result: PullResult;
21
+ } | {
22
+ ran: false;
23
+ reason: 'failed';
24
+ dir: string;
25
+ error: string;
26
+ };
27
+ export declare const firstPullReport: (outcome: FirstPull) => string | null;
28
+ /** Writes under `root`, refusing nothing the pull already refuses (see isWritablePath). */
29
+ export declare const fileWriter: (root: string) => (path: string, contents: string) => Promise<void>;
30
+ export declare const configuredOut: (cwd: string, env: Record<string, string | undefined>) => string | undefined;
31
+ export declare const runFirstPull: (client: Pick<FlareumClient, "files" | "file">, cwd: string, env: Record<string, string | undefined>, configuredOut?: string) => Promise<FirstPull>;
@@ -0,0 +1,80 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { mkdir, writeFile } from 'node:fs/promises';
3
+ import { dirname, join, resolve } from 'node:path';
4
+ import { CONFIG_PATH } from './config.js';
5
+ import { pullStyles } from './pull.js';
6
+ // Most specific first. A repo that keeps stylesheets in app/styles gets them there rather than in a
7
+ // src/styles nobody in that project looks at.
8
+ const STYLE_ROOTS = [
9
+ 'src/styles', 'src/style', 'src/assets/styles', 'src/scss', 'src/css',
10
+ 'app/styles', 'app/assets/styles', 'assets/styles', 'styles', 'scss', 'css',
11
+ ];
12
+ const LEAF = 'flareum';
13
+ /**
14
+ * Where this project's Flareum stylesheets belong. ONE decision, used by the CLI's default and by
15
+ * the server's first pull — two answers would write the same files to two places.
16
+ */
17
+ export const chooseStylesDir = (exists, configured) => {
18
+ if (configured)
19
+ return configured;
20
+ // An earlier pull already answered this. Re-deciding would strand those files and pull a second copy.
21
+ const pulled = STYLE_ROOTS.map(root => `${root}/${LEAF}`).find(exists);
22
+ if (pulled)
23
+ return pulled;
24
+ const root = STYLE_ROOTS.find(exists);
25
+ if (root)
26
+ return `${root}/${LEAF}`;
27
+ return exists('src') ? `src/styles/${LEAF}` : `styles/${LEAF}`;
28
+ };
29
+ // Opt-out, not opt-in: a project with no stylesheets is the case this exists for, and a stdio server
30
+ // has nobody to ask at startup. Anything but an explicit off means on.
31
+ export const autoPullEnabled = (env) => !['off', 'false', '0', 'no'].includes((env.FLAREUM_AUTO_PULL ?? '').trim().toLowerCase());
32
+ /** Only ever the FIRST time: an existing directory is the developer's, and `flareum pull` owns updates. */
33
+ export const needsFirstPull = (exists, dir) => !exists(dir);
34
+ export const firstPullReport = (outcome) => {
35
+ // Silence is right for a start that did nothing — this prints on every server start.
36
+ if (!outcome.ran && outcome.reason !== 'failed')
37
+ return null;
38
+ if (!outcome.ran)
39
+ return `[flareum] could NOT pull the stylesheets into ${outcome.dir} — ${outcome.error}. `
40
+ + 'The token tools still work; run `npx -p @flareum/mcp flareum pull` to retry.';
41
+ const { written, failed } = outcome.result;
42
+ const head = `[flareum] no local stylesheets — pulled ${written.length} into ${outcome.dir}. `
43
+ + 'Run `npx -p @flareum/mcp flareum watch` to keep them current, or set FLAREUM_AUTO_PULL=off.';
44
+ return failed.length ? `${head}\n[flareum] ${failed.length} could not be written: `
45
+ + failed.map(({ path, reason }) => `${path} — ${reason}`).join('; ') : head;
46
+ };
47
+ /** Writes under `root`, refusing nothing the pull already refuses (see isWritablePath). */
48
+ export const fileWriter = (root) => async (path, contents) => {
49
+ const target = join(root, path);
50
+ await mkdir(dirname(target), { recursive: true });
51
+ await writeFile(target, contents, 'utf8');
52
+ };
53
+ // The same `out` the CLI honours, read the same way — otherwise the server pulls into one folder and
54
+ // `flareum pull` updates another.
55
+ export const configuredOut = (cwd, env) => {
56
+ if (env.FLAREUM_OUT)
57
+ return env.FLAREUM_OUT;
58
+ try {
59
+ return JSON.parse(readFileSync(resolve(cwd, CONFIG_PATH), 'utf8')).out || undefined;
60
+ }
61
+ catch {
62
+ // No config file, or an unreadable one: fall through to the folder probe rather than failing.
63
+ return undefined;
64
+ }
65
+ };
66
+ export const runFirstPull = async (client, cwd, env, configuredOut) => {
67
+ const exists = path => existsSync(resolve(cwd, path));
68
+ const dir = chooseStylesDir(exists, configuredOut);
69
+ if (!autoPullEnabled(env))
70
+ return { ran: false, reason: 'disabled', dir };
71
+ if (!needsFirstPull(exists, dir))
72
+ return { ran: false, reason: 'already-pulled', dir };
73
+ try {
74
+ return { ran: true, dir, result: await pullStyles(client, fileWriter(resolve(cwd, dir))) };
75
+ }
76
+ catch (error) {
77
+ // A project with nothing published, an expired key, no network — none of them may stop the server.
78
+ return { ran: false, reason: 'failed', dir, error: error instanceof Error ? error.message : String(error) };
79
+ }
80
+ };
package/dist/pull.d.ts CHANGED
@@ -5,8 +5,11 @@ export type PullResult = {
5
5
  failed: Array<{
6
6
  path: string;
7
7
  reason: string;
8
+ permanent: boolean;
8
9
  }>;
9
10
  };
10
11
  export type Writer = (path: string, contents: string) => Promise<void>;
12
+ export declare const isWritablePath: (path: string) => boolean;
11
13
  export declare const pullStyles: (client: Pick<FlareumClient, "files" | "file">, write: Writer) => Promise<PullResult>;
14
+ export declare const isRetryable: ({ failed }: PullResult) => boolean;
12
15
  export declare const pullReport: ({ publishedVersionId, written, failed }: PullResult, outDir: string) => string;
package/dist/pull.js CHANGED
@@ -1,3 +1,15 @@
1
+ const STYLESHEET = /\.s?css$/i;
2
+ // The server says where to write, and a --api flag lets anyone be the server. Refused rather than
3
+ // normalised: repairing a path that climbs out of the output directory is how traversal gets through.
4
+ export const isWritablePath = (path) => {
5
+ if (!path || !STYLESHEET.test(path))
6
+ return false;
7
+ if (path.startsWith('/') || path.includes('\\') || path.includes('\0'))
8
+ return false;
9
+ if (/^[a-zA-Z]:/.test(path))
10
+ return false;
11
+ return !path.split('/').includes('..');
12
+ };
1
13
  // Copies the ARTIFACT a push generated; never rebuilds CSS from the catalog, so a name here and a
2
14
  // name the API reports cannot disagree.
3
15
  export const pullStyles = async (client, write) => {
@@ -6,15 +18,24 @@ export const pullStyles = async (client, write) => {
6
18
  const failed = [];
7
19
  for (const path of files)
8
20
  try {
21
+ if (!isWritablePath(path)) {
22
+ failed.push({ path, reason: 'refused — not a stylesheet inside the output directory',
23
+ permanent: true });
24
+ continue;
25
+ }
9
26
  await write(path, await client.file(path));
10
27
  written.push(path);
11
28
  }
12
29
  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) });
30
+ // One unreadable file must not lose the rest, and must not be reported as written. Marked
31
+ // retryable: unlike a refusal, a read or write that failed once can succeed next time.
32
+ failed.push({ path, reason: error instanceof Error ? error.message : String(error),
33
+ permanent: false });
15
34
  }
16
35
  return { publishedVersionId, written, failed };
17
36
  };
37
+ // A refusal will fail identically forever; anything else is worth another attempt.
38
+ export const isRetryable = ({ failed }) => failed.some(f => !f.permanent);
18
39
  export const pullReport = ({ publishedVersionId, written, failed }, outDir) => {
19
40
  const lines = [`Pulled ${written.length} stylesheet(s) from version ${publishedVersionId} into ${outDir}`];
20
41
  for (const path of written)
package/dist/server.js CHANGED
@@ -8,6 +8,7 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprot
8
8
  import { FlareumApiError, FlareumClient } from './client.js';
9
9
  import { TOOL_DESCRIPTIONS, formatError, formatSearch, formatVariable } from './tools.js';
10
10
  import { installSkill, skillInstallReport } from './skill.js';
11
+ import { configuredOut, firstPullReport, runFirstPull } from './first-pull.js';
11
12
  const client = new FlareumClient({
12
13
  token: process.env.FLAREUM_TOKEN ?? '',
13
14
  api: process.env.FLAREUM_API,
@@ -64,4 +65,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
64
65
  });
65
66
  // Never silently: if the skill does not land, the agent hardcodes values and nothing says why.
66
67
  console.error(skillInstallReport(await installSkill(process.cwd())));
68
+ // First connection with no stylesheets pulled yet: fetch them now, into the folder this project
69
+ // already keeps its styles in. Reported, never asked — a stdio server has nobody to prompt.
70
+ const firstPull = firstPullReport(await runFirstPull(client, process.cwd(), process.env, configuredOut(process.cwd(), process.env)));
71
+ if (firstPull)
72
+ console.error(firstPull);
67
73
  await server.connect(new StdioServerTransport());
package/dist/skill.js CHANGED
@@ -1,15 +1,24 @@
1
- import { mkdir, readFile, writeFile } from 'node:fs/promises';
1
+ import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
2
2
  import { dirname, join } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  const SKILL_DIR = ['.claude', 'skills', 'flareum'];
5
- // Written on every start, not once: a stale copy of the naming rules is worse than none, and the
6
- // file is generated rather than hand-edited.
5
+ // Every file under skill/, not just SKILL.md: it is a router now, and a reference it points at but
6
+ // never wrote is a rule the agent silently never reads. One failure fails the whole install.
7
+ const copyTree = async (from, to) => {
8
+ await mkdir(to, { recursive: true });
9
+ for (const entry of await readdir(from, { withFileTypes: true }))
10
+ if (entry.isDirectory())
11
+ await copyTree(join(from, entry.name), join(to, entry.name));
12
+ else
13
+ await writeFile(join(to, entry.name), await readFile(join(from, entry.name), 'utf8'), 'utf8');
14
+ };
15
+ // Written on every start, not once: a stale copy of the rules is worse than none, and the files are
16
+ // generated rather than hand-edited.
7
17
  export const installSkill = async (cwd) => {
8
- const source = join(dirname(fileURLToPath(import.meta.url)), '..', 'skill', 'SKILL.md');
18
+ const source = join(dirname(fileURLToPath(import.meta.url)), '..', 'skill');
9
19
  const path = join(cwd, ...SKILL_DIR, 'SKILL.md');
10
20
  try {
11
- await mkdir(join(cwd, ...SKILL_DIR), { recursive: true });
12
- await writeFile(path, await readFile(source, 'utf8'), 'utf8');
21
+ await copyTree(source, join(cwd, ...SKILL_DIR));
13
22
  return { ok: true, path };
14
23
  }
15
24
  catch (error) {
package/dist/watch.d.ts CHANGED
@@ -7,5 +7,7 @@ export type WatchDeps = {
7
7
  keepGoing: () => boolean;
8
8
  };
9
9
  export declare const DEFAULT_INTERVAL_MS = 15000;
10
+ export declare const MIN_INTERVAL_MS = 2000;
11
+ export declare const watchIntervalMs: (seconds: string | undefined) => number;
10
12
  export declare const watchPublished: ({ publishedVersion, onPublish, onError, sleep, keepGoing }: WatchDeps, intervalMs?: number) => Promise<void>;
11
13
  export declare const watchStartedReport: (outDir: string, intervalMs: number) => string;
package/dist/watch.js CHANGED
@@ -1,4 +1,13 @@
1
1
  export const DEFAULT_INTERVAL_MS = 15_000;
2
+ export const MIN_INTERVAL_MS = 2_000;
3
+ // A floor, not a default: `--interval 0.001` asked for a thousand polls a second, which the API
4
+ // answers with 429s rather than data.
5
+ export const watchIntervalMs = (seconds) => {
6
+ const asked = Number(seconds) * 1000;
7
+ if (!Number.isFinite(asked) || asked <= 0)
8
+ return DEFAULT_INTERVAL_MS;
9
+ return Math.max(asked, MIN_INTERVAL_MS);
10
+ };
2
11
  // Re-pulls when the PUBLISHED version changes — a push, never a save, so a designer mid-edit cannot
3
12
  // rewrite a file the developer is looking at.
4
13
  export const watchPublished = async ({ publishedVersion, onPublish, onError, sleep, keepGoing }, intervalMs = DEFAULT_INTERVAL_MS) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flareum/mcp",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "description": "Connect a coding agent to a Flareum project's design tokens.",
5
5
  "license": "MIT",
6
6
  "author": "Flareum",
package/skill/SKILL.md CHANGED
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: flareum
3
- description: Use the project's Flareum design tokens instead of literal values. Invoke whenever you are about to write a colour, size, spacing, radius, shadow or duration in CSS/SCSS, or need to know which token a value corresponds to.
3
+ description: Use the project's Flareum design tokens instead of literal values. Invoke whenever you are about to write a colour, size, spacing, radius, shadow, duration or text style in CSS/SCSS/HTML, or need to know which token a value corresponds to.
4
4
  ---
5
5
 
6
6
  # Flareum design tokens
@@ -9,6 +9,9 @@ This project's design tokens live in Flareum. The generated CSS/SCSS in the repo
9
9
  of them — the tokens themselves, with their types, modes, comments and usage, are reachable through
10
10
  the `flareum_search` and `flareum_get` tools.
11
11
 
12
+ Every name here is written with **`[prefix]`** where your project's own token prefix goes — the
13
+ string every `cssName` in a search result starts with. Substitute it; never write `[prefix]` itself.
14
+
12
15
  ## The one rule
13
16
 
14
17
  **Search before you write a literal value, and before you propose a token name.**
@@ -18,6 +21,16 @@ an existing `color/border/secondary` is worse — it looks like a decision, and
18
21
  reconcile the two. `flareum_search` takes a hex, an rgb(), a path fragment, a phrase from a comment,
19
22
  or a component name.
20
23
 
24
+ ## The rules, by topic
25
+
26
+ This page is the workflow. Open the one below that covers what you are about to write — each is a
27
+ page, and it is worth reading in full before the first line of code.
28
+
29
+ | Writing | Read |
30
+ |---|---|
31
+ | Any text — a heading, a label, body copy, a pseudo-element's `content` | [references/typography.md](references/typography.md) |
32
+ | A token name, or judging one that already exists | [references/naming.md](references/naming.md) |
33
+
21
34
  ## Reading a search result
22
35
 
23
36
  The header tells you which kind of answer you got, and they mean different things:
@@ -35,33 +48,9 @@ An empty project says so explicitly. Anything else always returns candidates.
35
48
  This integration is **read-only** — there is no tool that writes to Flareum, by design. So:
36
49
 
37
50
  1. Say which token is missing and what it would be for.
38
- 2. Propose a name that fits the grammar below, so the designer can create it in one step.
51
+ 2. Propose a name that fits [the grammar](references/naming.md), so the designer can create it in one step.
39
52
  3. Do not silently hardcode the value and move on.
40
53
 
41
- ## The naming grammar
42
-
43
- A token name is a serialized hierarchy, general → specific, terminal value last:
44
-
45
- ```
46
- --[prefix]-[category]-[layer?]-[path...]-[value]
47
- ```
48
-
49
- - **`category` always comes first** after the prefix: `color`, `space`, `size`, `radius`, `shadow`,
50
- `blur`, `duration`, `z`, `opacity`, `font`, `border`.
51
- - **`layer`** is optional and names the tier. For colour: `primitive`, `global`, `semantic`,
52
- `action_palette`, `action_state`, `action`. A raw scale (`--fui-space-4`) has none.
53
- - **A component is a `path` segment, never a top-level layer.**
54
-
55
- ```css
56
- /* ✓ */ --fui-color-semantic-button-background-primary-hover
57
- /* ✗ */ --fui-button-color-background-primary-hover /* component-first */
58
- /* ✗ */ --fui-semantic-color-text-primary /* layer hoisted above the category */
59
- ```
60
-
61
- Spell each segment the way the system already spells it — `disabled` not `disable`, `background` not
62
- `bg`, `accent` not `acent`. Search for the segment before coining a second spelling of a word that
63
- already exists; a new word is for a genuinely new concept only.
64
-
65
54
  ## Blast radius before a change
66
55
 
67
56
  `flareum_get` reports two independent things, and they answer different questions:
@@ -0,0 +1,36 @@
1
+ # Token naming
2
+
3
+ Read this before proposing a token name, or when judging whether a name that already exists is the
4
+ one you want.
5
+
6
+ ## The grammar
7
+
8
+ A token name is a serialized hierarchy, general → specific, terminal value last:
9
+
10
+ ```
11
+ --[prefix]-[category]-[layer?]-[path...]-[value]
12
+ ```
13
+
14
+ - **`category` always comes first** after the prefix: `color`, `space`, `size`, `radius`, `shadow`,
15
+ `blur`, `duration`, `z`, `opacity`, `font`, `border`.
16
+ - **`layer`** is optional and names the tier. For colour: `primitive`, `global`, `semantic`,
17
+ `action_palette`, `action_state`, `action`. A raw scale (`--[prefix]-space-4`) has none.
18
+ - **A component is a `path` segment, never a top-level layer.**
19
+
20
+ ```css
21
+ /* ✓ */ --[prefix]-color-semantic-button-background-primary-hover
22
+ /* ✗ */ --[prefix]-button-color-background-primary-hover /* component-first */
23
+ /* ✗ */ --[prefix]-semantic-color-text-primary /* layer hoisted above the category */
24
+ ```
25
+
26
+ Build the tree first, then serialize it — `color → semantic → input → border → error` is what makes
27
+ `--[prefix]-color-semantic-input-border-error` the only possible spelling of it.
28
+
29
+ ## One word per concept
30
+
31
+ Spell each segment the way the system already spells it — `disabled` not `disable`, `background` not
32
+ `bg`, `accent` not `acent`. Two spellings of one word mean nobody can find every token for it, or
33
+ trust that they have.
34
+
35
+ So `flareum_search` for the segment before coining a word. A new word is for a genuinely new concept
36
+ only; a shorter way to write one that exists is not a new concept.
@@ -0,0 +1,55 @@
1
+ # Text styles
2
+
3
+ Read this before styling any text — a heading, a label, body copy, a value in a table cell, or a
4
+ pseudo-element's `content`.
5
+
6
+ ## The class, not the mixin
7
+
8
+ Flareum exports every text style twice — a ready-made **class** in `typography/_styles.scss`, and the
9
+ **mixin** it is built from in `typography/_mixins.scss`. They are not two ways of doing the same
10
+ thing. **The class is how a text style is applied; the mixin is a fallback for the few elements a
11
+ class cannot reach.**
12
+
13
+ Put the pair on the element that holds the text — the base class plus the style class:
14
+
15
+ ```html
16
+ <h2 class="[prefix]-typography [prefix]-h5">Export settings</h2>
17
+ <p class="[prefix]-typography [prefix]-t2">Choose which collections ship to Figma.</p>
18
+ <span class="[prefix]-typography [prefix]-t2 [prefix]-bold">12 variables</span>
19
+ ```
20
+
21
+ Both halves are required. The base class declares the shared properties (`--line-height` among them)
22
+ and the style class re-points them, so a style class on its own applies nothing. A variant slot —
23
+ `bold`, `regular`, `highlight` — is a third class beside the style, never a replacement for it.
24
+
25
+ Why the class wins: it names the style in the markup, where the next person reading the template can
26
+ see which style an element uses and change it without opening a stylesheet; and one rule serves every
27
+ element that carries it, instead of each component compiling its own copy of the same declarations.
28
+
29
+ ## The exception
30
+
31
+ **Reach for the mixin only where no class can be placed** — a pseudo-element, or markup you do not
32
+ render (a third-party widget, generated HTML) and therefore cannot add an attribute to.
33
+
34
+ ```scss
35
+ // ::after has no class attribute — the only way to give it the style
36
+ .field::after {
37
+ content: attr(data-hint);
38
+ @include [prefix]-typography-t3;
39
+ }
40
+ ```
41
+
42
+ That is the whole list. "It was easier from SCSS" is not on it: a mixin reached for out of convenience
43
+ hides the style from the markup and duplicates the declarations into every component that includes it.
44
+
45
+ ## When a style looks like it is not applying
46
+
47
+ Check these before overriding anything — each one silently beats the class rather than erroring:
48
+
49
+ - **A lone style class.** `[prefix]-h5` with no `[prefix]-typography` beside it applies nothing.
50
+ - **A `font:` shorthand on the same element.** It resets family, size, weight and line-height in one
51
+ go, and component styles load after the global ones. Use the longhand for the one thing you meant
52
+ to change.
53
+ - **A missing `--line-height`.** Padding computed from it resolves to an invalid `calc()`, and the
54
+ property falls back to its initial value with nothing in the console. The typography class has to
55
+ be on the element that reads the variable, or an ancestor of it.