@flareum/mcp 0.2.11 → 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 +1 -1
- package/dist/cli.js +6 -0
- package/dist/client.d.ts +41 -0
- package/dist/client.js +3 -0
- package/dist/config.js +1 -1
- package/dist/first-pull.d.ts +5 -4
- package/dist/first-pull.js +53 -17
- package/dist/key-check.d.ts +4 -2
- package/dist/key-check.js +3 -3
- package/dist/lock.d.ts +18 -0
- package/dist/lock.js +36 -0
- package/dist/project-config.d.ts +1 -0
- package/dist/project-config.js +28 -7
- package/dist/server.js +29 -12
- package/dist/tools.d.ts +3 -1
- package/dist/tools.js +47 -0
- package/package.json +1 -1
- package/skill/SKILL.md +12 -0
- package/skill/references/applying.md +70 -0
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
|
|
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
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
|
package/dist/first-pull.d.ts
CHANGED
|
@@ -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
|
|
13
|
-
export declare const needsPull: (exists: Probe,
|
|
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>;
|
package/dist/first-pull.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
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';
|
|
6
|
+
import { IN_FLIGHT, LEGACY_STAMP, legacyVersion, makeLock, owns, readLock, syncedVersion, writeLock, } from './lock.js';
|
|
5
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.
|
|
@@ -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
|
-
|
|
34
|
-
export const
|
|
35
|
-
|
|
36
|
-
//
|
|
37
|
-
|
|
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
|
-
|
|
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,28 +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
|
|
104
|
+
const isEmpty = path => {
|
|
82
105
|
try {
|
|
83
|
-
return
|
|
106
|
+
return readdirSync(resolve(cwd, path)).length === 0;
|
|
84
107
|
}
|
|
85
108
|
catch {
|
|
86
|
-
return
|
|
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 =
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
//
|
|
98
|
-
// stamping on its return
|
|
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.
|
|
99
135
|
if (!isRetryable(result))
|
|
100
|
-
await
|
|
136
|
+
await writeLock(cwd, makeLock(projectId, result.publishedVersionId, dir, now()));
|
|
101
137
|
return { ran: true, dir, result };
|
|
102
138
|
}
|
|
103
139
|
catch (error) {
|
package/dist/key-check.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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));
|
package/dist/project-config.d.ts
CHANGED
|
@@ -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;
|
package/dist/project-config.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
if (!existsSync(join(cwd, '.git')))
|
|
27
|
+
const root = repoRoot(cwd, existsSync);
|
|
28
|
+
if (!root)
|
|
14
29
|
return false;
|
|
15
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
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.
|