@floomhq/floom-mcp-sync 1.0.25 → 1.0.27
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 +2 -2
- package/dist/auto-sync.js +133 -37
- package/dist/lib/api.js +21 -53
- package/dist/lib/config.js +3 -58
- package/dist/lib/paths.js +3 -0
- package/dist/server.js +5 -2
- package/dist/tools/install.js +117 -0
- package/dist/tools/libraries.js +28 -0
- package/dist/tools/publish.js +24 -0
- package/package.json +3 -8
package/README.md
CHANGED
|
@@ -49,7 +49,7 @@ args = ["-y", "@floomhq/floom-mcp-sync"]
|
|
|
49
49
|
FLOOM_SKILLS_DIR = "~/.codex/skills"
|
|
50
50
|
```
|
|
51
51
|
|
|
52
|
-
Directory resolution order is `FLOOM_SKILLS_DIR`, `CLAUDE_SKILLS_DIR`, `CODEX_SKILLS_DIR`, then `~/.claude/skills`.
|
|
52
|
+
Directory resolution order is `FLOOM_SKILLS_DIR`, `CLAUDE_SKILLS_DIR`, `CODEX_SKILLS_DIR`, `CURSOR_SKILLS_DIR`, `OPENCODE_SKILLS_DIR`, `KIMI_SKILLS_DIR`, then `~/.claude/skills`.
|
|
53
53
|
|
|
54
54
|
## Sync Behavior
|
|
55
55
|
|
|
@@ -59,7 +59,7 @@ Version 1 sync does not replace existing local files. Remote updates, existing u
|
|
|
59
59
|
|
|
60
60
|
The poll uses HTTP `If-None-Match` against `/api/v1/me/skills`, so unchanged responses are `304` with no body. If a Floom-tracked local file is missing after a `304`, MCP refetches once without the ETag so Version 1 can recreate missing files without overwriting existing files.
|
|
61
61
|
|
|
62
|
-
Configure the poll interval with `FLOOM_SYNC_INTERVAL_MS` (default `60000`, minimum `10000`).
|
|
62
|
+
Configure the preview poll interval with `FLOOM_SYNC_INTERVAL_MS` (default `60000`, minimum `10000`).
|
|
63
63
|
|
|
64
64
|
## Tools
|
|
65
65
|
|
package/dist/auto-sync.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { constants } from "node:fs";
|
|
2
2
|
import { lstat, mkdir, open } from "node:fs/promises";
|
|
3
3
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
|
-
import { apiUrlFromConfig, readConfig
|
|
4
|
+
import { apiUrlFromConfig, readConfig } from "./lib/config.js";
|
|
5
5
|
import { getJsonWithEtag, FloomApiError } from "./lib/api.js";
|
|
6
6
|
import { sha256 } from "./lib/hash.js";
|
|
7
7
|
import { assertValidSlug } from "./lib/slug.js";
|
|
@@ -17,23 +17,111 @@ const HEARTBEAT_MS = 10 * 60 * 1000; // 10 minutes
|
|
|
17
17
|
const AUTH_WARNING_MS = 10 * 60 * 1000;
|
|
18
18
|
const MANIFEST_SEGMENT_RE = /^[A-Za-z0-9._-]{1,128}$/;
|
|
19
19
|
const PACKAGE_FILE_SEGMENT_RE = /^[A-Za-z0-9._-]{1,128}$/;
|
|
20
|
-
const SUPPORT_DIRS = new Set([
|
|
20
|
+
const SUPPORT_DIRS = new Set([
|
|
21
|
+
".github",
|
|
22
|
+
"agents",
|
|
23
|
+
"assets",
|
|
24
|
+
"bin",
|
|
25
|
+
"canvas-fonts",
|
|
26
|
+
"checks",
|
|
27
|
+
"claude",
|
|
28
|
+
"codex",
|
|
29
|
+
"contrib",
|
|
30
|
+
"core",
|
|
31
|
+
"cursor",
|
|
32
|
+
"design",
|
|
33
|
+
"docs",
|
|
34
|
+
"evidence",
|
|
35
|
+
"examples",
|
|
36
|
+
"extension",
|
|
37
|
+
"helpers",
|
|
38
|
+
"hosts",
|
|
39
|
+
"kimi",
|
|
40
|
+
"lib",
|
|
41
|
+
"migrations",
|
|
42
|
+
"model-overlays",
|
|
43
|
+
"openclaw",
|
|
44
|
+
"opencode",
|
|
45
|
+
"reference",
|
|
46
|
+
"references",
|
|
47
|
+
"remotion-starter",
|
|
48
|
+
"scripts",
|
|
49
|
+
"sdk",
|
|
50
|
+
"schemas",
|
|
51
|
+
"src",
|
|
52
|
+
"specialists",
|
|
53
|
+
"supabase",
|
|
54
|
+
"templates",
|
|
55
|
+
"test",
|
|
56
|
+
"tests",
|
|
57
|
+
"themes",
|
|
58
|
+
"vendor",
|
|
59
|
+
]);
|
|
60
|
+
const ROOT_SUPPORT_FILES = new Set([
|
|
61
|
+
".env.example",
|
|
62
|
+
"ACKNOWLEDGEMENTS.md",
|
|
63
|
+
"CHANGELOG.md",
|
|
64
|
+
"LICENSE",
|
|
65
|
+
"LICENSE.md",
|
|
66
|
+
"LICENSE.txt",
|
|
67
|
+
"README.md",
|
|
68
|
+
"AGENTS.md",
|
|
69
|
+
"ARCHITECTURE.md",
|
|
70
|
+
"BROWSER.md",
|
|
71
|
+
"CLAUDE.md",
|
|
72
|
+
"CONTRIBUTING.md",
|
|
73
|
+
"DESIGN.md",
|
|
74
|
+
"ETHOS.md",
|
|
75
|
+
"PLAN-snapshot-dropdown-interactive.md",
|
|
76
|
+
"REMOTION_PROTOCOL.md",
|
|
77
|
+
"SKILL.md.tmpl",
|
|
78
|
+
"TODOS.md",
|
|
79
|
+
"TODOS-format.md",
|
|
80
|
+
"USING_GBRAIN_WITH_GSTACK.md",
|
|
81
|
+
"VERSION",
|
|
82
|
+
".gitlab-ci.yml",
|
|
83
|
+
"actionlint.yaml",
|
|
84
|
+
"bun.lock",
|
|
85
|
+
"checklist.md",
|
|
86
|
+
"conductor.json",
|
|
87
|
+
"design-checklist.md",
|
|
88
|
+
"dx-hall-of-fame.md",
|
|
89
|
+
"editing.md",
|
|
90
|
+
"forms.md",
|
|
91
|
+
"instructions.md",
|
|
92
|
+
"nanobanana.py",
|
|
93
|
+
"package.json",
|
|
94
|
+
"plan.md",
|
|
95
|
+
"pptxgenjs.md",
|
|
96
|
+
"reference.md",
|
|
97
|
+
"requirements.txt",
|
|
98
|
+
"greptile-triage.md",
|
|
99
|
+
"setup",
|
|
100
|
+
"skill.md",
|
|
101
|
+
"slop-scan.config.json",
|
|
102
|
+
"style_guidelines.md",
|
|
103
|
+
"theme-showcase.pdf",
|
|
104
|
+
]);
|
|
105
|
+
const GENERATED_PACKAGE_DIRS = new Set([
|
|
106
|
+
".cache",
|
|
107
|
+
".git",
|
|
108
|
+
".next",
|
|
109
|
+
".pytest_cache",
|
|
110
|
+
"__pycache__",
|
|
111
|
+
"build",
|
|
112
|
+
"coverage",
|
|
113
|
+
"dist",
|
|
114
|
+
"fixtures",
|
|
115
|
+
"node_modules",
|
|
116
|
+
"out",
|
|
117
|
+
]);
|
|
118
|
+
const GENERATED_PACKAGE_FILES = new Set([".DS_Store"]);
|
|
119
|
+
const GENERATED_PACKAGE_FILE_SUFFIXES = [".icns", ".log", ".mov", ".mp3", ".mp4", ".pyc", ".pyo", ".wav", ".webm"];
|
|
21
120
|
const FD_PATH_ROOT = "/proc/self/fd";
|
|
22
|
-
const PACKAGE_FILE_LIMIT =
|
|
23
|
-
const PACKAGE_TOTAL_BYTES_LIMIT =
|
|
121
|
+
const PACKAGE_FILE_LIMIT = 1000;
|
|
122
|
+
const PACKAGE_TOTAL_BYTES_LIMIT = 8_000_000;
|
|
24
123
|
const PACKAGE_FILE_BYTES_LIMIT = 500_000;
|
|
25
124
|
const BASE64_RE = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
|
|
26
|
-
function hasStructuredPath(skill) {
|
|
27
|
-
return Boolean(skill.library_slug || skill.folder);
|
|
28
|
-
}
|
|
29
|
-
function dedupeSyncSkills(skills) {
|
|
30
|
-
const structuredSlugs = new Set();
|
|
31
|
-
for (const skill of skills) {
|
|
32
|
-
if (hasStructuredPath(skill))
|
|
33
|
-
structuredSlugs.add(skill.slug);
|
|
34
|
-
}
|
|
35
|
-
return skills.filter((skill) => !structuredSlugs.has(skill.slug) || hasStructuredPath(skill));
|
|
36
|
-
}
|
|
37
125
|
async function localState(path) {
|
|
38
126
|
try {
|
|
39
127
|
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
@@ -173,6 +261,8 @@ function normalizePackageFilePath(path) {
|
|
|
173
261
|
const segments = path.split("/").filter(Boolean);
|
|
174
262
|
if (segments.length === 1 && segments[0] === "SKILL.md")
|
|
175
263
|
return "SKILL.md";
|
|
264
|
+
if (segments.length === 1 && ROOT_SUPPORT_FILES.has(segments[0] ?? ""))
|
|
265
|
+
return segments[0] ?? "";
|
|
176
266
|
const first = segments[0];
|
|
177
267
|
if (segments.length < 2 || first === undefined || !SUPPORT_DIRS.has(first)) {
|
|
178
268
|
throw new Error("Invalid package file path");
|
|
@@ -180,6 +270,16 @@ function normalizePackageFilePath(path) {
|
|
|
180
270
|
if (segments.some((segment) => segment === "." || segment === ".." || !PACKAGE_FILE_SEGMENT_RE.test(segment))) {
|
|
181
271
|
throw new Error("Invalid package file path");
|
|
182
272
|
}
|
|
273
|
+
if (segments.some((segment, index) => {
|
|
274
|
+
const isLast = index === segments.length - 1;
|
|
275
|
+
if (!isLast && GENERATED_PACKAGE_DIRS.has(segment))
|
|
276
|
+
return true;
|
|
277
|
+
if (GENERATED_PACKAGE_FILES.has(segment))
|
|
278
|
+
return true;
|
|
279
|
+
return isLast && GENERATED_PACKAGE_FILE_SUFFIXES.some((suffix) => segment.endsWith(suffix));
|
|
280
|
+
})) {
|
|
281
|
+
throw new Error("Generated package artifacts are not allowed");
|
|
282
|
+
}
|
|
183
283
|
return segments.join("/");
|
|
184
284
|
}
|
|
185
285
|
async function planPackageSync(root, files, manifest) {
|
|
@@ -210,8 +310,12 @@ async function planPackageSync(root, files, manifest) {
|
|
|
210
310
|
missing += 1;
|
|
211
311
|
continue;
|
|
212
312
|
}
|
|
213
|
-
if (!tracked)
|
|
214
|
-
|
|
313
|
+
if (!tracked) {
|
|
314
|
+
if (state.hash !== file.hash)
|
|
315
|
+
return { kind: "conflict", target: file.target, reason: "existing file is not tracked by Floom sync" };
|
|
316
|
+
unchanged += 1;
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
215
319
|
if (state.hash !== tracked.hash)
|
|
216
320
|
return { kind: "conflict", target: file.target, reason: "local file changed since the last Floom sync" };
|
|
217
321
|
if (state.hash !== file.hash)
|
|
@@ -254,29 +358,21 @@ export async function autoSync(log = (message) => process.stderr.write(`${messag
|
|
|
254
358
|
return await withSyncLock(async () => {
|
|
255
359
|
const manifest = await readSyncManifest();
|
|
256
360
|
const root = skillsDir();
|
|
257
|
-
|
|
258
|
-
const apiUrl = apiUrlFromConfig(activeCfg);
|
|
361
|
+
const apiUrl = apiUrlFromConfig(cfg);
|
|
259
362
|
let response;
|
|
260
363
|
try {
|
|
261
|
-
response = await getJsonWithEtag(`${apiUrl}/api/v1/me/skills`,
|
|
364
|
+
response = await getJsonWithEtag(`${apiUrl}/api/v1/me/skills`, cfg.accessToken, cachedEtag, signal);
|
|
262
365
|
}
|
|
263
366
|
catch (err) {
|
|
264
367
|
if (err instanceof FloomApiError && err.status === 401) {
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
maybeAuthWarning(log, "[floom] sign-in expired; skipping sync (run `npx -y @floomhq/floom login` again)");
|
|
268
|
-
return { synced: 0, unchanged: 0, updated: 0, conflicts: 0 };
|
|
269
|
-
}
|
|
270
|
-
activeCfg = refreshed;
|
|
271
|
-
response = await getJsonWithEtag(`${apiUrl}/api/v1/me/skills`, activeCfg.accessToken, cachedEtag, signal);
|
|
272
|
-
}
|
|
273
|
-
else {
|
|
274
|
-
throw err;
|
|
368
|
+
maybeAuthWarning(log, "[floom] sign-in expired; skipping sync (run `npx -y @floomhq/floom login` again)");
|
|
369
|
+
return { synced: 0, unchanged: 0, updated: 0, conflicts: 0 };
|
|
275
370
|
}
|
|
371
|
+
throw err;
|
|
276
372
|
}
|
|
277
373
|
if (response.status === 304) {
|
|
278
374
|
if (await manifestHasMissingTrackedFile(manifest, root)) {
|
|
279
|
-
response = await getJsonWithEtag(`${apiUrl}/api/v1/me/skills`,
|
|
375
|
+
response = await getJsonWithEtag(`${apiUrl}/api/v1/me/skills`, cfg.accessToken, null, signal);
|
|
280
376
|
}
|
|
281
377
|
else {
|
|
282
378
|
maybeHeartbeat(log);
|
|
@@ -299,12 +395,9 @@ export async function autoSync(log = (message) => process.stderr.write(`${messag
|
|
|
299
395
|
}
|
|
300
396
|
for (const skill of payload.skills)
|
|
301
397
|
validateSyncSkillShape(skill);
|
|
302
|
-
// Version 1
|
|
303
|
-
// the single /me/skills response. If a slug appears both at top level and in
|
|
304
|
-
// a library/folder placement, keep only the structured placement so agents do
|
|
305
|
-
// not see duplicate native packages.
|
|
398
|
+
// Version 1 preview syncs owned published skills only.
|
|
306
399
|
const buckets = [
|
|
307
|
-
{ skills:
|
|
400
|
+
{ skills: payload.skills, defaultLib: null },
|
|
308
401
|
];
|
|
309
402
|
let unchanged = 0;
|
|
310
403
|
let updated = 0;
|
|
@@ -358,6 +451,9 @@ export async function autoSync(log = (message) => process.stderr.write(`${messag
|
|
|
358
451
|
continue;
|
|
359
452
|
}
|
|
360
453
|
if (plan.kind === "unchanged") {
|
|
454
|
+
for (const file of packageFiles)
|
|
455
|
+
markSynced(manifest, manifestKey(root, file.target), skill.slug, file.hash);
|
|
456
|
+
await writeSyncManifest(manifest);
|
|
361
457
|
unchanged += 1;
|
|
362
458
|
continue;
|
|
363
459
|
}
|
|
@@ -436,7 +532,7 @@ export async function autoSync(log = (message) => process.stderr.write(`${messag
|
|
|
436
532
|
if (updated > 0) {
|
|
437
533
|
// Activation telemetry counts syncs that write new content. Best-effort;
|
|
438
534
|
// never blocks or throws.
|
|
439
|
-
void emitSyncCompleted(apiUrl,
|
|
535
|
+
void emitSyncCompleted(apiUrl, cfg.accessToken, { total, updated, unchanged }, signal).catch(() => { });
|
|
440
536
|
}
|
|
441
537
|
}
|
|
442
538
|
else if (conflicts > 0) {
|
package/dist/lib/api.js
CHANGED
|
@@ -6,53 +6,6 @@ export class FloomApiError extends Error {
|
|
|
6
6
|
this.name = "FloomApiError";
|
|
7
7
|
}
|
|
8
8
|
}
|
|
9
|
-
export const DEFAULT_TIMEOUT_MS = 15_000;
|
|
10
|
-
function withTimeout(signal) {
|
|
11
|
-
const controller = new AbortController();
|
|
12
|
-
const timeoutMs = requestTimeoutMs();
|
|
13
|
-
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
14
|
-
const abort = () => controller.abort();
|
|
15
|
-
signal?.addEventListener("abort", abort, { once: true });
|
|
16
|
-
if (signal?.aborted)
|
|
17
|
-
controller.abort();
|
|
18
|
-
const aborted = new Promise((_, reject) => {
|
|
19
|
-
controller.signal.addEventListener("abort", () => {
|
|
20
|
-
reject(new FloomApiError(408, "Request timed out while contacting Floom."));
|
|
21
|
-
}, { once: true });
|
|
22
|
-
});
|
|
23
|
-
return {
|
|
24
|
-
signal: controller.signal,
|
|
25
|
-
aborted,
|
|
26
|
-
cleanup: () => {
|
|
27
|
-
clearTimeout(timer);
|
|
28
|
-
signal?.removeEventListener("abort", abort);
|
|
29
|
-
},
|
|
30
|
-
};
|
|
31
|
-
}
|
|
32
|
-
function requestTimeoutMs() {
|
|
33
|
-
const fromEnv = Number(process.env.FLOOM_REQUEST_TIMEOUT_MS);
|
|
34
|
-
if (Number.isFinite(fromEnv) && fromEnv > 0)
|
|
35
|
-
return fromEnv;
|
|
36
|
-
return DEFAULT_TIMEOUT_MS;
|
|
37
|
-
}
|
|
38
|
-
async function fetchWithTimeout(url, init, signal) {
|
|
39
|
-
const timeout = withTimeout(signal);
|
|
40
|
-
try {
|
|
41
|
-
return await Promise.race([
|
|
42
|
-
fetch(url, { ...init, signal: timeout.signal }),
|
|
43
|
-
timeout.aborted,
|
|
44
|
-
]);
|
|
45
|
-
}
|
|
46
|
-
catch (err) {
|
|
47
|
-
if (err.name === "AbortError") {
|
|
48
|
-
throw new FloomApiError(408, "Request timed out while contacting Floom.");
|
|
49
|
-
}
|
|
50
|
-
throw err;
|
|
51
|
-
}
|
|
52
|
-
finally {
|
|
53
|
-
timeout.cleanup();
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
9
|
async function readError(res) {
|
|
57
10
|
const text = await res.text();
|
|
58
11
|
if (!text)
|
|
@@ -71,7 +24,10 @@ export async function getJson(url, token, signal) {
|
|
|
71
24
|
const headers = {};
|
|
72
25
|
if (token)
|
|
73
26
|
headers.authorization = `Bearer ${token}`;
|
|
74
|
-
const
|
|
27
|
+
const init = { headers };
|
|
28
|
+
if (signal)
|
|
29
|
+
init.signal = signal;
|
|
30
|
+
const res = await fetch(url, init);
|
|
75
31
|
if (!res.ok)
|
|
76
32
|
throw new FloomApiError(res.status, await readError(res));
|
|
77
33
|
return (await res.json());
|
|
@@ -84,7 +40,10 @@ export async function getJsonWithEtag(url, token, etag, signal) {
|
|
|
84
40
|
const headers = { authorization: `Bearer ${token}` };
|
|
85
41
|
if (etag)
|
|
86
42
|
headers["if-none-match"] = etag;
|
|
87
|
-
const
|
|
43
|
+
const init = { headers };
|
|
44
|
+
if (signal)
|
|
45
|
+
init.signal = signal;
|
|
46
|
+
const res = await fetch(url, init);
|
|
88
47
|
const responseEtag = res.headers.get("etag");
|
|
89
48
|
if (res.status === 304) {
|
|
90
49
|
return { status: 304, body: null, etag: responseEtag ?? etag };
|
|
@@ -95,7 +54,10 @@ export async function getJsonWithEtag(url, token, etag, signal) {
|
|
|
95
54
|
return { status: res.status, body, etag: responseEtag };
|
|
96
55
|
}
|
|
97
56
|
export async function getText(url, signal) {
|
|
98
|
-
const
|
|
57
|
+
const init = {};
|
|
58
|
+
if (signal)
|
|
59
|
+
init.signal = signal;
|
|
60
|
+
const res = await fetch(url, init);
|
|
99
61
|
if (!res.ok)
|
|
100
62
|
throw new FloomApiError(res.status, await readError(res));
|
|
101
63
|
return res.text();
|
|
@@ -109,7 +71,9 @@ export async function postJson(url, token, body, signal) {
|
|
|
109
71
|
},
|
|
110
72
|
body: JSON.stringify(body),
|
|
111
73
|
};
|
|
112
|
-
|
|
74
|
+
if (signal)
|
|
75
|
+
init.signal = signal;
|
|
76
|
+
const res = await fetch(url, init);
|
|
113
77
|
if (!res.ok)
|
|
114
78
|
throw new FloomApiError(res.status, await readError(res));
|
|
115
79
|
return (await res.json());
|
|
@@ -123,7 +87,9 @@ export async function putJson(url, token, body, signal) {
|
|
|
123
87
|
},
|
|
124
88
|
body: JSON.stringify(body),
|
|
125
89
|
};
|
|
126
|
-
|
|
90
|
+
if (signal)
|
|
91
|
+
init.signal = signal;
|
|
92
|
+
const res = await fetch(url, init);
|
|
127
93
|
if (!res.ok)
|
|
128
94
|
throw new FloomApiError(res.status, await readError(res));
|
|
129
95
|
return (await res.json());
|
|
@@ -133,7 +99,9 @@ export async function deleteRequest(url, token, signal) {
|
|
|
133
99
|
method: "DELETE",
|
|
134
100
|
headers: { authorization: `Bearer ${token}` },
|
|
135
101
|
};
|
|
136
|
-
|
|
102
|
+
if (signal)
|
|
103
|
+
init.signal = signal;
|
|
104
|
+
const res = await fetch(url, init);
|
|
137
105
|
if (!res.ok)
|
|
138
106
|
throw new FloomApiError(res.status, await readError(res));
|
|
139
107
|
return (await res.json());
|
package/dist/lib/config.js
CHANGED
|
@@ -1,10 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { dirname } from "node:path";
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
3
2
|
import { configPath } from "./paths.js";
|
|
4
3
|
export const DEFAULT_API_URL = "https://floom.dev";
|
|
5
|
-
const REFRESH_SKEW_SECONDS = 5 * 60;
|
|
6
4
|
export function apiUrlFromConfig(cfg) {
|
|
7
|
-
return (process.env.FLOOM_API_URL ??
|
|
5
|
+
return (cfg.apiUrl ?? process.env.FLOOM_API_URL ?? DEFAULT_API_URL).replace(/\/$/, "");
|
|
8
6
|
}
|
|
9
7
|
export async function readConfig() {
|
|
10
8
|
try {
|
|
@@ -12,16 +10,13 @@ export async function readConfig() {
|
|
|
12
10
|
const parsed = JSON.parse(raw);
|
|
13
11
|
if (typeof parsed.accessToken !== "string" || parsed.accessToken.length === 0)
|
|
14
12
|
return null;
|
|
15
|
-
|
|
13
|
+
return {
|
|
16
14
|
accessToken: parsed.accessToken,
|
|
17
15
|
...(typeof parsed.apiUrl === "string" ? { apiUrl: parsed.apiUrl } : {}),
|
|
18
16
|
...(typeof parsed.refreshToken === "string" ? { refreshToken: parsed.refreshToken } : {}),
|
|
19
17
|
...(typeof parsed.expiresAt === "number" ? { expiresAt: parsed.expiresAt } : {}),
|
|
20
18
|
...(typeof parsed.email === "string" || parsed.email === null ? { email: parsed.email } : {}),
|
|
21
19
|
};
|
|
22
|
-
if (needsRefresh(cfg))
|
|
23
|
-
return refreshConfig(cfg);
|
|
24
|
-
return cfg;
|
|
25
20
|
}
|
|
26
21
|
catch (err) {
|
|
27
22
|
if (err.code === "ENOENT")
|
|
@@ -29,53 +24,3 @@ export async function readConfig() {
|
|
|
29
24
|
throw err;
|
|
30
25
|
}
|
|
31
26
|
}
|
|
32
|
-
export function needsRefresh(cfg) {
|
|
33
|
-
if (!cfg.refreshToken)
|
|
34
|
-
return false;
|
|
35
|
-
return !Number.isFinite(cfg.expiresAt) || Number(cfg.expiresAt) <= Math.floor(Date.now() / 1000) + REFRESH_SKEW_SECONDS;
|
|
36
|
-
}
|
|
37
|
-
export async function refreshConfig(cfg) {
|
|
38
|
-
if (!cfg.refreshToken)
|
|
39
|
-
return cfg;
|
|
40
|
-
const apiUrl = apiUrlFromConfig(cfg);
|
|
41
|
-
let res;
|
|
42
|
-
try {
|
|
43
|
-
res = await fetch(`${apiUrl}/api/v1/auth/refresh`, {
|
|
44
|
-
method: "POST",
|
|
45
|
-
headers: { "content-type": "application/json" },
|
|
46
|
-
body: JSON.stringify({ refresh_token: cfg.refreshToken }),
|
|
47
|
-
});
|
|
48
|
-
}
|
|
49
|
-
catch {
|
|
50
|
-
return null;
|
|
51
|
-
}
|
|
52
|
-
if (!res.ok)
|
|
53
|
-
return null;
|
|
54
|
-
let data;
|
|
55
|
-
try {
|
|
56
|
-
data = await res.json();
|
|
57
|
-
}
|
|
58
|
-
catch {
|
|
59
|
-
return null;
|
|
60
|
-
}
|
|
61
|
-
if (typeof data.access_token !== "string" || typeof data.refresh_token !== "string")
|
|
62
|
-
return null;
|
|
63
|
-
const expiresAt = typeof data.expires_at === "number" && Number.isFinite(data.expires_at)
|
|
64
|
-
? data.expires_at
|
|
65
|
-
: Math.floor(Date.now() / 1000) + (typeof data.expires_in === "number" && Number.isFinite(data.expires_in) ? data.expires_in : 3600);
|
|
66
|
-
const refreshed = {
|
|
67
|
-
apiUrl,
|
|
68
|
-
accessToken: data.access_token,
|
|
69
|
-
refreshToken: data.refresh_token,
|
|
70
|
-
expiresAt,
|
|
71
|
-
email: typeof data.email === "string" || data.email === null ? data.email : cfg.email ?? null,
|
|
72
|
-
};
|
|
73
|
-
await writeConfig(refreshed);
|
|
74
|
-
return refreshed;
|
|
75
|
-
}
|
|
76
|
-
async function writeConfig(cfg) {
|
|
77
|
-
const target = configPath();
|
|
78
|
-
await mkdir(dirname(target), { recursive: true, mode: 0o700 });
|
|
79
|
-
await writeFile(target, JSON.stringify(cfg, null, 2), { mode: 0o600 });
|
|
80
|
-
await chmod(target, 0o600);
|
|
81
|
-
}
|
package/dist/lib/paths.js
CHANGED
|
@@ -8,6 +8,9 @@ export function skillsDir() {
|
|
|
8
8
|
return expandHome(process.env.FLOOM_SKILLS_DIR
|
|
9
9
|
?? process.env.CLAUDE_SKILLS_DIR
|
|
10
10
|
?? process.env.CODEX_SKILLS_DIR
|
|
11
|
+
?? process.env.CURSOR_SKILLS_DIR
|
|
12
|
+
?? process.env.OPENCODE_SKILLS_DIR
|
|
13
|
+
?? process.env.KIMI_SKILLS_DIR
|
|
11
14
|
?? join(homedir(), ".claude", "skills"));
|
|
12
15
|
}
|
|
13
16
|
function expandHome(path) {
|
package/dist/server.js
CHANGED
|
@@ -5,7 +5,7 @@ import { autoSync } from "./auto-sync.js";
|
|
|
5
5
|
import { getSkill } from "./tools/get.js";
|
|
6
6
|
import { searchSkills } from "./tools/search.js";
|
|
7
7
|
import { syncStatus } from "./tools/status.js";
|
|
8
|
-
const SERVER_VERSION = "1.0.
|
|
8
|
+
const SERVER_VERSION = "1.0.27";
|
|
9
9
|
const DEFAULT_INTERVAL_MS = 60_000;
|
|
10
10
|
const MIN_INTERVAL_MS = 10_000;
|
|
11
11
|
const SEARCH_TYPES = new Set(["knowledge", "instruction", "workflow", "skill"]);
|
|
@@ -30,7 +30,7 @@ function abortAutoSync() {
|
|
|
30
30
|
function usage() {
|
|
31
31
|
return `
|
|
32
32
|
floom-mcp-sync v${SERVER_VERSION}
|
|
33
|
-
Floom MCP server for Version 1 sync.
|
|
33
|
+
Floom MCP server for Version 1 preview sync.
|
|
34
34
|
|
|
35
35
|
Usage
|
|
36
36
|
floom-mcp-sync
|
|
@@ -49,6 +49,9 @@ Env
|
|
|
49
49
|
FLOOM_SKILLS_DIR Override the native skills directory.
|
|
50
50
|
CLAUDE_SKILLS_DIR Claude skills directory override.
|
|
51
51
|
CODEX_SKILLS_DIR Codex skills directory override.
|
|
52
|
+
CURSOR_SKILLS_DIR Cursor skills directory override.
|
|
53
|
+
OPENCODE_SKILLS_DIR OpenCode skills directory override.
|
|
54
|
+
KIMI_SKILLS_DIR Kimi skills directory override.
|
|
52
55
|
FLOOM_SYNC_INTERVAL_MS Poll interval in milliseconds. Minimum: 10000.
|
|
53
56
|
`.trimStart();
|
|
54
57
|
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { lstat, mkdir, open } from "node:fs/promises";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
|
+
import { apiUrlFromConfig, readConfig, DEFAULT_API_URL } from "../lib/config.js";
|
|
6
|
+
import { getText } from "../lib/api.js";
|
|
7
|
+
import { assertValidSlug } from "../lib/slug.js";
|
|
8
|
+
import { skillPath, skillsDir } from "../lib/paths.js";
|
|
9
|
+
const FD_PATH_ROOT = "/proc/self/fd";
|
|
10
|
+
export async function installSkill(slug) {
|
|
11
|
+
assertValidSlug(slug);
|
|
12
|
+
const cfg = await readConfig();
|
|
13
|
+
const apiUrl = cfg ? apiUrlFromConfig(cfg) : (process.env.FLOOM_API_URL ?? DEFAULT_API_URL).replace(/\/$/, "");
|
|
14
|
+
const body = await getText(`${apiUrl}/s/${slug}.md`);
|
|
15
|
+
if (typeof body !== "string")
|
|
16
|
+
throw new Error("Invalid skill response");
|
|
17
|
+
const target = skillPath(slug);
|
|
18
|
+
const remoteHash = sha256(body);
|
|
19
|
+
const existing = await localHash(target);
|
|
20
|
+
if (existing !== null && existing !== remoteHash) {
|
|
21
|
+
throw new Error("Local skill already exists with different content");
|
|
22
|
+
}
|
|
23
|
+
if (existing === null)
|
|
24
|
+
await writeInstallFile(target, body);
|
|
25
|
+
return { slug, path: target, bytes: Buffer.byteLength(body) };
|
|
26
|
+
}
|
|
27
|
+
function sha256(input) {
|
|
28
|
+
return createHash("sha256").update(input).digest("hex");
|
|
29
|
+
}
|
|
30
|
+
async function localHash(path) {
|
|
31
|
+
try {
|
|
32
|
+
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
33
|
+
try {
|
|
34
|
+
const stat = await handle.stat();
|
|
35
|
+
if (!stat.isFile())
|
|
36
|
+
throw new Error("path is blocked by an existing local file or directory");
|
|
37
|
+
return sha256(await handle.readFile("utf8"));
|
|
38
|
+
}
|
|
39
|
+
finally {
|
|
40
|
+
await handle.close();
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
catch (err) {
|
|
44
|
+
if (err.code === "ENOENT")
|
|
45
|
+
return null;
|
|
46
|
+
throw err;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
async function writeInstallFile(target, body) {
|
|
50
|
+
const parent = await openSafeParentDirectory(skillsDir(), target);
|
|
51
|
+
let handle = null;
|
|
52
|
+
try {
|
|
53
|
+
handle = await open(childCreatePath(parent, dirname(target), basename(target)), constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
54
|
+
await writeAll(handle, body);
|
|
55
|
+
}
|
|
56
|
+
finally {
|
|
57
|
+
await handle?.close();
|
|
58
|
+
await parent.close();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
async function openSafeParentDirectory(root, target) {
|
|
62
|
+
await ensureSafeParentDirectory(root, target);
|
|
63
|
+
return open(dirname(target), constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
|
64
|
+
}
|
|
65
|
+
function childCreatePath(parent, fallbackParent, name) {
|
|
66
|
+
if (process.platform === "linux")
|
|
67
|
+
return `${FD_PATH_ROOT}/${parent.fd}/${name}`;
|
|
68
|
+
return join(resolve(fallbackParent), name);
|
|
69
|
+
}
|
|
70
|
+
async function writeAll(handle, body) {
|
|
71
|
+
const buffer = Buffer.from(body, "utf8");
|
|
72
|
+
let offset = 0;
|
|
73
|
+
while (offset < buffer.length) {
|
|
74
|
+
const result = await handle.write(buffer, offset, buffer.length - offset, offset);
|
|
75
|
+
if (result.bytesWritten === 0)
|
|
76
|
+
throw new Error("failed to write local skill file");
|
|
77
|
+
offset += result.bytesWritten;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
async function ensureSafeParentDirectory(root, target) {
|
|
81
|
+
const resolvedRoot = resolve(root);
|
|
82
|
+
const resolvedParent = resolve(dirname(target));
|
|
83
|
+
const relativeParent = relative(resolvedRoot, resolvedParent);
|
|
84
|
+
if (relativeParent === ".." || relativeParent.startsWith(`..${sep}`) || isAbsolute(relativeParent)) {
|
|
85
|
+
throw new Error("Invalid skill target path");
|
|
86
|
+
}
|
|
87
|
+
await mkdir(resolvedRoot, { recursive: true, mode: 0o700 });
|
|
88
|
+
await assertSafeDirectory(resolvedRoot);
|
|
89
|
+
if (!relativeParent || relativeParent === ".")
|
|
90
|
+
return;
|
|
91
|
+
let current = resolvedRoot;
|
|
92
|
+
for (const segment of relativeParent.split(sep).filter(Boolean)) {
|
|
93
|
+
current = join(current, segment);
|
|
94
|
+
try {
|
|
95
|
+
await assertSafeDirectory(current);
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
if (err.code !== "ENOENT")
|
|
99
|
+
throw err;
|
|
100
|
+
await mkdir(current, { mode: 0o700 });
|
|
101
|
+
await assertSafeDirectory(current);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
async function assertSafeDirectory(path) {
|
|
106
|
+
const stat = await lstat(path);
|
|
107
|
+
if (stat.isSymbolicLink()) {
|
|
108
|
+
const err = new Error("path contains a symbolic link");
|
|
109
|
+
err.code = "ELOOP";
|
|
110
|
+
throw err;
|
|
111
|
+
}
|
|
112
|
+
if (!stat.isDirectory()) {
|
|
113
|
+
const err = new Error("path is blocked by an existing local file or directory");
|
|
114
|
+
err.code = "ENOTDIR";
|
|
115
|
+
throw err;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { apiUrlFromConfig, readConfig, DEFAULT_API_URL } from "../lib/config.js";
|
|
2
|
+
import { deleteRequest, getJson, postJson, putJson } from "../lib/api.js";
|
|
3
|
+
export async function listLibraries() {
|
|
4
|
+
const cfg = await readConfig();
|
|
5
|
+
const apiUrl = cfg ? apiUrlFromConfig(cfg) : (process.env.FLOOM_API_URL ?? DEFAULT_API_URL).replace(/\/$/, "");
|
|
6
|
+
return await getJson(`${apiUrl}/api/v1/libraries`, cfg?.accessToken);
|
|
7
|
+
}
|
|
8
|
+
export async function subscribeLibrary(slug) {
|
|
9
|
+
const cfg = await readConfig();
|
|
10
|
+
if (!cfg)
|
|
11
|
+
throw new Error("Not signed in. Run `npx -y @floomhq/floom login` first.");
|
|
12
|
+
const apiUrl = apiUrlFromConfig(cfg);
|
|
13
|
+
return await postJson(`${apiUrl}/api/v1/me/subscriptions`, cfg.accessToken, { library_slug: slug });
|
|
14
|
+
}
|
|
15
|
+
export async function unsubscribeLibrary(slug) {
|
|
16
|
+
const cfg = await readConfig();
|
|
17
|
+
if (!cfg)
|
|
18
|
+
throw new Error("Not signed in. Run `npx -y @floomhq/floom login` first.");
|
|
19
|
+
const apiUrl = apiUrlFromConfig(cfg);
|
|
20
|
+
return await deleteRequest(`${apiUrl}/api/v1/me/subscriptions/${encodeURIComponent(slug)}`, cfg.accessToken);
|
|
21
|
+
}
|
|
22
|
+
export async function moveSkill(slug, folder, tags) {
|
|
23
|
+
const cfg = await readConfig();
|
|
24
|
+
if (!cfg)
|
|
25
|
+
throw new Error("Not signed in. Run `npx -y @floomhq/floom login` first.");
|
|
26
|
+
const apiUrl = apiUrlFromConfig(cfg);
|
|
27
|
+
return await putJson(`${apiUrl}/api/v1/me/skills/${encodeURIComponent(slug)}/override`, cfg.accessToken, { folder, tags });
|
|
28
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { apiUrlFromConfig, readConfig } from "../lib/config.js";
|
|
2
|
+
import { postJson } from "../lib/api.js";
|
|
3
|
+
export async function publishSkill(name, content, description, visibility = "unlisted", assetType = "skill", installsAs = "claude_skill", version) {
|
|
4
|
+
const cfg = await readConfig();
|
|
5
|
+
if (!cfg)
|
|
6
|
+
throw new Error("Not signed in. Run `npx -y @floomhq/floom login` first.");
|
|
7
|
+
const apiUrl = apiUrlFromConfig(cfg);
|
|
8
|
+
const data = await postJson(`${apiUrl}/api/skills`, cfg.accessToken, {
|
|
9
|
+
title: name,
|
|
10
|
+
description: description ?? null,
|
|
11
|
+
body_md: content,
|
|
12
|
+
visibility,
|
|
13
|
+
asset_type: assetType,
|
|
14
|
+
source: "markdown",
|
|
15
|
+
installs_as: installsAs,
|
|
16
|
+
version: version ?? null,
|
|
17
|
+
published_via: "mcp",
|
|
18
|
+
});
|
|
19
|
+
return {
|
|
20
|
+
slug: data.slug,
|
|
21
|
+
url: data.url ?? `${apiUrl}/s/${data.slug}.md`,
|
|
22
|
+
...(data.version ? { version: data.version } : {}),
|
|
23
|
+
};
|
|
24
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@floomhq/floom-mcp-sync",
|
|
3
|
-
"version": "1.0.
|
|
4
|
-
"description": "Lightweight Floom MCP server for
|
|
3
|
+
"version": "1.0.27",
|
|
4
|
+
"description": "Lightweight Floom MCP server for installing, publishing, and startup-syncing skills.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
@@ -9,12 +9,7 @@
|
|
|
9
9
|
},
|
|
10
10
|
"files": [
|
|
11
11
|
"bin",
|
|
12
|
-
"dist
|
|
13
|
-
"dist/lib",
|
|
14
|
-
"dist/server.js",
|
|
15
|
-
"dist/tools/get.js",
|
|
16
|
-
"dist/tools/search.js",
|
|
17
|
-
"dist/tools/status.js",
|
|
12
|
+
"dist",
|
|
18
13
|
"README.md",
|
|
19
14
|
"LICENSE"
|
|
20
15
|
],
|