@floomhq/floom-mcp-sync 1.0.6 → 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.
- package/README.md +56 -25
- package/dist/auto-sync.js +305 -191
- package/dist/lib/manifest.js +62 -8
- package/dist/lib/paths.js +19 -22
- package/dist/server.js +32 -142
- package/dist/tools/get.js +111 -0
- package/dist/tools/status.js +89 -0
- package/package.json +1 -1
package/dist/lib/manifest.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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("/")
|
|
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(
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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.
|
|
42
|
-
* `<
|
|
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
|
|
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(
|
|
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 {
|
|
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 {
|
|
10
|
-
const SERVER_VERSION = "1.0.
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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,9 +26,7 @@ Usage
|
|
|
34
26
|
|
|
35
27
|
Behavior
|
|
36
28
|
Starts a stdio MCP server.
|
|
37
|
-
Syncs
|
|
38
|
-
Requires a signed-in Floom CLI account for account-backed sync.
|
|
39
|
-
Public and unlisted shared-link installs still work through floom_install_skill without an account.
|
|
29
|
+
Syncs Floom skills into native Claude/Codex package folders.
|
|
40
30
|
Polls for updates while the MCP process is running.
|
|
41
31
|
|
|
42
32
|
Options
|
|
@@ -45,10 +35,9 @@ Options
|
|
|
45
35
|
|
|
46
36
|
Env
|
|
47
37
|
FLOOM_API_URL Override the API host.
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
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.
|
|
52
41
|
FLOOM_SYNC_INTERVAL_MS Poll interval in milliseconds. Minimum: 10000.
|
|
53
42
|
`.trimStart();
|
|
54
43
|
}
|
|
@@ -91,7 +80,7 @@ function startPolling(intervalMs, state) {
|
|
|
91
80
|
return;
|
|
92
81
|
}
|
|
93
82
|
state.inFlight = true;
|
|
94
|
-
|
|
83
|
+
runAutoSync().catch((err) => {
|
|
95
84
|
process.stderr.write(`[floom] poll failed: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
96
85
|
}).finally(() => {
|
|
97
86
|
state.inFlight = false;
|
|
@@ -103,7 +92,7 @@ function toolList() {
|
|
|
103
92
|
tools: [
|
|
104
93
|
{
|
|
105
94
|
name: "floom_search_skills",
|
|
106
|
-
description: "Search public Floom skills
|
|
95
|
+
description: "Search public Floom skills through the live API.",
|
|
107
96
|
inputSchema: {
|
|
108
97
|
type: "object",
|
|
109
98
|
properties: {
|
|
@@ -117,8 +106,8 @@ function toolList() {
|
|
|
117
106
|
},
|
|
118
107
|
},
|
|
119
108
|
{
|
|
120
|
-
name: "
|
|
121
|
-
description: "Fetch
|
|
109
|
+
name: "floom_get_skill",
|
|
110
|
+
description: "Fetch full Floom skill content by slug on demand.",
|
|
122
111
|
inputSchema: {
|
|
123
112
|
type: "object",
|
|
124
113
|
properties: {
|
|
@@ -129,26 +118,8 @@ function toolList() {
|
|
|
129
118
|
},
|
|
130
119
|
},
|
|
131
120
|
{
|
|
132
|
-
name: "
|
|
133
|
-
description: "
|
|
134
|
-
inputSchema: {
|
|
135
|
-
type: "object",
|
|
136
|
-
properties: {
|
|
137
|
-
name: { type: "string", minLength: 1, maxLength: 200 },
|
|
138
|
-
content: { type: "string", minLength: 1, maxLength: 500_000 },
|
|
139
|
-
description: { type: "string", maxLength: 1000 },
|
|
140
|
-
visibility: { type: "string", enum: [...VISIBILITIES] },
|
|
141
|
-
asset_type: { type: "string", enum: [...ASSET_TYPES] },
|
|
142
|
-
installs_as: { type: ["string", "null"], enum: [...INSTALL_TARGETS, null] },
|
|
143
|
-
version: { type: "string", pattern: "^[A-Za-z0-9][A-Za-z0-9._+-]{0,63}$" },
|
|
144
|
-
},
|
|
145
|
-
required: ["name", "content"],
|
|
146
|
-
additionalProperties: false,
|
|
147
|
-
},
|
|
148
|
-
},
|
|
149
|
-
{
|
|
150
|
-
name: "floom_list_libraries",
|
|
151
|
-
description: "List public Floom libraries.",
|
|
121
|
+
name: "floom_status",
|
|
122
|
+
description: "Report local Floom sync manifest and drift counts.",
|
|
152
123
|
inputSchema: {
|
|
153
124
|
type: "object",
|
|
154
125
|
properties: {},
|
|
@@ -156,44 +127,11 @@ function toolList() {
|
|
|
156
127
|
},
|
|
157
128
|
},
|
|
158
129
|
{
|
|
159
|
-
name: "
|
|
160
|
-
description: "
|
|
161
|
-
inputSchema: {
|
|
162
|
-
type: "object",
|
|
163
|
-
properties: {
|
|
164
|
-
slug: { type: "string", minLength: 1, maxLength: 64 },
|
|
165
|
-
},
|
|
166
|
-
required: ["slug"],
|
|
167
|
-
additionalProperties: false,
|
|
168
|
-
},
|
|
169
|
-
},
|
|
170
|
-
{
|
|
171
|
-
name: "floom_unsubscribe_library",
|
|
172
|
-
description: "Unfollow a Floom library for the signed-in user.",
|
|
173
|
-
inputSchema: {
|
|
174
|
-
type: "object",
|
|
175
|
-
properties: {
|
|
176
|
-
slug: { type: "string", minLength: 1, maxLength: 64 },
|
|
177
|
-
},
|
|
178
|
-
required: ["slug"],
|
|
179
|
-
additionalProperties: false,
|
|
180
|
-
},
|
|
181
|
-
},
|
|
182
|
-
{
|
|
183
|
-
name: "floom_move_skill",
|
|
184
|
-
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.",
|
|
185
132
|
inputSchema: {
|
|
186
133
|
type: "object",
|
|
187
|
-
properties: {
|
|
188
|
-
slug: { type: "string", minLength: 1, maxLength: 128 },
|
|
189
|
-
folder: { type: ["string", "null"], maxLength: 256 },
|
|
190
|
-
tags: {
|
|
191
|
-
type: "array",
|
|
192
|
-
items: { type: "string", minLength: 1, maxLength: 64 },
|
|
193
|
-
maxItems: 32,
|
|
194
|
-
},
|
|
195
|
-
},
|
|
196
|
-
required: ["slug", "folder"],
|
|
134
|
+
properties: {},
|
|
197
135
|
additionalProperties: false,
|
|
198
136
|
},
|
|
199
137
|
},
|
|
@@ -218,13 +156,6 @@ function optionalString(value, label, max) {
|
|
|
218
156
|
throw new Error(`Invalid ${label}.`);
|
|
219
157
|
return value;
|
|
220
158
|
}
|
|
221
|
-
function enumValue(value, label, allowed, fallback) {
|
|
222
|
-
if (value === undefined)
|
|
223
|
-
return fallback;
|
|
224
|
-
if (typeof value !== "string" || !allowed.has(value))
|
|
225
|
-
throw new Error(`Invalid ${label}.`);
|
|
226
|
-
return value;
|
|
227
|
-
}
|
|
228
159
|
function optionalEnumValue(value, label, allowed) {
|
|
229
160
|
if (value === undefined)
|
|
230
161
|
return undefined;
|
|
@@ -232,22 +163,6 @@ function optionalEnumValue(value, label, allowed) {
|
|
|
232
163
|
throw new Error(`Invalid ${label}.`);
|
|
233
164
|
return value;
|
|
234
165
|
}
|
|
235
|
-
function nullableEnumValue(value, label, allowed, fallback) {
|
|
236
|
-
if (value === undefined)
|
|
237
|
-
return fallback;
|
|
238
|
-
if (value === null)
|
|
239
|
-
return null;
|
|
240
|
-
if (typeof value !== "string" || !allowed.has(value))
|
|
241
|
-
throw new Error(`Invalid ${label}.`);
|
|
242
|
-
return value;
|
|
243
|
-
}
|
|
244
|
-
function optionalStringArray(value, label, maxItems, maxLength) {
|
|
245
|
-
if (value === undefined)
|
|
246
|
-
return [];
|
|
247
|
-
if (!Array.isArray(value) || value.length > maxItems)
|
|
248
|
-
throw new Error(`Invalid ${label}.`);
|
|
249
|
-
return value.map((item) => asString(item, label, 1, maxLength));
|
|
250
|
-
}
|
|
251
166
|
function optionalInteger(value, label, min, max) {
|
|
252
167
|
if (value === undefined)
|
|
253
168
|
return undefined;
|
|
@@ -256,20 +171,12 @@ function optionalInteger(value, label, min, max) {
|
|
|
256
171
|
}
|
|
257
172
|
return value;
|
|
258
173
|
}
|
|
259
|
-
function nullableFolder(value) {
|
|
260
|
-
if (value === null)
|
|
261
|
-
return null;
|
|
262
|
-
const folder = asString(value, "folder", 1, 256);
|
|
263
|
-
if (!FOLDER_RE.test(folder))
|
|
264
|
-
throw new Error("Invalid folder.");
|
|
265
|
-
return folder;
|
|
266
|
-
}
|
|
267
174
|
async function callTool(params) {
|
|
268
175
|
const parsed = asObject(params);
|
|
269
176
|
const name = asString(parsed.name, "tool name", 1, 200);
|
|
270
177
|
const args = asObject(parsed.arguments ?? {});
|
|
271
|
-
if (name === "
|
|
272
|
-
return ok(await
|
|
178
|
+
if (name === "floom_get_skill") {
|
|
179
|
+
return ok(await getSkill(asString(args.slug, "slug", 1, 128)));
|
|
273
180
|
}
|
|
274
181
|
if (name === "floom_search_skills") {
|
|
275
182
|
const opts = {};
|
|
@@ -284,23 +191,11 @@ async function callTool(params) {
|
|
|
284
191
|
opts.limit = limit;
|
|
285
192
|
return ok(await searchSkills(asString(args.query, "query", 1, 120), opts));
|
|
286
193
|
}
|
|
287
|
-
if (name === "
|
|
288
|
-
|
|
289
|
-
if (version !== undefined && !VERSION_RE.test(version))
|
|
290
|
-
throw new Error("Invalid version.");
|
|
291
|
-
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));
|
|
292
|
-
}
|
|
293
|
-
if (name === "floom_list_libraries") {
|
|
294
|
-
return ok(await listLibraries());
|
|
194
|
+
if (name === "floom_status") {
|
|
195
|
+
return ok(await syncStatus());
|
|
295
196
|
}
|
|
296
|
-
if (name === "
|
|
297
|
-
return ok(await
|
|
298
|
-
}
|
|
299
|
-
if (name === "floom_unsubscribe_library") {
|
|
300
|
-
return ok(await unsubscribeLibrary(asString(args.slug, "slug", 1, 64)));
|
|
301
|
-
}
|
|
302
|
-
if (name === "floom_move_skill") {
|
|
303
|
-
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());
|
|
304
199
|
}
|
|
305
200
|
throw new Error(`Unknown tool: ${name}`);
|
|
306
201
|
}
|
|
@@ -326,12 +221,7 @@ async function handleRequest(message) {
|
|
|
326
221
|
stdout.write(response(id, {
|
|
327
222
|
protocolVersion: "2025-06-18",
|
|
328
223
|
capabilities: { tools: {} },
|
|
329
|
-
serverInfo: {
|
|
330
|
-
name: "floom-mcp-sync",
|
|
331
|
-
version: SERVER_VERSION,
|
|
332
|
-
target: agentTarget(),
|
|
333
|
-
skillsDir: skillsDir(),
|
|
334
|
-
},
|
|
224
|
+
serverInfo: { name: "floom-mcp-sync", version: SERVER_VERSION },
|
|
335
225
|
}));
|
|
336
226
|
return;
|
|
337
227
|
}
|
|
@@ -357,9 +247,9 @@ async function main() {
|
|
|
357
247
|
if (handleCliArgs(process.argv.slice(2)))
|
|
358
248
|
return;
|
|
359
249
|
const intervalMs = resolvePollIntervalMs();
|
|
360
|
-
process.stderr.write(`[floom] starting sync poller
|
|
250
|
+
process.stderr.write(`[floom] starting sync poller (interval ${intervalMs}ms)\n`);
|
|
361
251
|
const syncState = { inFlight: true };
|
|
362
|
-
void
|
|
252
|
+
void runAutoSync().catch((err) => {
|
|
363
253
|
process.stderr.write(`[floom] initial sync failed: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
364
254
|
}).finally(() => {
|
|
365
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
|
+
}
|