@floomhq/floom-mcp-sync 1.0.5 → 1.0.7

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.
@@ -1,11 +1,13 @@
1
1
  import { constants } from "node:fs";
2
- import { lstat, mkdir, open, rename } from "node:fs/promises";
2
+ import { lstat, mkdir, open, rename, rm, stat } from "node:fs/promises";
3
3
  import { dirname, join, relative, resolve, sep } from "node:path";
4
4
  import { configPath } from "./paths.js";
5
5
  const MANIFEST_VERSION = 1;
6
6
  const SLUG_RE = /^[A-Za-z0-9_-]{1,128}$/;
7
7
  const FD_PATH_ROOT = "/proc/self/fd";
8
- function manifestPath() {
8
+ const LOCK_TIMEOUT_MS = 15_000;
9
+ const LOCK_STALE_MS = 5 * 60_000;
10
+ export function syncManifestPath() {
9
11
  return join(dirname(configPath()), "sync-manifest.json");
10
12
  }
11
13
  function emptyManifest() {
@@ -15,18 +17,33 @@ function isEntryForKey(key, value) {
15
17
  if (!value || typeof value !== "object")
16
18
  return false;
17
19
  const entry = value;
18
- return (typeof entry.hash === "string" &&
20
+ if (typeof entry.hash === "string" &&
19
21
  typeof entry.slug === "string" &&
20
22
  typeof entry.target === "string" &&
21
23
  typeof entry.syncedAt === "string" &&
22
24
  entry.target === key &&
23
- SLUG_RE.test(entry.slug) &&
24
- key.split("/").at(-1) === `${entry.slug}.md`);
25
+ SLUG_RE.test(entry.slug)) {
26
+ const segments = key.split("/");
27
+ const slugIndex = segments.lastIndexOf(entry.slug);
28
+ const packagePath = slugIndex >= 0 ? segments.slice(slugIndex + 1) : [];
29
+ return isPackageFilePath(packagePath);
30
+ }
31
+ return false;
32
+ }
33
+ function isPackageFilePath(packagePath) {
34
+ if (packagePath.length === 1 && packagePath[0] === "SKILL.md")
35
+ return true;
36
+ if (packagePath.length < 2)
37
+ return false;
38
+ const first = packagePath[0];
39
+ if (first === undefined || !["references", "examples", "scripts", "assets"].includes(first))
40
+ return false;
41
+ return packagePath.every((segment) => segment !== "." && segment !== ".." && segment.length > 0);
25
42
  }
26
43
  export async function readSyncManifest() {
27
44
  try {
28
45
  await ensureSyncManifestDir();
29
- const handle = await open(manifestPath(), constants.O_RDONLY | constants.O_NOFOLLOW);
46
+ const handle = await open(syncManifestPath(), constants.O_RDONLY | constants.O_NOFOLLOW);
30
47
  let body;
31
48
  try {
32
49
  body = await handle.readFile("utf8");
@@ -55,7 +72,7 @@ export async function readSyncManifest() {
55
72
  }
56
73
  export async function writeSyncManifest(manifest) {
57
74
  await ensureSyncManifestDir();
58
- const path = manifestPath();
75
+ const path = syncManifestPath();
59
76
  const dirPath = dirname(path);
60
77
  const dir = await open(dirPath, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
61
78
  const tmpBase = `sync-manifest.json.${process.pid}.${Date.now()}`;
@@ -94,7 +111,7 @@ function childPath(parent, fallbackParent, name) {
94
111
  return join(resolve(fallbackParent), name);
95
112
  }
96
113
  export async function ensureSyncManifestDir() {
97
- const path = manifestPath();
114
+ const path = syncManifestPath();
98
115
  const dir = dirname(path);
99
116
  await mkdir(dir, { recursive: true, mode: 0o700 });
100
117
  const stat = await lstat(dir);
@@ -109,6 +126,43 @@ export async function ensureSyncManifestDir() {
109
126
  throw err;
110
127
  }
111
128
  }
129
+ export async function withSyncLock(fn) {
130
+ await ensureSyncManifestDir();
131
+ const lockPath = join(dirname(syncManifestPath()), "sync.lock");
132
+ const startedAt = Date.now();
133
+ for (;;) {
134
+ try {
135
+ await mkdir(lockPath, { mode: 0o700 });
136
+ break;
137
+ }
138
+ catch (err) {
139
+ if (err.code !== "EEXIST")
140
+ throw err;
141
+ try {
142
+ const lockStat = await stat(lockPath);
143
+ if (Date.now() - lockStat.mtimeMs > LOCK_STALE_MS) {
144
+ await rm(lockPath, { recursive: true, force: true });
145
+ continue;
146
+ }
147
+ }
148
+ catch (statErr) {
149
+ if (statErr.code === "ENOENT")
150
+ continue;
151
+ throw statErr;
152
+ }
153
+ if (Date.now() - startedAt > LOCK_TIMEOUT_MS) {
154
+ throw new Error("Timed out waiting for Floom sync lock.");
155
+ }
156
+ await new Promise((resolveDelay) => setTimeout(resolveDelay, 50));
157
+ }
158
+ }
159
+ try {
160
+ return await fn();
161
+ }
162
+ finally {
163
+ await rm(lockPath, { recursive: true, force: true }).catch(() => { });
164
+ }
165
+ }
112
166
  export function manifestKey(root, target) {
113
167
  const relativeTarget = relative(resolve(root), resolve(target));
114
168
  if (relativeTarget === ".." || relativeTarget.startsWith(`..${sep}`)) {
package/dist/lib/paths.js CHANGED
@@ -4,25 +4,22 @@ import { assertValidSlug } from "./slug.js";
4
4
  export function configPath() {
5
5
  return process.env.FLOOM_CONFIG_PATH ?? join(homedir(), ".floom", "config.json");
6
6
  }
7
- export function agentTarget() {
8
- const raw = (process.env.FLOOM_TARGET ?? process.env.FLOOM_AGENT_TARGET ?? "claude").toLowerCase();
9
- if (raw === "codex")
10
- return "codex";
11
- if (raw === "claude")
12
- return "claude";
13
- throw new Error("Invalid FLOOM_TARGET. Use claude or codex.");
14
- }
15
7
  export function skillsDir() {
16
- if (process.env.FLOOM_SKILLS_DIR)
17
- return process.env.FLOOM_SKILLS_DIR;
18
- if (agentTarget() === "codex") {
19
- const codexHome = process.env.CODEX_HOME ?? join(homedir(), ".codex");
20
- return process.env.CODEX_SKILLS_DIR ?? join(codexHome, "skills");
21
- }
22
- return process.env.CLAUDE_SKILLS_DIR ?? join(homedir(), ".claude", "skills");
8
+ return expandHome(process.env.FLOOM_SKILLS_DIR
9
+ ?? process.env.CLAUDE_SKILLS_DIR
10
+ ?? process.env.CODEX_SKILLS_DIR
11
+ ?? join(homedir(), ".claude", "skills"));
12
+ }
13
+ function expandHome(path) {
14
+ if (path === "~")
15
+ return homedir();
16
+ if (path.startsWith("~/"))
17
+ return join(homedir(), path.slice(2));
18
+ return path;
23
19
  }
24
20
  export function skillPath(slug) {
25
- return join(skillsDir(), `${slug}.md`);
21
+ assertValidSlug(slug);
22
+ return join(skillsDir(), slug, "SKILL.md");
26
23
  }
27
24
  const PATH_SEGMENT_RE = /^[a-z0-9._-]{1,128}$/;
28
25
  function safePathSegments(value, label) {
@@ -37,12 +34,12 @@ function safePathSegments(value, label) {
37
34
  return segments;
38
35
  }
39
36
  /**
40
- * Compute the on-disk path for a skill given optional folder + library
41
- * grouping. Version 1 preview uses owned skills at the skills root or inside
42
- * `<folder>/`. Library grouping remains a later-version path shape.
37
+ * Compute the on-disk path for a skill package given optional folder + library
38
+ * grouping. Native Claude/Codex packages are written as
39
+ * `<slug>/SKILL.md`, with folder/library segments above the package root.
43
40
  *
44
- * The slug always becomes the filename. Folder/library segments must already
45
- * be validated by the API (server-side regex enforces lowercase tokens).
41
+ * The slug always becomes the package directory. Folder/library segments must
42
+ * already be validated by the API (server-side regex enforces lowercase tokens).
46
43
  */
47
44
  export function skillTargetPath(opts) {
48
45
  assertValidSlug(opts.slug);
@@ -50,7 +47,7 @@ export function skillTargetPath(opts) {
50
47
  const segments = [root];
51
48
  segments.push(...safePathSegments(opts.librarySlug, "library slug"));
52
49
  segments.push(...safePathSegments(opts.folder, "folder"));
53
- segments.push(`${opts.slug}.md`);
50
+ segments.push(opts.slug, "SKILL.md");
54
51
  const target = join(...segments);
55
52
  const relativeTarget = relative(resolve(root), resolve(target));
56
53
  if (relativeTarget === ".." || relativeTarget.startsWith(`..${sep}`) || isAbsolute(relativeTarget)) {
package/dist/server.js CHANGED
@@ -2,28 +2,20 @@
2
2
  import { createInterface } from "node:readline";
3
3
  import { stdin, stdout } from "node:process";
4
4
  import { autoSync } from "./auto-sync.js";
5
- import { installSkill } from "./tools/install.js";
6
- import { publishSkill } from "./tools/publish.js";
7
- import { listLibraries, moveSkill, subscribeLibrary, unsubscribeLibrary, } from "./tools/libraries.js";
5
+ import { getSkill } from "./tools/get.js";
8
6
  import { searchSkills } from "./tools/search.js";
9
- import { agentTarget, skillsDir } from "./lib/paths.js";
10
- const SERVER_VERSION = "1.0.5";
7
+ import { syncStatus } from "./tools/status.js";
8
+ const SERVER_VERSION = "1.0.7";
11
9
  const DEFAULT_INTERVAL_MS = 60_000;
12
10
  const MIN_INTERVAL_MS = 10_000;
13
- const VERSION_RE = /^[A-Za-z0-9][A-Za-z0-9._+-]{0,63}$/;
14
- const VISIBILITIES = new Set(["unlisted", "public", "private"]);
15
- const ASSET_TYPES = new Set(["knowledge", "instruction", "workflow", "skill"]);
16
11
  const SEARCH_TYPES = new Set(["knowledge", "instruction", "workflow", "skill"]);
17
- const FOLDER_RE = /^[a-z0-9][a-z0-9._-]*(\/[a-z0-9][a-z0-9._-]*)*$/;
18
- const INSTALL_TARGETS = new Set([
19
- "claude_skill",
20
- "memory",
21
- "rule",
22
- "codex_instruction",
23
- "opencode_instruction",
24
- "cursor_rule",
25
- "other",
26
- ]);
12
+ let syncInFlight = null;
13
+ function runAutoSync() {
14
+ syncInFlight ??= autoSync().finally(() => {
15
+ syncInFlight = null;
16
+ });
17
+ return syncInFlight;
18
+ }
27
19
  function usage() {
28
20
  return `
29
21
  floom-mcp-sync v${SERVER_VERSION}
@@ -34,7 +26,7 @@ Usage
34
26
 
35
27
  Behavior
36
28
  Starts a stdio MCP server.
37
- Syncs published, saved, and subscribed library skills into the configured local skills directory.
29
+ Syncs Floom skills into native Claude/Codex package folders.
38
30
  Polls for updates while the MCP process is running.
39
31
 
40
32
  Options
@@ -43,10 +35,9 @@ Options
43
35
 
44
36
  Env
45
37
  FLOOM_API_URL Override the API host.
46
- FLOOM_TARGET claude or codex. Default: claude.
47
- FLOOM_SKILLS_DIR Override the local skills directory directly.
48
- CLAUDE_SKILLS_DIR Override Claude's skills directory.
49
- CODEX_SKILLS_DIR Override Codex's skills directory.
38
+ FLOOM_SKILLS_DIR Override the native skills directory.
39
+ CLAUDE_SKILLS_DIR Claude skills directory override.
40
+ CODEX_SKILLS_DIR Codex skills directory override.
50
41
  FLOOM_SYNC_INTERVAL_MS Poll interval in milliseconds. Minimum: 10000.
51
42
  `.trimStart();
52
43
  }
@@ -89,7 +80,7 @@ function startPolling(intervalMs, state) {
89
80
  return;
90
81
  }
91
82
  state.inFlight = true;
92
- autoSync().catch((err) => {
83
+ runAutoSync().catch((err) => {
93
84
  process.stderr.write(`[floom] poll failed: ${err instanceof Error ? err.message : String(err)}\n`);
94
85
  }).finally(() => {
95
86
  state.inFlight = false;
@@ -101,7 +92,7 @@ function toolList() {
101
92
  tools: [
102
93
  {
103
94
  name: "floom_search_skills",
104
- description: "Search public Floom skills before recreating reusable behavior from scratch.",
95
+ description: "Search public Floom skills through the live API.",
105
96
  inputSchema: {
106
97
  type: "object",
107
98
  properties: {
@@ -115,8 +106,8 @@ function toolList() {
115
106
  },
116
107
  },
117
108
  {
118
- name: "floom_install_skill",
119
- description: "Fetch a Floom skill by slug and install it into the configured local skills directory.",
109
+ name: "floom_get_skill",
110
+ description: "Fetch full Floom skill content by slug on demand.",
120
111
  inputSchema: {
121
112
  type: "object",
122
113
  properties: {
@@ -127,26 +118,8 @@ function toolList() {
127
118
  },
128
119
  },
129
120
  {
130
- name: "floom_publish_skill",
131
- description: "Publish Markdown content to Floom as a skill.",
132
- inputSchema: {
133
- type: "object",
134
- properties: {
135
- name: { type: "string", minLength: 1, maxLength: 200 },
136
- content: { type: "string", minLength: 1, maxLength: 500_000 },
137
- description: { type: "string", maxLength: 1000 },
138
- visibility: { type: "string", enum: [...VISIBILITIES] },
139
- asset_type: { type: "string", enum: [...ASSET_TYPES] },
140
- installs_as: { type: ["string", "null"], enum: [...INSTALL_TARGETS, null] },
141
- version: { type: "string", pattern: "^[A-Za-z0-9][A-Za-z0-9._+-]{0,63}$" },
142
- },
143
- required: ["name", "content"],
144
- additionalProperties: false,
145
- },
146
- },
147
- {
148
- name: "floom_list_libraries",
149
- description: "List public Floom libraries.",
121
+ name: "floom_status",
122
+ description: "Report local Floom sync manifest and drift counts.",
150
123
  inputSchema: {
151
124
  type: "object",
152
125
  properties: {},
@@ -154,44 +127,11 @@ function toolList() {
154
127
  },
155
128
  },
156
129
  {
157
- name: "floom_subscribe_library",
158
- description: "Subscribe the signed-in user to a Floom library so it syncs locally.",
159
- inputSchema: {
160
- type: "object",
161
- properties: {
162
- slug: { type: "string", minLength: 1, maxLength: 64 },
163
- },
164
- required: ["slug"],
165
- additionalProperties: false,
166
- },
167
- },
168
- {
169
- name: "floom_unsubscribe_library",
170
- description: "Unsubscribe the signed-in user from a Floom library.",
171
- inputSchema: {
172
- type: "object",
173
- properties: {
174
- slug: { type: "string", minLength: 1, maxLength: 64 },
175
- },
176
- required: ["slug"],
177
- additionalProperties: false,
178
- },
179
- },
180
- {
181
- name: "floom_move_skill",
182
- description: "Set a signed-in user's local folder/tags override for a Floom skill.",
130
+ name: "floom_sync",
131
+ description: "Run one foreground sync into the native local skills directory.",
183
132
  inputSchema: {
184
133
  type: "object",
185
- properties: {
186
- slug: { type: "string", minLength: 1, maxLength: 128 },
187
- folder: { type: ["string", "null"], maxLength: 256 },
188
- tags: {
189
- type: "array",
190
- items: { type: "string", minLength: 1, maxLength: 64 },
191
- maxItems: 32,
192
- },
193
- },
194
- required: ["slug", "folder"],
134
+ properties: {},
195
135
  additionalProperties: false,
196
136
  },
197
137
  },
@@ -216,13 +156,6 @@ function optionalString(value, label, max) {
216
156
  throw new Error(`Invalid ${label}.`);
217
157
  return value;
218
158
  }
219
- function enumValue(value, label, allowed, fallback) {
220
- if (value === undefined)
221
- return fallback;
222
- if (typeof value !== "string" || !allowed.has(value))
223
- throw new Error(`Invalid ${label}.`);
224
- return value;
225
- }
226
159
  function optionalEnumValue(value, label, allowed) {
227
160
  if (value === undefined)
228
161
  return undefined;
@@ -230,22 +163,6 @@ function optionalEnumValue(value, label, allowed) {
230
163
  throw new Error(`Invalid ${label}.`);
231
164
  return value;
232
165
  }
233
- function nullableEnumValue(value, label, allowed, fallback) {
234
- if (value === undefined)
235
- return fallback;
236
- if (value === null)
237
- return null;
238
- if (typeof value !== "string" || !allowed.has(value))
239
- throw new Error(`Invalid ${label}.`);
240
- return value;
241
- }
242
- function optionalStringArray(value, label, maxItems, maxLength) {
243
- if (value === undefined)
244
- return [];
245
- if (!Array.isArray(value) || value.length > maxItems)
246
- throw new Error(`Invalid ${label}.`);
247
- return value.map((item) => asString(item, label, 1, maxLength));
248
- }
249
166
  function optionalInteger(value, label, min, max) {
250
167
  if (value === undefined)
251
168
  return undefined;
@@ -254,20 +171,12 @@ function optionalInteger(value, label, min, max) {
254
171
  }
255
172
  return value;
256
173
  }
257
- function nullableFolder(value) {
258
- if (value === null)
259
- return null;
260
- const folder = asString(value, "folder", 1, 256);
261
- if (!FOLDER_RE.test(folder))
262
- throw new Error("Invalid folder.");
263
- return folder;
264
- }
265
174
  async function callTool(params) {
266
175
  const parsed = asObject(params);
267
176
  const name = asString(parsed.name, "tool name", 1, 200);
268
177
  const args = asObject(parsed.arguments ?? {});
269
- if (name === "floom_install_skill") {
270
- return ok(await installSkill(asString(args.slug, "slug", 1, 128)));
178
+ if (name === "floom_get_skill") {
179
+ return ok(await getSkill(asString(args.slug, "slug", 1, 128)));
271
180
  }
272
181
  if (name === "floom_search_skills") {
273
182
  const opts = {};
@@ -282,23 +191,11 @@ async function callTool(params) {
282
191
  opts.limit = limit;
283
192
  return ok(await searchSkills(asString(args.query, "query", 1, 120), opts));
284
193
  }
285
- if (name === "floom_publish_skill") {
286
- const version = optionalString(args.version, "version", 64);
287
- if (version !== undefined && !VERSION_RE.test(version))
288
- throw new Error("Invalid version.");
289
- return ok(await publishSkill(asString(args.name, "name", 1, 200), asString(args.content, "content", 1, 500_000), optionalString(args.description, "description", 1000), enumValue(args.visibility, "visibility", VISIBILITIES, "unlisted"), enumValue(args.asset_type, "asset_type", ASSET_TYPES, "skill"), nullableEnumValue(args.installs_as, "installs_as", INSTALL_TARGETS, "claude_skill"), version));
290
- }
291
- if (name === "floom_list_libraries") {
292
- return ok(await listLibraries());
194
+ if (name === "floom_status") {
195
+ return ok(await syncStatus());
293
196
  }
294
- if (name === "floom_subscribe_library") {
295
- return ok(await subscribeLibrary(asString(args.slug, "slug", 1, 64)));
296
- }
297
- if (name === "floom_unsubscribe_library") {
298
- return ok(await unsubscribeLibrary(asString(args.slug, "slug", 1, 64)));
299
- }
300
- if (name === "floom_move_skill") {
301
- return ok(await moveSkill(asString(args.slug, "slug", 1, 128), nullableFolder(args.folder), optionalStringArray(args.tags, "tags", 32, 64)));
197
+ if (name === "floom_sync") {
198
+ return ok(await runAutoSync());
302
199
  }
303
200
  throw new Error(`Unknown tool: ${name}`);
304
201
  }
@@ -324,12 +221,7 @@ async function handleRequest(message) {
324
221
  stdout.write(response(id, {
325
222
  protocolVersion: "2025-06-18",
326
223
  capabilities: { tools: {} },
327
- serverInfo: {
328
- name: "floom-mcp-sync",
329
- version: SERVER_VERSION,
330
- target: agentTarget(),
331
- skillsDir: skillsDir(),
332
- },
224
+ serverInfo: { name: "floom-mcp-sync", version: SERVER_VERSION },
333
225
  }));
334
226
  return;
335
227
  }
@@ -355,9 +247,9 @@ async function main() {
355
247
  if (handleCliArgs(process.argv.slice(2)))
356
248
  return;
357
249
  const intervalMs = resolvePollIntervalMs();
358
- process.stderr.write(`[floom] starting sync poller for ${agentTarget()} at ${skillsDir()} (interval ${intervalMs}ms)\n`);
250
+ process.stderr.write(`[floom] starting sync poller (interval ${intervalMs}ms)\n`);
359
251
  const syncState = { inFlight: true };
360
- void autoSync().catch((err) => {
252
+ void runAutoSync().catch((err) => {
361
253
  process.stderr.write(`[floom] initial sync failed: ${err instanceof Error ? err.message : String(err)}\n`);
362
254
  }).finally(() => {
363
255
  syncState.inFlight = false;
@@ -0,0 +1,111 @@
1
+ import { createHash } from "node:crypto";
2
+ import { apiUrlFromConfig, DEFAULT_API_URL, readConfig } from "../lib/config.js";
3
+ import { getJson } from "../lib/api.js";
4
+ import { assertValidSlug } from "../lib/slug.js";
5
+ const PACKAGE_FILE_LIMIT = 100;
6
+ const PACKAGE_FILE_BYTES_LIMIT = 500_000;
7
+ const PACKAGE_TOTAL_BYTES_LIMIT = 1_000_000;
8
+ const PATH_SEGMENT_RE = /^[A-Za-z0-9._-]{1,128}$/;
9
+ const SUPPORT_DIRS = new Set(["references", "examples", "scripts", "assets"]);
10
+ const SHA256_RE = /^[a-f0-9]{64}$/i;
11
+ const BASE64_RE = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
12
+ export async function getSkill(slug) {
13
+ assertValidSlug(slug);
14
+ const cfg = await readConfig();
15
+ const apiUrl = cfg ? apiUrlFromConfig(cfg) : (process.env.FLOOM_API_URL ?? DEFAULT_API_URL).replace(/\/$/, "");
16
+ const detail = await getJson(`${apiUrl}/api/v1/skills/${encodeURIComponent(slug)}`, cfg?.accessToken);
17
+ if (!detail || typeof detail.slug !== "string" || typeof detail.body_md !== "string") {
18
+ throw new Error("Invalid skill response");
19
+ }
20
+ return {
21
+ slug: detail.slug,
22
+ title: typeof detail.title === "string" ? detail.title : null,
23
+ description: typeof detail.description === "string" ? detail.description : null,
24
+ type: typeof detail.asset_type === "string" ? detail.asset_type : "skill",
25
+ version: typeof detail.version === "string"
26
+ ? detail.version
27
+ : typeof detail.skill_version === "string"
28
+ ? detail.skill_version
29
+ : null,
30
+ content: detail.body_md,
31
+ package: {
32
+ main: "SKILL.md",
33
+ supporting_dirs: ["references", "examples", "scripts", "assets"],
34
+ },
35
+ package_files: normalizePackageFiles(detail.package_files ?? detail.files),
36
+ };
37
+ }
38
+ function normalizePackageFiles(raw) {
39
+ if (!Array.isArray(raw))
40
+ return [];
41
+ if (raw.length > PACKAGE_FILE_LIMIT)
42
+ throw new Error("Invalid skill package response");
43
+ let totalBytes = 0;
44
+ return raw.flatMap((file) => {
45
+ if (!file || typeof file !== "object")
46
+ return [];
47
+ const candidate = file;
48
+ if (typeof candidate.path !== "string" || !isSafePackagePath(candidate.path))
49
+ return [];
50
+ const out = { path: candidate.path };
51
+ if (typeof candidate.encoding === "string")
52
+ out.encoding = candidate.encoding;
53
+ if (typeof candidate.size_bytes === "number") {
54
+ if (!Number.isInteger(candidate.size_bytes) || candidate.size_bytes < 0 || candidate.size_bytes > PACKAGE_FILE_BYTES_LIMIT)
55
+ return [];
56
+ out.size_bytes = candidate.size_bytes;
57
+ totalBytes += candidate.size_bytes;
58
+ if (totalBytes > PACKAGE_TOTAL_BYTES_LIMIT)
59
+ throw new Error("Invalid skill package response");
60
+ }
61
+ if (typeof candidate.sha256 === "string") {
62
+ if (!SHA256_RE.test(candidate.sha256))
63
+ return [];
64
+ out.sha256 = candidate.sha256.toLowerCase();
65
+ }
66
+ if (typeof candidate.content_hash === "string" && SHA256_RE.test(candidate.content_hash)) {
67
+ out.content_hash = candidate.content_hash.toLowerCase();
68
+ }
69
+ if (typeof candidate.content_base64 === "string") {
70
+ if ((candidate.encoding ?? "base64") !== "base64")
71
+ return [];
72
+ if (!BASE64_RE.test(candidate.content_base64))
73
+ return [];
74
+ const bytes = Buffer.from(candidate.content_base64, "base64");
75
+ if (bytes.length > PACKAGE_FILE_BYTES_LIMIT)
76
+ return [];
77
+ if (out.size_bytes !== undefined && out.size_bytes !== bytes.length)
78
+ return [];
79
+ const expectedHash = out.sha256 ?? out.content_hash;
80
+ if (!expectedHash)
81
+ return [];
82
+ const actualHash = createHash("sha256").update(bytes).digest("hex");
83
+ if (actualHash !== expectedHash)
84
+ return [];
85
+ out.content_base64 = candidate.content_base64;
86
+ if (out.size_bytes === undefined)
87
+ totalBytes += bytes.length;
88
+ out.size_bytes = bytes.length;
89
+ out.sha256 = expectedHash;
90
+ }
91
+ if (typeof candidate.content === "string")
92
+ out.content = candidate.content;
93
+ if (typeof candidate.body === "string")
94
+ out.body = candidate.body;
95
+ if (typeof candidate.body_md === "string")
96
+ out.body_md = candidate.body_md;
97
+ if (typeof candidate.text === "string")
98
+ out.text = candidate.text;
99
+ return [out];
100
+ });
101
+ }
102
+ function isSafePackagePath(path) {
103
+ if (!path || path.length > 512 || path.startsWith("/") || path.includes("\\") || path.includes("//"))
104
+ return false;
105
+ const segments = path.split("/");
106
+ if (segments.length < 2)
107
+ return false;
108
+ if (!SUPPORT_DIRS.has(segments[0] ?? ""))
109
+ return false;
110
+ return segments.every((segment) => PATH_SEGMENT_RE.test(segment) && segment !== "." && segment !== "..");
111
+ }
@@ -0,0 +1,89 @@
1
+ import { constants } from "node:fs";
2
+ import { open } from "node:fs/promises";
3
+ import { isAbsolute, join, relative, resolve, sep } from "node:path";
4
+ import { readConfig } from "../lib/config.js";
5
+ import { sha256 } from "../lib/hash.js";
6
+ import { skillsDir } from "../lib/paths.js";
7
+ import { readSyncManifest, syncManifestPath } from "../lib/manifest.js";
8
+ const MANIFEST_SEGMENT_RE = /^[A-Za-z0-9._-]{1,128}$/;
9
+ export async function syncStatus() {
10
+ const cfg = await readConfig();
11
+ const root = skillsDir();
12
+ const manifest = await readSyncManifest();
13
+ const drift = { missing: [], changed: [], blocked: [] };
14
+ let upToDate = 0;
15
+ for (const [key, entry] of Object.entries(manifest.files)) {
16
+ let target;
17
+ try {
18
+ target = targetFromManifestKey(root, key);
19
+ }
20
+ catch (err) {
21
+ drift.blocked.push({ target: key, reason: err instanceof Error ? err.message : "invalid manifest target path" });
22
+ continue;
23
+ }
24
+ const state = await localState(target);
25
+ if (state.kind === "missing") {
26
+ drift.missing.push(key);
27
+ }
28
+ else if (state.kind === "blocked") {
29
+ drift.blocked.push({ target: key, reason: state.reason });
30
+ }
31
+ else if (state.hash === entry.hash) {
32
+ upToDate += 1;
33
+ }
34
+ else {
35
+ drift.changed.push(key);
36
+ }
37
+ }
38
+ return {
39
+ signed_in: cfg !== null,
40
+ skills_dir: root,
41
+ manifest_path: syncManifestPath(),
42
+ tracked_files: Object.keys(manifest.files).length,
43
+ up_to_date: upToDate,
44
+ missing: drift.missing.length,
45
+ changed: drift.changed.length,
46
+ blocked: drift.blocked.length,
47
+ drift,
48
+ };
49
+ }
50
+ async function localState(path) {
51
+ try {
52
+ const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
53
+ try {
54
+ const stat = await handle.stat();
55
+ if (!stat.isFile())
56
+ return { kind: "blocked", reason: "path is blocked by an existing local file or directory" };
57
+ return { kind: "file", hash: sha256(await handle.readFile()) };
58
+ }
59
+ finally {
60
+ await handle.close();
61
+ }
62
+ }
63
+ catch (err) {
64
+ const code = err.code;
65
+ if (code === "ENOENT")
66
+ return { kind: "missing" };
67
+ if (code === "ELOOP")
68
+ return { kind: "blocked", reason: "path is a symbolic link" };
69
+ if (code === "ENOTDIR" || code === "EISDIR") {
70
+ return { kind: "blocked", reason: "path is blocked by an existing local file or directory" };
71
+ }
72
+ throw err;
73
+ }
74
+ }
75
+ function targetFromManifestKey(root, key) {
76
+ if (!key || isAbsolute(key) || key.includes("\\") || key.length > 512) {
77
+ throw new Error("Invalid manifest target path");
78
+ }
79
+ const segments = key.split("/");
80
+ if (segments.some((segment) => segment === "." || segment === ".." || !MANIFEST_SEGMENT_RE.test(segment))) {
81
+ throw new Error("Invalid manifest target path");
82
+ }
83
+ const target = join(root, ...segments);
84
+ const relativeTarget = relative(resolve(root), resolve(target));
85
+ if (relativeTarget === ".." || relativeTarget.startsWith(`..${sep}`) || isAbsolute(relativeTarget)) {
86
+ throw new Error("Invalid manifest target path");
87
+ }
88
+ return target;
89
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floomhq/floom-mcp-sync",
3
- "version": "1.0.5",
3
+ "version": "1.0.7",
4
4
  "description": "Lightweight Floom MCP server for installing, publishing, and startup-syncing skills.",
5
5
  "license": "MIT",
6
6
  "type": "module",