@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 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";
@@ -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(["references", "examples", "scripts", "assets"]);
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 = 100;
23
- const PACKAGE_TOTAL_BYTES_LIMIT = 1_000_000;
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
- 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
+ }
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
- let activeCfg = cfg;
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`, activeCfg.accessToken, cachedEtag, signal);
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
- const refreshed = await refreshConfig(activeCfg);
266
- if (!refreshed) {
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`, activeCfg.accessToken, null, signal);
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 receives published, saved, and subscribed library skills through
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: dedupeSyncSkills(payload.skills), defaultLib: null },
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, activeCfg.accessToken, { total, updated, unchanged }, signal).catch(() => { });
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 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
- }
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.25";
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.25",
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
  ],