@floomhq/floom-mcp-sync 1.0.26 → 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 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, refreshConfig } from "./lib/config.js";
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";
@@ -18,52 +18,110 @@ 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
20
  const SUPPORT_DIRS = new Set([
21
+ ".github",
21
22
  "agents",
22
23
  "assets",
24
+ "bin",
23
25
  "canvas-fonts",
24
26
  "checks",
27
+ "claude",
28
+ "codex",
29
+ "contrib",
25
30
  "core",
31
+ "cursor",
32
+ "design",
33
+ "docs",
26
34
  "evidence",
27
35
  "examples",
36
+ "extension",
28
37
  "helpers",
38
+ "hosts",
39
+ "kimi",
40
+ "lib",
41
+ "migrations",
42
+ "model-overlays",
43
+ "openclaw",
44
+ "opencode",
29
45
  "reference",
30
46
  "references",
31
- "schemas",
47
+ "remotion-starter",
32
48
  "scripts",
33
- "spreadsheets",
49
+ "sdk",
50
+ "schemas",
51
+ "src",
52
+ "specialists",
53
+ "supabase",
34
54
  "templates",
55
+ "test",
35
56
  "tests",
36
57
  "themes",
58
+ "vendor",
37
59
  ]);
38
- const ROOT_FILE_EXTENSIONS = [
60
+ const ROOT_SUPPORT_FILES = new Set([
39
61
  ".env.example",
40
- ".js",
41
- ".json",
42
- ".md",
43
- ".py",
44
- ".sh",
45
- ".toml",
46
- ".ts",
47
- ".txt",
48
- ".yaml",
49
- ".yml",
50
- ];
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"];
51
120
  const FD_PATH_ROOT = "/proc/self/fd";
52
- const PACKAGE_FILE_LIMIT = 100;
53
- const PACKAGE_TOTAL_BYTES_LIMIT = 1_000_000;
121
+ const PACKAGE_FILE_LIMIT = 1000;
122
+ const PACKAGE_TOTAL_BYTES_LIMIT = 8_000_000;
54
123
  const PACKAGE_FILE_BYTES_LIMIT = 500_000;
55
124
  const BASE64_RE = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
56
- function hasStructuredPath(skill) {
57
- return Boolean(skill.library_slug || skill.folder);
58
- }
59
- function dedupeSyncSkills(skills) {
60
- const structuredSlugs = new Set();
61
- for (const skill of skills) {
62
- if (hasStructuredPath(skill))
63
- structuredSlugs.add(skill.slug);
64
- }
65
- return skills.filter((skill) => !structuredSlugs.has(skill.slug) || hasStructuredPath(skill));
66
- }
67
125
  async function localState(path) {
68
126
  try {
69
127
  const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
@@ -203,7 +261,7 @@ function normalizePackageFilePath(path) {
203
261
  const segments = path.split("/").filter(Boolean);
204
262
  if (segments.length === 1 && segments[0] === "SKILL.md")
205
263
  return "SKILL.md";
206
- if (segments.length === 1 && isAllowedRootPackageFile(segments[0] ?? ""))
264
+ if (segments.length === 1 && ROOT_SUPPORT_FILES.has(segments[0] ?? ""))
207
265
  return segments[0] ?? "";
208
266
  const first = segments[0];
209
267
  if (segments.length < 2 || first === undefined || !SUPPORT_DIRS.has(first)) {
@@ -212,13 +270,18 @@ function normalizePackageFilePath(path) {
212
270
  if (segments.some((segment) => segment === "." || segment === ".." || !PACKAGE_FILE_SEGMENT_RE.test(segment))) {
213
271
  throw new Error("Invalid package file path");
214
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
+ }
215
283
  return segments.join("/");
216
284
  }
217
- function isAllowedRootPackageFile(name) {
218
- if (!name || (name.startsWith(".") && name !== ".env.example"))
219
- return false;
220
- return ROOT_FILE_EXTENSIONS.some((ext) => name.toLowerCase().endsWith(ext));
221
- }
222
285
  async function planPackageSync(root, files, manifest) {
223
286
  let missing = 0;
224
287
  let unchanged = 0;
@@ -247,8 +310,12 @@ async function planPackageSync(root, files, manifest) {
247
310
  missing += 1;
248
311
  continue;
249
312
  }
250
- if (!tracked)
251
- return { kind: "conflict", target: file.target, reason: "existing file is not tracked by Floom sync" };
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
+ }
252
319
  if (state.hash !== tracked.hash)
253
320
  return { kind: "conflict", target: file.target, reason: "local file changed since the last Floom sync" };
254
321
  if (state.hash !== file.hash)
@@ -291,29 +358,21 @@ export async function autoSync(log = (message) => process.stderr.write(`${messag
291
358
  return await withSyncLock(async () => {
292
359
  const manifest = await readSyncManifest();
293
360
  const root = skillsDir();
294
- let activeCfg = cfg;
295
- const apiUrl = apiUrlFromConfig(activeCfg);
361
+ const apiUrl = apiUrlFromConfig(cfg);
296
362
  let response;
297
363
  try {
298
- response = await getJsonWithEtag(`${apiUrl}/api/v1/me/skills`, activeCfg.accessToken, cachedEtag, signal);
364
+ response = await getJsonWithEtag(`${apiUrl}/api/v1/me/skills`, cfg.accessToken, cachedEtag, signal);
299
365
  }
300
366
  catch (err) {
301
367
  if (err instanceof FloomApiError && err.status === 401) {
302
- const refreshed = await refreshConfig(activeCfg);
303
- if (!refreshed) {
304
- maybeAuthWarning(log, "[floom] sign-in expired; skipping sync (run `npx -y @floomhq/floom login` again)");
305
- return { synced: 0, unchanged: 0, updated: 0, conflicts: 0 };
306
- }
307
- activeCfg = refreshed;
308
- response = await getJsonWithEtag(`${apiUrl}/api/v1/me/skills`, activeCfg.accessToken, cachedEtag, signal);
309
- }
310
- else {
311
- 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 };
312
370
  }
371
+ throw err;
313
372
  }
314
373
  if (response.status === 304) {
315
374
  if (await manifestHasMissingTrackedFile(manifest, root)) {
316
- response = await getJsonWithEtag(`${apiUrl}/api/v1/me/skills`, activeCfg.accessToken, null, signal);
375
+ response = await getJsonWithEtag(`${apiUrl}/api/v1/me/skills`, cfg.accessToken, null, signal);
317
376
  }
318
377
  else {
319
378
  maybeHeartbeat(log);
@@ -336,12 +395,9 @@ export async function autoSync(log = (message) => process.stderr.write(`${messag
336
395
  }
337
396
  for (const skill of payload.skills)
338
397
  validateSyncSkillShape(skill);
339
- // Version 1 receives published, saved, and subscribed library skills through
340
- // the single /me/skills response. If a slug appears both at top level and in
341
- // a library/folder placement, keep only the structured placement so agents do
342
- // not see duplicate native packages.
398
+ // Version 1 preview syncs owned published skills only.
343
399
  const buckets = [
344
- { skills: dedupeSyncSkills(payload.skills), defaultLib: null },
400
+ { skills: payload.skills, defaultLib: null },
345
401
  ];
346
402
  let unchanged = 0;
347
403
  let updated = 0;
@@ -395,6 +451,9 @@ export async function autoSync(log = (message) => process.stderr.write(`${messag
395
451
  continue;
396
452
  }
397
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);
398
457
  unchanged += 1;
399
458
  continue;
400
459
  }
@@ -473,7 +532,7 @@ export async function autoSync(log = (message) => process.stderr.write(`${messag
473
532
  if (updated > 0) {
474
533
  // Activation telemetry counts syncs that write new content. Best-effort;
475
534
  // never blocks or throws.
476
- void emitSyncCompleted(apiUrl, activeCfg.accessToken, { total, updated, unchanged }, signal).catch(() => { });
535
+ void emitSyncCompleted(apiUrl, cfg.accessToken, { total, updated, unchanged }, signal).catch(() => { });
477
536
  }
478
537
  }
479
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 res = await fetchWithTimeout(url, { headers }, signal);
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 res = await fetchWithTimeout(url, { headers }, signal);
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 res = await fetchWithTimeout(url, {}, signal);
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
- const res = await fetchWithTimeout(url, init, signal);
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
- const res = await fetchWithTimeout(url, init, signal);
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
- const res = await fetchWithTimeout(url, init, signal);
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());
@@ -1,10 +1,8 @@
1
- import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
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 ?? cfg.apiUrl ?? DEFAULT_API_URL).replace(/\/$/, "");
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
- const cfg = {
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
- }
@@ -33,51 +33,13 @@ function isEntryForKey(key, value) {
33
33
  function isPackageFilePath(packagePath) {
34
34
  if (packagePath.length === 1 && packagePath[0] === "SKILL.md")
35
35
  return true;
36
- if (packagePath.length === 1 && isAllowedRootPackageFile(packagePath[0] ?? ""))
37
- return true;
38
36
  if (packagePath.length < 2)
39
37
  return false;
40
38
  const first = packagePath[0];
41
- if (first === undefined || !SUPPORT_DIRS.has(first))
39
+ if (first === undefined || !["references", "examples", "scripts", "assets"].includes(first))
42
40
  return false;
43
41
  return packagePath.every((segment) => segment !== "." && segment !== ".." && segment.length > 0);
44
42
  }
45
- const SUPPORT_DIRS = new Set([
46
- "agents",
47
- "assets",
48
- "canvas-fonts",
49
- "checks",
50
- "core",
51
- "evidence",
52
- "examples",
53
- "helpers",
54
- "reference",
55
- "references",
56
- "schemas",
57
- "scripts",
58
- "spreadsheets",
59
- "templates",
60
- "tests",
61
- "themes",
62
- ]);
63
- const ROOT_FILE_EXTENSIONS = [
64
- ".env.example",
65
- ".js",
66
- ".json",
67
- ".md",
68
- ".py",
69
- ".sh",
70
- ".toml",
71
- ".ts",
72
- ".txt",
73
- ".yaml",
74
- ".yml",
75
- ];
76
- function isAllowedRootPackageFile(name) {
77
- if (!name || (name.startsWith(".") && name !== ".env.example"))
78
- return false;
79
- return ROOT_FILE_EXTENSIONS.some((ext) => name.toLowerCase().endsWith(ext));
80
- }
81
43
  export async function readSyncManifest() {
82
44
  try {
83
45
  await ensureSyncManifestDir();
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.26";
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
  }
package/dist/tools/get.js CHANGED
@@ -6,37 +6,7 @@ const PACKAGE_FILE_LIMIT = 100;
6
6
  const PACKAGE_FILE_BYTES_LIMIT = 500_000;
7
7
  const PACKAGE_TOTAL_BYTES_LIMIT = 1_000_000;
8
8
  const PATH_SEGMENT_RE = /^[A-Za-z0-9._-]{1,128}$/;
9
- const SUPPORT_DIRS = new Set([
10
- "agents",
11
- "assets",
12
- "canvas-fonts",
13
- "checks",
14
- "core",
15
- "evidence",
16
- "examples",
17
- "helpers",
18
- "reference",
19
- "references",
20
- "schemas",
21
- "scripts",
22
- "spreadsheets",
23
- "templates",
24
- "tests",
25
- "themes",
26
- ]);
27
- const ROOT_FILE_EXTENSIONS = [
28
- ".env.example",
29
- ".js",
30
- ".json",
31
- ".md",
32
- ".py",
33
- ".sh",
34
- ".toml",
35
- ".ts",
36
- ".txt",
37
- ".yaml",
38
- ".yml",
39
- ];
9
+ const SUPPORT_DIRS = new Set(["references", "examples", "scripts", "assets"]);
40
10
  const SHA256_RE = /^[a-f0-9]{64}$/i;
41
11
  const BASE64_RE = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
42
12
  export async function getSkill(slug) {
@@ -60,7 +30,7 @@ export async function getSkill(slug) {
60
30
  content: detail.body_md,
61
31
  package: {
62
32
  main: "SKILL.md",
63
- supporting_dirs: Array.from(SUPPORT_DIRS),
33
+ supporting_dirs: ["references", "examples", "scripts", "assets"],
64
34
  },
65
35
  package_files: normalizePackageFiles(detail.package_files ?? detail.files),
66
36
  };
@@ -133,16 +103,9 @@ function isSafePackagePath(path) {
133
103
  if (!path || path.length > 512 || path.startsWith("/") || path.includes("\\") || path.includes("//"))
134
104
  return false;
135
105
  const segments = path.split("/");
136
- if (segments.length === 1)
137
- return isAllowedRootPackageFile(segments[0] ?? "") && PATH_SEGMENT_RE.test(segments[0] ?? "");
138
106
  if (segments.length < 2)
139
107
  return false;
140
108
  if (!SUPPORT_DIRS.has(segments[0] ?? ""))
141
109
  return false;
142
110
  return segments.every((segment) => PATH_SEGMENT_RE.test(segment) && segment !== "." && segment !== "..");
143
111
  }
144
- function isAllowedRootPackageFile(name) {
145
- if (!name || (name.startsWith(".") && name !== ".env.example"))
146
- return false;
147
- return ROOT_FILE_EXTENSIONS.some((ext) => name.toLowerCase().endsWith(ext));
148
- }
@@ -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.26",
4
- "description": "Lightweight Floom MCP server for search, on-demand skill fetch, status, and sync.",
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/auto-sync.js",
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
  ],