@flareum/mcp 0.2.10 → 0.5.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
@@ -182,7 +182,7 @@ nothing runs until you open a session — or run `claude mcp list`, which connec
182
182
  and is why the skill sometimes appears then. `flareum pull` writes it too, so one pull sets the
183
183
  project up without waiting for a connection.
184
184
 
185
- On the first connection it also **pulls the stylesheets**, once. It looks for the folder this project
185
+ On connection it also **pulls the stylesheets** whenever the published version has moved. It looks for the folder this project
186
186
  already keeps its styles in — `src/styles`, `app/styles`, `scss`, and the rest — and writes them to
187
187
  `<that folder>/flareum`, falling back to `styles/flareum` in a project that has none. It says on
188
188
  stderr where they landed.
package/dist/cli.js CHANGED
@@ -6,6 +6,8 @@ import { FlareumApiError, FlareumClient } from './client.js';
6
6
  import { CONFIG_PATH, cliErrorReport, parseArgs, resolveConfig } from './config.js';
7
7
  import { chooseStylesDir, fileWriter } from './first-pull.js';
8
8
  import { isRetryable, pullReport, pullStyles } from './pull.js';
9
+ import { makeLock, writeLock } from './lock.js';
10
+ import { projectIdFromToken } from './client.js';
9
11
  import { installSkill, skillInstallReport } from './skill.js';
10
12
  import { watchIntervalMs, watchPublished, watchStartedReport } from './watch.js';
11
13
  // Where the styles go is decided in ONE place, so the CLI and the server never disagree about it.
@@ -64,6 +66,10 @@ try {
64
66
  console.log(skillInstallReport(await installSkill(process.cwd())));
65
67
  const runPull = async () => {
66
68
  const result = await pullStyles(client, write);
69
+ // The server reads this lock to decide whether to pull. Without it a CLI pull was invisible,
70
+ // and the next connection fetched the same version again.
71
+ if (!isRetryable(result))
72
+ await writeLock(process.cwd(), makeLock(projectIdFromToken(config.token), result.publishedVersionId, outDir, new Date().toISOString()));
67
73
  console.log(pullReport(result, outDir));
68
74
  return result;
69
75
  };
package/dist/client.d.ts CHANGED
@@ -23,6 +23,7 @@ export declare class FlareumClient {
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
25
  published(): Promise<PublishedResponse>;
26
+ changes(since?: string): Promise<ChangesResponse>;
26
27
  files(): Promise<FilesResponse>;
27
28
  file(path: string): Promise<string>;
28
29
  variable(path: string): Promise<VariableResponse>;
@@ -73,6 +74,46 @@ export type FilesResponse = {
73
74
  export type PublishedResponse = {
74
75
  publishedVersionId: string;
75
76
  };
77
+ export type ChangeEntry = {
78
+ kind: string;
79
+ change: string;
80
+ id: string;
81
+ before?: {
82
+ path?: string;
83
+ cssName?: string;
84
+ name?: string;
85
+ value?: string;
86
+ type?: string;
87
+ };
88
+ after?: {
89
+ path?: string;
90
+ cssName?: string;
91
+ name?: string;
92
+ value?: string;
93
+ type?: string;
94
+ };
95
+ replacement?: {
96
+ cssName?: string;
97
+ value?: string;
98
+ };
99
+ viaFolder?: {
100
+ from: string;
101
+ to: string;
102
+ };
103
+ mode?: string;
104
+ collection?: {
105
+ id: string;
106
+ name: string;
107
+ };
108
+ };
109
+ export type ChangesResponse = {
110
+ since: string | null;
111
+ liveVersionId: string;
112
+ publishedVersionId: string | null;
113
+ totalChanges: number;
114
+ entries: ChangeEntry[];
115
+ truncated: boolean;
116
+ };
76
117
  export type VariableMode = {
77
118
  id: string;
78
119
  name: string;
package/dist/client.js CHANGED
@@ -90,6 +90,9 @@ export class FlareumClient {
90
90
  return { publishedVersionId };
91
91
  }
92
92
  }
93
+ changes(since = '') {
94
+ return this.#get('/changes', { since });
95
+ }
93
96
  files() {
94
97
  return this.#get('/files');
95
98
  }
package/dist/config.js CHANGED
@@ -28,7 +28,7 @@ export const resolveConfig = ({ flags, env, file }) => {
28
28
  return {
29
29
  token,
30
30
  api: flags.api || env.FLAREUM_API || fileApi,
31
- out: flags.out || file?.out,
31
+ out: flags.out || env.FLAREUM_OUT || file?.out,
32
32
  };
33
33
  };
34
34
  // Flags that mean nothing without a value. --version / --help are deliberately absent: a bare one
@@ -9,11 +9,12 @@ export type Probe = (path: string) => boolean;
9
9
  export declare const chooseStylesDir: (exists: Probe, configured?: string) => string;
10
10
  export declare const autoPullEnabled: (env: Record<string, string | undefined>) => boolean;
11
11
  export declare const STAMP = ".flareum-version";
12
- export declare const pulledVersion: (read: (path: string) => string | null, dir: string) => string | null;
13
- export declare const needsPull: (exists: Probe, read: (path: string) => string | null, dir: string, published: string | null) => boolean;
12
+ export declare const PENDING = "pending";
13
+ export declare const needsPull: (exists: Probe, claimed: boolean, synced: string | null, dir: string, published: string | null, isEmpty?: Probe) => boolean;
14
+ export declare const unclaimed: (exists: Probe, claimed: boolean, dir: string, isEmpty: Probe) => boolean;
14
15
  export type FirstPull = {
15
16
  ran: false;
16
- reason: 'disabled' | 'up-to-date';
17
+ reason: 'disabled' | 'up-to-date' | 'unclaimed';
17
18
  dir: string;
18
19
  } | {
19
20
  ran: true;
@@ -29,4 +30,4 @@ export declare const firstPullReport: (outcome: FirstPull) => string | null;
29
30
  /** Writes under `root`, refusing nothing the pull already refuses (see isWritablePath). */
30
31
  export declare const fileWriter: (root: string) => (path: string, contents: string) => Promise<void>;
31
32
  export declare const configuredOut: (cwd: string, env: Record<string, string | undefined>) => string | undefined;
32
- export declare const runFirstPull: (client: Pick<FlareumClient, "files" | "file" | "published">, cwd: string, env: Record<string, string | undefined>, configuredOut?: string) => Promise<FirstPull>;
33
+ export declare const runFirstPull: (client: Pick<FlareumClient, "files" | "file" | "published">, cwd: string, env: Record<string, string | undefined>, configuredOut?: string, knownVersion?: string | null, now?: () => string) => Promise<FirstPull>;
@@ -1,8 +1,10 @@
1
- import { existsSync, readFileSync } from 'node:fs';
1
+ import { existsSync, lstatSync, readFileSync, readdirSync } from 'node:fs';
2
2
  import { mkdir, writeFile } from 'node:fs/promises';
3
3
  import { dirname, join, resolve } from 'node:path';
4
+ import { projectIdFromToken } from './client.js';
4
5
  import { CONFIG_PATH } from './config.js';
5
- import { pullStyles } from './pull.js';
6
+ import { IN_FLIGHT, LEGACY_STAMP, legacyVersion, makeLock, owns, readLock, syncedVersion, writeLock, } from './lock.js';
7
+ import { isRetryable, pullStyles } from './pull.js';
6
8
  // Most specific first. A repo that keeps stylesheets in app/styles gets them there rather than in a
7
9
  // src/styles nobody in that project looks at.
8
10
  const STYLE_ROOTS = [
@@ -30,18 +32,27 @@ export const chooseStylesDir = (exists, configured) => {
30
32
  // has nobody to ask at startup. Anything but an explicit off means on.
31
33
  export const autoPullEnabled = (env) => !['off', 'false', '0', 'no'].includes((env.FLAREUM_AUTO_PULL ?? '').trim().toLowerCase());
32
34
  // A stamp beside the files, not in config.json: config is the developer's, this is bookkeeping.
33
- export const STAMP = '.flareum-version';
34
- export const pulledVersion = (read, dir) => read(join(dir, STAMP))?.trim() || null;
35
- // Pull when there is nothing here, or when what is published is not what is on disk. Connecting
36
- // used to be a one-time event, so a project pulled once never saw another push.
37
- export const needsPull = (exists, read, dir, published) => {
35
+ // Kept only so a 0.2.x stamp can be adopted; the anchor is .flareum/lock.json now.
36
+ export const STAMP = LEGACY_STAMP;
37
+ export const PENDING = IN_FLIGHT;
38
+ // Pull when the folder is ours and stale, or absent. A folder holding files we never stamped is
39
+ // somebody else's this repo's own read-only src/styles/flareum is exactly that.
40
+ export const needsPull = (exists, claimed, synced, dir, published, isEmpty = () => true) => {
38
41
  if (!exists(dir))
39
42
  return true;
40
43
  if (!published)
41
44
  return false;
42
- return pulledVersion(read, dir) !== published;
45
+ if (!claimed)
46
+ return isEmpty(dir);
47
+ return synced !== published;
43
48
  };
49
+ // Present, not ours, and not to be written over. Ownership is the lock naming this folder.
50
+ export const unclaimed = (exists, claimed, dir, isEmpty) => exists(dir) && !claimed && !isEmpty(dir);
44
51
  export const firstPullReport = (outcome) => {
52
+ // Said once, because leaving a folder alone silently looks like a broken pull.
53
+ if (!outcome.ran && outcome.reason === 'unclaimed')
54
+ return `[flareum] ${outcome.dir} already holds files this did not write, so it was left alone. `
55
+ + 'Run `npx -p @flareum/mcp flareum pull` to replace them deliberately.';
45
56
  // Silence is right for a start that did nothing — this prints on every server start.
46
57
  if (!outcome.ran && outcome.reason !== 'failed')
47
58
  return null;
@@ -57,9 +68,21 @@ export const firstPullReport = (outcome) => {
57
68
  return failed.length ? `${head}\n[flareum] ${failed.length} could not be written: `
58
69
  + failed.map(({ path, reason }) => `${path} — ${reason}`).join('; ') : head;
59
70
  };
71
+ // isWritablePath checks the path STRING; writeFile follows what is on DISK. A symlink already at
72
+ // the target wrote straight through it, out of the directory — refused rather than followed.
73
+ const isSymlink = (path) => {
74
+ try {
75
+ return lstatSync(path).isSymbolicLink();
76
+ }
77
+ catch {
78
+ return false;
79
+ }
80
+ };
60
81
  /** Writes under `root`, refusing nothing the pull already refuses (see isWritablePath). */
61
82
  export const fileWriter = (root) => async (path, contents) => {
62
83
  const target = join(root, path);
84
+ if (isSymlink(target))
85
+ throw new Error(`refused — ${path} is a symlink`);
63
86
  await mkdir(dirname(target), { recursive: true });
64
87
  await writeFile(target, contents, 'utf8');
65
88
  };
@@ -76,26 +99,41 @@ export const configuredOut = (cwd, env) => {
76
99
  return undefined;
77
100
  }
78
101
  };
79
- export const runFirstPull = async (client, cwd, env, configuredOut) => {
102
+ export const runFirstPull = async (client, cwd, env, configuredOut, knownVersion, now = () => new Date().toISOString()) => {
80
103
  const exists = path => existsSync(resolve(cwd, path));
81
- const read = (path) => {
104
+ const isEmpty = path => {
82
105
  try {
83
- return readFileSync(resolve(cwd, path), 'utf8');
106
+ return readdirSync(resolve(cwd, path)).length === 0;
84
107
  }
85
108
  catch {
86
- return null;
109
+ return true;
87
110
  }
88
111
  };
89
112
  const dir = chooseStylesDir(exists, configuredOut);
90
113
  if (!autoPullEnabled(env))
91
114
  return { ran: false, reason: 'disabled', dir };
115
+ const lock = readLock(cwd);
116
+ // A 0.2.x project recorded the version beside the stylesheets. Adopted, so an upgrade does not
117
+ // re-pull what is already on disk, and the folder is recognised as ours rather than a stranger's.
118
+ const adopted = legacyVersion(cwd, dir);
119
+ const claimed = owns(lock, dir) || adopted !== null;
120
+ const synced = syncedVersion(lock, dir) ?? adopted;
92
121
  try {
93
- const published = await client.published().then(r => r.publishedVersionId).catch(() => null);
94
- if (!needsPull(exists, read, dir, published))
95
- return { ran: false, reason: 'up-to-date', dir };
122
+ const published = knownVersion !== undefined
123
+ ? knownVersion
124
+ : await client.published().then(r => r.publishedVersionId).catch(() => null);
125
+ if (!needsPull(exists, claimed, synced, dir, published, isEmpty))
126
+ return { ran: false, reason: unclaimed(exists, claimed, dir, isEmpty) ? 'unclaimed' : 'up-to-date', dir };
127
+ const projectId = projectIdFromToken(env.FLAREUM_TOKEN ?? '');
128
+ // Claimed BEFORE the first write: an interrupted pull leaves files with no completed version,
129
+ // and without this it would read as somebody else's folder and never be finished.
130
+ await mkdir(resolve(cwd, dir), { recursive: true });
131
+ await writeLock(cwd, makeLock(projectId, IN_FLIGHT, dir, now()));
96
132
  const result = await pullStyles(client, fileWriter(resolve(cwd, dir)));
97
- // Stamped only after the write, so a failed pull is retried on the next connection.
98
- await writeFile(resolve(cwd, dir, STAMP), `${result.publishedVersionId}\n`, 'utf8');
133
+ // Recorded only when the WHOLE version landed: pullStyles resolves even when files failed, so
134
+ // stamping on its return marked a half-pulled folder complete and never retried it.
135
+ if (!isRetryable(result))
136
+ await writeLock(cwd, makeLock(projectId, result.publishedVersionId, dir, now()));
99
137
  return { ran: true, dir, result };
100
138
  }
101
139
  catch (error) {
@@ -1,14 +1,16 @@
1
1
  export type KeyCheck = {
2
2
  usable: true;
3
+ version?: string | null;
3
4
  } | {
4
5
  usable: false;
5
6
  fatal: true;
6
7
  message: string;
8
+ version?: undefined;
7
9
  } | {
8
10
  usable: false;
9
11
  fatal: false;
10
12
  message: string;
13
+ version?: undefined;
11
14
  };
12
15
  export declare const classifyKeyCheck: (error: unknown) => KeyCheck;
13
- /** One cheap authenticated call, so "connected" cannot mean "holding a key the server rejects". */
14
- export declare const checkKey: (probe: () => Promise<unknown>) => Promise<KeyCheck>;
16
+ export declare const checkKey: (probe: () => Promise<string | null>) => Promise<KeyCheck>;
package/dist/key-check.js CHANGED
@@ -22,11 +22,11 @@ export const classifyKeyCheck = (error) => {
22
22
  + 'Starting anyway; the token tools will report their own errors.',
23
23
  };
24
24
  };
25
- /** One cheap authenticated call, so "connected" cannot mean "holding a key the server rejects". */
25
+ // One cheap authenticated call, so "connected" cannot mean "holding a key the server rejects". The
26
+ // answer is carried out: startup needs the same version, and asking twice can cost two bucket lists.
26
27
  export const checkKey = async (probe) => {
27
28
  try {
28
- await probe();
29
- return { usable: true };
29
+ return { usable: true, version: await probe() };
30
30
  }
31
31
  catch (error) {
32
32
  return classifyKeyCheck(error);
package/dist/lock.d.ts ADDED
@@ -0,0 +1,18 @@
1
+ export declare const LOCK_PATH = ".flareum/lock.json";
2
+ export type Lock = {
3
+ projectId: string;
4
+ syncedVersionId: string;
5
+ syncedAt: string;
6
+ out: string;
7
+ prefix?: string;
8
+ };
9
+ export declare const IN_FLIGHT = "pending";
10
+ export declare const readLock: (cwd: string) => Lock | null;
11
+ /** The folder is ours when the lock names it — a pull in flight counts, so it can be finished. */
12
+ export declare const owns: (lock: Lock | null, dir: string) => boolean;
13
+ export declare const syncedVersion: (lock: Lock | null, dir: string) => string | null;
14
+ export declare const writeLock: (cwd: string, lock: Lock) => Promise<void>;
15
+ export declare const makeLock: (projectId: string, syncedVersionId: string, out: string, at: string, prefix?: string) => Lock;
16
+ export declare const LEGACY_STAMP = ".flareum-version";
17
+ export declare const legacyVersion: (cwd: string, dir: string) => string | null;
18
+ export declare const legacyStampExists: (cwd: string, dir: string) => boolean;
package/dist/lock.js ADDED
@@ -0,0 +1,36 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { mkdir, writeFile } from 'node:fs/promises';
3
+ import { dirname, join } from 'node:path';
4
+ export const LOCK_PATH = '.flareum/lock.json';
5
+ export const IN_FLIGHT = 'pending';
6
+ export const readLock = (cwd) => {
7
+ try {
8
+ const lock = JSON.parse(readFileSync(join(cwd, LOCK_PATH), 'utf8'));
9
+ return lock.out ? lock : null;
10
+ }
11
+ catch {
12
+ return null;
13
+ }
14
+ };
15
+ /** The folder is ours when the lock names it — a pull in flight counts, so it can be finished. */
16
+ export const owns = (lock, dir) => !!lock && lock.out === dir;
17
+ export const syncedVersion = (lock, dir) => owns(lock, dir) && lock.syncedVersionId !== IN_FLIGHT ? lock.syncedVersionId : null;
18
+ export const writeLock = async (cwd, lock) => {
19
+ const path = join(cwd, LOCK_PATH);
20
+ await mkdir(dirname(path), { recursive: true });
21
+ await writeFile(path, `${JSON.stringify(lock, null, 2)}\n`, 'utf8');
22
+ };
23
+ export const makeLock = (projectId, syncedVersionId, out, at, prefix) => ({ projectId, syncedVersionId, syncedAt: at, out, ...(prefix ? { prefix } : {}) });
24
+ // A project pulled by 0.2.x recorded the version in a stamp beside the stylesheets. Adopted rather
25
+ // than ignored, so an upgrade does not re-pull a version already on disk.
26
+ export const LEGACY_STAMP = '.flareum-version';
27
+ export const legacyVersion = (cwd, dir) => {
28
+ try {
29
+ const raw = readFileSync(join(cwd, dir, LEGACY_STAMP), 'utf8').trim();
30
+ return !raw || raw === IN_FLIGHT ? null : raw;
31
+ }
32
+ catch {
33
+ return null;
34
+ }
35
+ };
36
+ export const legacyStampExists = (cwd, dir) => existsSync(join(cwd, dir, LEGACY_STAMP));
@@ -6,5 +6,6 @@ export type ConfigWrite = {
6
6
  path: string;
7
7
  gitignored: boolean;
8
8
  };
9
+ export declare const repoRoot: (from: string, exists: (path: string) => boolean) => string | null;
9
10
  export declare const writeProjectConfig: (cwd: string, env: Record<string, string | undefined>, out?: string) => Promise<ConfigWrite>;
10
11
  export declare const projectConfigReport: (result: ConfigWrite) => string | null;
@@ -1,18 +1,39 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import { appendFile, mkdir, writeFile } from 'node:fs/promises';
3
- import { dirname, join } from 'node:path';
3
+ import { dirname, join, parse } from 'node:path';
4
4
  import { CONFIG_PATH } from './config.js';
5
- const IGNORE_LINE = '.flareum/';
5
+ // The KEY, not the directory: lock.json lives beside it and is committed deliberately — it makes
6
+ // "which token version is this branch built against" a reviewable fact in the pull request.
7
+ const IGNORE_LINE = '.flareum/config.json';
8
+ const BLANKET = '.flareum/';
9
+ const KEEP_LOCK = '!.flareum/lock.json';
10
+ // The repo ROOT, not cwd: a nested package has no .git of its own, and a bare pattern there matches
11
+ // at any depth. `.git` may be a file — worktrees and submodules write one.
12
+ export const repoRoot = (from, exists) => {
13
+ let dir = from;
14
+ for (;;) {
15
+ if (exists(join(dir, '.git')))
16
+ return dir;
17
+ const up = dirname(dir);
18
+ if (up === dir || dir === parse(dir).root)
19
+ return null;
20
+ dir = up;
21
+ }
22
+ };
6
23
  // Appended only when the pattern is absent: this file holds a key, and a project that commits it
7
24
  // has published one. Never rewrites an existing .gitignore beyond that one line.
8
25
  const ensureGitignored = async (cwd) => {
9
- const path = join(cwd, '.gitignore');
10
26
  try {
11
- if (existsSync(path) && readFileSync(path, 'utf8').split('\n').some(l => l.trim() === IGNORE_LINE))
12
- return true;
13
- if (!existsSync(join(cwd, '.git')))
27
+ const root = repoRoot(cwd, existsSync);
28
+ if (!root)
14
29
  return false;
15
- await appendFile(path, `\n# Holds a Flareum API key\n${IGNORE_LINE}\n`, 'utf8');
30
+ const path = join(root, '.gitignore');
31
+ const lines = existsSync(path) ? readFileSync(path, 'utf8').split('\n').map(l => l.trim()) : [];
32
+ if (lines.includes(IGNORE_LINE) || lines.includes(KEEP_LOCK))
33
+ return true;
34
+ // An earlier version ignored the whole directory, which would now hide the committed lock too.
35
+ const rescue = lines.includes(BLANKET) ? `${KEEP_LOCK}\n` : `${IGNORE_LINE}\n`;
36
+ await appendFile(path, `\n# The key is private; the lock is committed.\n${rescue}`, 'utf8');
16
37
  return true;
17
38
  }
18
39
  catch {
package/dist/server.js CHANGED
@@ -6,11 +6,15 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
6
6
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
7
7
  import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
8
8
  import { FlareumApiError, FlareumClient } from './client.js';
9
- import { TOOL_DESCRIPTIONS, formatError, formatSearch, formatVariable } from './tools.js';
9
+ import { TOOL_DESCRIPTIONS, formatChanges, formatError, formatSearch, formatVariable } from './tools.js';
10
10
  import { installSkill, skillInstallReport } from './skill.js';
11
11
  import { configuredOut, firstPullReport, runFirstPull } from './first-pull.js';
12
12
  import { projectConfigReport, writeProjectConfig } from './project-config.js';
13
13
  import { checkKey } from './key-check.js';
14
+ import { readLock } from './lock.js';
15
+ // The agent should not have to be told which version to diff from — the lock in the project is the
16
+ // answer, and asking the model to supply it invites a guess.
17
+ const lockedVersion = () => readLock(process.cwd())?.syncedVersionId ?? '';
14
18
  // A missing key threw at module load, and an editor shows that as a Node stack trace with the
15
19
  // message buried in it. Say it in one line and exit, the way the CLI already does.
16
20
  let client;
@@ -42,6 +46,16 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
42
46
  required: ['query'],
43
47
  },
44
48
  },
49
+ {
50
+ name: 'flareum_changes_since',
51
+ description: TOOL_DESCRIPTIONS.changes,
52
+ inputSchema: {
53
+ type: 'object',
54
+ properties: {
55
+ since: { type: 'string', description: 'The syncedVersionId from .flareum/lock.json. Omit to list everything this project has.' },
56
+ },
57
+ },
58
+ },
45
59
  {
46
60
  name: 'flareum_get',
47
61
  description: TOOL_DESCRIPTIONS.get,
@@ -61,6 +75,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
61
75
  try {
62
76
  if (request.params.name === 'flareum_search')
63
77
  return text(formatSearch(await client.search(String(args.query ?? ''), Number(args.limit) || undefined)));
78
+ if (request.params.name === 'flareum_changes_since')
79
+ return text(formatChanges(await client.changes(String(args.since ?? '')), lockedVersion()));
64
80
  if (request.params.name === 'flareum_get')
65
81
  return text(formatVariable(await client.variable(String(args.path ?? ''))));
66
82
  return text(`Unknown tool: ${request.params.name}`);
@@ -74,24 +90,25 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
74
90
  hint: 'Check FLAREUM_API and that the machine is online.' }));
75
91
  }
76
92
  });
77
- // Never silently: if the skill does not land, the agent hardcodes values and nothing says why.
78
- console.error(skillInstallReport(await installSkill(process.cwd())));
79
- // First connection with no stylesheets pulled yet: fetch them now, into the folder this project
80
- // already keeps its styles in. Reported, never asked — a stdio server has nobody to prompt.
81
- // The CLI reads its key from the project, the server from its environment — two sources that can
82
- // disagree, and did: `flareum pull` said "no key" in a project whose server was connected.
83
93
  // "Connected" only means this process started, so a revoked key looked healthy while every lookup
84
- // failed. One authenticated call decides it before anything else runs.
85
- const key = await checkKey(() => client.published());
94
+ // failed. One request, before anything else, so the editor is told rather than the tools failing.
95
+ const key = await checkKey(() => client.published().then(r => r.publishedVersionId));
86
96
  if (!key.usable) {
87
97
  console.error(key.message);
88
98
  if (key.fatal)
89
99
  process.exit(1);
90
100
  }
91
- const configWritten = projectConfigReport(await writeProjectConfig(process.cwd(), process.env, configuredOut(process.cwd(), process.env)));
101
+ // The handshake goes FIRST. Provisioning used to run before it, so a slow pull delayed the reply
102
+ // and a client that gave up killed the process mid-write — 12 of 24 stylesheets, stamped as whole.
103
+ await server.connect(new StdioServerTransport());
104
+ const out = configuredOut(process.cwd(), process.env);
105
+ // Never silently: if the skill does not land, the agent hardcodes values and nothing says why.
106
+ console.error(skillInstallReport(await installSkill(process.cwd())));
107
+ const configWritten = projectConfigReport(await writeProjectConfig(process.cwd(), process.env, out));
92
108
  if (configWritten)
93
109
  console.error(configWritten);
94
- const firstPull = firstPullReport(await runFirstPull(client, process.cwd(), process.env, configuredOut(process.cwd(), process.env)));
110
+ // The version the key check already fetched, so a start asks for it once rather than twice — and
111
+ // on a server without /published each ask costs a whole bucket listing.
112
+ const firstPull = firstPullReport(await runFirstPull(client, process.cwd(), process.env, out, key.version));
95
113
  if (firstPull)
96
114
  console.error(firstPull);
97
- await server.connect(new StdioServerTransport());
package/dist/tools.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { SearchResponse, VariableResponse } from './client.js';
1
+ import type { ChangesResponse, SearchResponse, VariableResponse } from './client.js';
2
2
  export declare const NO_MATCH_INSTRUCTION: string;
3
3
  /** What `flareum_search` hands back. */
4
4
  export declare const formatSearch: (result: SearchResponse) => string;
@@ -12,4 +12,6 @@ export declare const formatError: ({ code, message, hint }: {
12
12
  export declare const TOOL_DESCRIPTIONS: {
13
13
  readonly search: string;
14
14
  readonly get: string;
15
+ readonly changes: string;
15
16
  };
17
+ export declare const formatChanges: ({ since, totalChanges, entries, truncated, liveVersionId }: ChangesResponse, lockedVersion?: string) => string;
package/dist/tools.js CHANGED
@@ -71,4 +71,51 @@ export const TOOL_DESCRIPTIONS = {
71
71
  + 'read the header before acting.',
72
72
  get: 'Read one design token in full: its value in every mode, its type, which components use it, and '
73
73
  + 'which other tokens reference it. Use it to judge what a change would affect before making one.',
74
+ changes: 'List what changed in Flareum since the version this project last synced. Call it when the user '
75
+ + 'asks to update, sync or migrate tokens, and at the start of a session in a project that has a '
76
+ + '.flareum/lock.json. A rename reads as a rename with both names, so rewrite the call sites '
77
+ + 'rather than deleting and adding. A removed token carries what to use instead. Read every '
78
+ + 'entry before editing anything, and say what you are about to change.',
79
+ };
80
+ // Written for a model that is about to EDIT files: the CSS names first, because those are what it
81
+ // greps for, and the action stated per line so a rename is never applied as a delete plus an add.
82
+ export const formatChanges = ({ since, totalChanges, entries, truncated, liveVersionId }, lockedVersion = '') => {
83
+ if (!totalChanges)
84
+ return since
85
+ ? `No changes since ${since}. The project is at ${liveVersionId}.`
86
+ : `This project has no tokens yet.`;
87
+ const lines = [
88
+ since
89
+ ? `${totalChanges} change(s) since ${since}. The project is now at ${liveVersionId}.`
90
+ : `${totalChanges} change(s). No baseline was given, so this is everything the project has.`,
91
+ '',
92
+ ];
93
+ if (lockedVersion && since && lockedVersion !== since)
94
+ lines.push(`NOTE: .flareum/lock.json says ${lockedVersion}, which is not what was asked for.`, '');
95
+ for (const entry of entries)
96
+ lines.push(` ${changeLine(entry)}`);
97
+ if (truncated)
98
+ lines.push('', `Only the first ${entries.length} are listed. Apply these, sync, then ask again.`);
99
+ lines.push('', 'A rename is a RENAME: rewrite the call sites, never delete and re-add.');
100
+ return lines.join('\n');
101
+ };
102
+ const nameOf = (side) => side?.cssName ?? side?.path ?? side?.name ?? '?';
103
+ const changeLine = (entry) => {
104
+ const { kind, change, before, after } = entry;
105
+ const via = entry.viaFolder ? ` (folder ${entry.viaFolder.from} → ${entry.viaFolder.to})` : '';
106
+ const mode = entry.mode ? ` [${entry.mode}]` : '';
107
+ if (change === 'renamed' || change === 'moved')
108
+ return `${kind} ${change}: ${nameOf(before)} → ${nameOf(after)}${via}`;
109
+ if (change === 'deleted')
110
+ return `${kind} removed: ${nameOf(before)}`
111
+ + (entry.replacement?.value ? ` — use instead: ${entry.replacement.value}` : '');
112
+ if (change === 'added')
113
+ return `${kind} added: ${nameOf(after)}`;
114
+ if (change === 'typeChanged')
115
+ return `${kind} type: ${nameOf(before)} is now ${after?.type} (was ${before?.type})`;
116
+ if (change === 'valueChanged')
117
+ return `${kind} value${mode}: ${nameOf(before)} ${before?.value} → ${after?.value}`;
118
+ if (change === 'movedCollection')
119
+ return `${kind} moved collection: ${nameOf(before)} → ${nameOf(after)}`;
120
+ return `${kind} ${change}: ${nameOf(after ?? before)}`;
74
121
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flareum/mcp",
3
- "version": "0.2.10",
3
+ "version": "0.5.0",
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
@@ -30,6 +30,7 @@ page, and it is worth reading in full before the first line of code.
30
30
  |---|---|
31
31
  | Any text — a heading, a label, body copy, a pseudo-element's `content` | [references/typography.md](references/typography.md) |
32
32
  | A token name, or judging one that already exists | [references/naming.md](references/naming.md) |
33
+ | Applying what `flareum_changes_since` returned — a sync, an update, a migration | [references/applying.md](references/applying.md) |
33
34
 
34
35
  ## Before the first token: check the stylesheets are imported
35
36
 
@@ -67,6 +68,17 @@ that is imported but still not applying is usually the `font:` shorthand or a mi
67
68
  If the pulled folder does not exist at all, the project has never pulled: say so and give the
68
69
  command — `npx -p @flareum/mcp flareum pull` — rather than writing tokens that cannot resolve.
69
70
 
71
+ ## When the project is behind
72
+
73
+ A project with a `.flareum/lock.json` was synced at a particular version. `flareum_changes_since`
74
+ says what has changed since — a rename reads as a rename, a removed token carries what to use
75
+ instead. Reach for it when the user asks to sync, update or migrate tokens, and at the start of a
76
+ session in a project that has a lock.
77
+
78
+ **Applying those entries has an order that matters**, and getting it wrong leaves a project that
79
+ reports itself synced while still referencing dead tokens:
80
+ [references/applying.md](references/applying.md).
81
+
70
82
  ## Reading a search result
71
83
 
72
84
  The header tells you which kind of answer you got, and they mean different things:
@@ -0,0 +1,70 @@
1
+ # Applying what changed
2
+
3
+ Read this when `flareum_changes_since` returns entries, or the user asks to sync, update or migrate
4
+ tokens.
5
+
6
+ ## The order is the safety
7
+
8
+ **Rewrite the call sites first. Pull last.**
9
+
10
+ `flareum pull` brings the new stylesheets AND stamps `.flareum/lock.json`, which is what records the
11
+ project as synced. Pull first and a rewrite that fails halfway leaves the lock advanced over a
12
+ codebase still referencing dead tokens — the next session reports nothing to do, and the breakage is
13
+ invisible. Pull last and a failed rewrite simply leaves the project where it was.
14
+
15
+ ```
16
+ 1. flareum_changes_since read every entry before editing anything
17
+ 2. show the developer what you are about to change, and what you cannot
18
+ 3. rewrite the call sites names only
19
+ 4. npx -p @flareum/mcp flareum pull the new tree, and the lock
20
+ ```
21
+
22
+ Do not update `lock.json` by hand. It is stamped by a successful pull, and stamping it any other way
23
+ is claiming work that did not happen.
24
+
25
+ ## What you may change, and what you may not
26
+
27
+ | You may | You may not |
28
+ |---|---|
29
+ | Rewrite `var(--old)` → `var(--new)` across the repo | Change a **value**. Only a name. |
30
+ | Substitute a removed token's recorded replacement | Guess a replacement when none is recorded |
31
+ | Replace the pulled `variables/` tree by pulling | Hand-edit anything inside it — it is generated |
32
+ | Report what you could not resolve | Silently skip an entry |
33
+
34
+ ## Entry by entry
35
+
36
+ **`renamed` / `moved`** — one entry carries both names. Rewrite every occurrence of `before.cssName`
37
+ to `after.cssName`. It is a rename: do not delete the old and add the new, and do not touch the value.
38
+
39
+ **`viaFolder`** — this rename is part of a whole folder moving. Apply it as one prefix rewrite across
40
+ the members rather than N independent edits, and say so once rather than N times.
41
+
42
+ **`deleted` with a replacement** — substitute it. The replacement is what the designer recorded as
43
+ the successor, and it may be a token, a formula or a literal.
44
+
45
+ **`deleted` with NO replacement** — **report it and leave it alone.** You have no basis for choosing
46
+ a successor, and a plausible-looking guess is how a design system quietly acquires wrong semantics.
47
+ Name the token, name the files that use it, and let the developer decide.
48
+
49
+ **`valueChanged`** — nothing to do in the code. The value lives in the stylesheets the pull replaces;
50
+ a call site referencing the token gets the new value for free. Mention it only if the change is large
51
+ enough that someone should look at the result.
52
+
53
+ **`typeChanged`** — the token still exists under the same name, but it is a different kind of value
54
+ now (a colour that became a number). The call sites may still compile and be wrong. Report each one;
55
+ do not rewrite them silently.
56
+
57
+ **`added`** — nothing to apply. Worth mentioning only if it replaces something you are about to
58
+ report as removed.
59
+
60
+ **`reordered`** — ignore. It affects the order tokens are emitted in, not any call site.
61
+
62
+ ## Before you touch a file
63
+
64
+ Say what you are about to do, in the developer's terms: how many call sites, in how many files, and
65
+ what you will not be touching. A migration nobody agreed to is a migration nobody can review.
66
+
67
+ ## When it does not go cleanly
68
+
69
+ Report what failed and **do not pull**. A partial rewrite with the old stylesheets still in place is
70
+ a working project; a partial rewrite with the lock advanced is a broken one that claims to be current.