@floomhq/floom-mcp-sync 1.0.26 → 1.0.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/auto-sync.js +122 -58
- package/dist/lib/api.js +21 -53
- package/dist/lib/config.js +29 -39
- package/dist/lib/manifest.js +1 -39
- package/dist/lib/paths.js +3 -0
- package/dist/server.js +5 -2
- package/dist/tools/get.js +2 -39
- package/dist/tools/install.js +117 -0
- package/dist/tools/libraries.js +28 -0
- package/dist/tools/publish.js +24 -0
- package/package.json +3 -8
package/README.md
CHANGED
|
@@ -49,7 +49,7 @@ args = ["-y", "@floomhq/floom-mcp-sync"]
|
|
|
49
49
|
FLOOM_SKILLS_DIR = "~/.codex/skills"
|
|
50
50
|
```
|
|
51
51
|
|
|
52
|
-
Directory resolution order is `FLOOM_SKILLS_DIR`, `CLAUDE_SKILLS_DIR`, `CODEX_SKILLS_DIR`, then `~/.claude/skills`.
|
|
52
|
+
Directory resolution order is `FLOOM_SKILLS_DIR`, `CLAUDE_SKILLS_DIR`, `CODEX_SKILLS_DIR`, `CURSOR_SKILLS_DIR`, `OPENCODE_SKILLS_DIR`, `KIMI_SKILLS_DIR`, then `~/.claude/skills`.
|
|
53
53
|
|
|
54
54
|
## Sync Behavior
|
|
55
55
|
|
|
@@ -59,7 +59,7 @@ Version 1 sync does not replace existing local files. Remote updates, existing u
|
|
|
59
59
|
|
|
60
60
|
The poll uses HTTP `If-None-Match` against `/api/v1/me/skills`, so unchanged responses are `304` with no body. If a Floom-tracked local file is missing after a `304`, MCP refetches once without the ETag so Version 1 can recreate missing files without overwriting existing files.
|
|
61
61
|
|
|
62
|
-
Configure the poll interval with `FLOOM_SYNC_INTERVAL_MS` (default `60000`, minimum `10000`).
|
|
62
|
+
Configure the preview poll interval with `FLOOM_SYNC_INTERVAL_MS` (default `60000`, minimum `10000`).
|
|
63
63
|
|
|
64
64
|
## Tools
|
|
65
65
|
|
package/dist/auto-sync.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { constants } from "node:fs";
|
|
2
2
|
import { lstat, mkdir, open } from "node:fs/promises";
|
|
3
3
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
|
-
import { apiUrlFromConfig, readConfig,
|
|
4
|
+
import { apiUrlFromConfig, readConfig, refreshConfigIfNeeded } 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
|
-
"
|
|
47
|
+
"remotion-starter",
|
|
32
48
|
"scripts",
|
|
33
|
-
"
|
|
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
|
|
60
|
+
const ROOT_SUPPORT_FILES = new Set([
|
|
39
61
|
".env.example",
|
|
40
|
-
".
|
|
41
|
-
".
|
|
42
|
-
"
|
|
43
|
-
".
|
|
44
|
-
".
|
|
45
|
-
".
|
|
46
|
-
".
|
|
47
|
-
".
|
|
48
|
-
".
|
|
49
|
-
".
|
|
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 =
|
|
53
|
-
const PACKAGE_TOTAL_BYTES_LIMIT =
|
|
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 &&
|
|
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
|
-
|
|
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)
|
|
@@ -282,38 +349,35 @@ async function manifestHasMissingTrackedFile(manifest, root) {
|
|
|
282
349
|
return false;
|
|
283
350
|
}
|
|
284
351
|
export async function autoSync(log = (message) => process.stderr.write(`${message}\n`), signal) {
|
|
285
|
-
const
|
|
286
|
-
if (!
|
|
352
|
+
const initialCfg = await readConfig();
|
|
353
|
+
if (!initialCfg) {
|
|
287
354
|
maybeAuthWarning(log, "[floom] not signed in; skipping sync (run `npx -y @floomhq/floom login`)");
|
|
288
355
|
return { synced: 0, unchanged: 0, updated: 0, conflicts: 0 };
|
|
289
356
|
}
|
|
357
|
+
let cfg = initialCfg;
|
|
290
358
|
await ensureSyncManifestDir();
|
|
291
359
|
return await withSyncLock(async () => {
|
|
292
360
|
const manifest = await readSyncManifest();
|
|
293
361
|
const root = skillsDir();
|
|
294
|
-
|
|
295
|
-
const apiUrl = apiUrlFromConfig(activeCfg);
|
|
362
|
+
const apiUrl = apiUrlFromConfig(cfg);
|
|
296
363
|
let response;
|
|
297
364
|
try {
|
|
298
|
-
response = await getJsonWithEtag(`${apiUrl}/api/v1/me/skills`,
|
|
365
|
+
response = await getJsonWithEtag(`${apiUrl}/api/v1/me/skills`, cfg.accessToken, cachedEtag, signal);
|
|
299
366
|
}
|
|
300
367
|
catch (err) {
|
|
301
|
-
if (err instanceof FloomApiError
|
|
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 {
|
|
368
|
+
if (!(err instanceof FloomApiError) || err.status !== 401)
|
|
311
369
|
throw err;
|
|
370
|
+
const refreshed = await refreshConfigIfNeeded(cfg, { force: true });
|
|
371
|
+
if (refreshed.accessToken === cfg.accessToken) {
|
|
372
|
+
maybeAuthWarning(log, "[floom] sign-in expired; skipping sync (run `npx -y @floomhq/floom login` again)");
|
|
373
|
+
return { synced: 0, unchanged: 0, updated: 0, conflicts: 0 };
|
|
312
374
|
}
|
|
375
|
+
cfg = refreshed;
|
|
376
|
+
response = await getJsonWithEtag(`${apiUrl}/api/v1/me/skills`, cfg.accessToken, cachedEtag, signal);
|
|
313
377
|
}
|
|
314
378
|
if (response.status === 304) {
|
|
315
379
|
if (await manifestHasMissingTrackedFile(manifest, root)) {
|
|
316
|
-
response = await getJsonWithEtag(`${apiUrl}/api/v1/me/skills`,
|
|
380
|
+
response = await getJsonWithEtag(`${apiUrl}/api/v1/me/skills`, cfg.accessToken, null, signal);
|
|
317
381
|
}
|
|
318
382
|
else {
|
|
319
383
|
maybeHeartbeat(log);
|
|
@@ -336,12 +400,9 @@ export async function autoSync(log = (message) => process.stderr.write(`${messag
|
|
|
336
400
|
}
|
|
337
401
|
for (const skill of payload.skills)
|
|
338
402
|
validateSyncSkillShape(skill);
|
|
339
|
-
// Version 1
|
|
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.
|
|
403
|
+
// Version 1 preview syncs owned published skills only.
|
|
343
404
|
const buckets = [
|
|
344
|
-
{ skills:
|
|
405
|
+
{ skills: payload.skills, defaultLib: null },
|
|
345
406
|
];
|
|
346
407
|
let unchanged = 0;
|
|
347
408
|
let updated = 0;
|
|
@@ -395,6 +456,9 @@ export async function autoSync(log = (message) => process.stderr.write(`${messag
|
|
|
395
456
|
continue;
|
|
396
457
|
}
|
|
397
458
|
if (plan.kind === "unchanged") {
|
|
459
|
+
for (const file of packageFiles)
|
|
460
|
+
markSynced(manifest, manifestKey(root, file.target), skill.slug, file.hash);
|
|
461
|
+
await writeSyncManifest(manifest);
|
|
398
462
|
unchanged += 1;
|
|
399
463
|
continue;
|
|
400
464
|
}
|
|
@@ -473,7 +537,7 @@ export async function autoSync(log = (message) => process.stderr.write(`${messag
|
|
|
473
537
|
if (updated > 0) {
|
|
474
538
|
// Activation telemetry counts syncs that write new content. Best-effort;
|
|
475
539
|
// never blocks or throws.
|
|
476
|
-
void emitSyncCompleted(apiUrl,
|
|
540
|
+
void emitSyncCompleted(apiUrl, cfg.accessToken, { total, updated, unchanged }, signal).catch(() => { });
|
|
477
541
|
}
|
|
478
542
|
}
|
|
479
543
|
else if (conflicts > 0) {
|
package/dist/lib/api.js
CHANGED
|
@@ -6,53 +6,6 @@ export class FloomApiError extends Error {
|
|
|
6
6
|
this.name = "FloomApiError";
|
|
7
7
|
}
|
|
8
8
|
}
|
|
9
|
-
export const DEFAULT_TIMEOUT_MS = 15_000;
|
|
10
|
-
function withTimeout(signal) {
|
|
11
|
-
const controller = new AbortController();
|
|
12
|
-
const timeoutMs = requestTimeoutMs();
|
|
13
|
-
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
14
|
-
const abort = () => controller.abort();
|
|
15
|
-
signal?.addEventListener("abort", abort, { once: true });
|
|
16
|
-
if (signal?.aborted)
|
|
17
|
-
controller.abort();
|
|
18
|
-
const aborted = new Promise((_, reject) => {
|
|
19
|
-
controller.signal.addEventListener("abort", () => {
|
|
20
|
-
reject(new FloomApiError(408, "Request timed out while contacting Floom."));
|
|
21
|
-
}, { once: true });
|
|
22
|
-
});
|
|
23
|
-
return {
|
|
24
|
-
signal: controller.signal,
|
|
25
|
-
aborted,
|
|
26
|
-
cleanup: () => {
|
|
27
|
-
clearTimeout(timer);
|
|
28
|
-
signal?.removeEventListener("abort", abort);
|
|
29
|
-
},
|
|
30
|
-
};
|
|
31
|
-
}
|
|
32
|
-
function requestTimeoutMs() {
|
|
33
|
-
const fromEnv = Number(process.env.FLOOM_REQUEST_TIMEOUT_MS);
|
|
34
|
-
if (Number.isFinite(fromEnv) && fromEnv > 0)
|
|
35
|
-
return fromEnv;
|
|
36
|
-
return DEFAULT_TIMEOUT_MS;
|
|
37
|
-
}
|
|
38
|
-
async function fetchWithTimeout(url, init, signal) {
|
|
39
|
-
const timeout = withTimeout(signal);
|
|
40
|
-
try {
|
|
41
|
-
return await Promise.race([
|
|
42
|
-
fetch(url, { ...init, signal: timeout.signal }),
|
|
43
|
-
timeout.aborted,
|
|
44
|
-
]);
|
|
45
|
-
}
|
|
46
|
-
catch (err) {
|
|
47
|
-
if (err.name === "AbortError") {
|
|
48
|
-
throw new FloomApiError(408, "Request timed out while contacting Floom.");
|
|
49
|
-
}
|
|
50
|
-
throw err;
|
|
51
|
-
}
|
|
52
|
-
finally {
|
|
53
|
-
timeout.cleanup();
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
9
|
async function readError(res) {
|
|
57
10
|
const text = await res.text();
|
|
58
11
|
if (!text)
|
|
@@ -71,7 +24,10 @@ export async function getJson(url, token, signal) {
|
|
|
71
24
|
const headers = {};
|
|
72
25
|
if (token)
|
|
73
26
|
headers.authorization = `Bearer ${token}`;
|
|
74
|
-
const
|
|
27
|
+
const init = { headers };
|
|
28
|
+
if (signal)
|
|
29
|
+
init.signal = signal;
|
|
30
|
+
const res = await fetch(url, init);
|
|
75
31
|
if (!res.ok)
|
|
76
32
|
throw new FloomApiError(res.status, await readError(res));
|
|
77
33
|
return (await res.json());
|
|
@@ -84,7 +40,10 @@ export async function getJsonWithEtag(url, token, etag, signal) {
|
|
|
84
40
|
const headers = { authorization: `Bearer ${token}` };
|
|
85
41
|
if (etag)
|
|
86
42
|
headers["if-none-match"] = etag;
|
|
87
|
-
const
|
|
43
|
+
const init = { headers };
|
|
44
|
+
if (signal)
|
|
45
|
+
init.signal = signal;
|
|
46
|
+
const res = await fetch(url, init);
|
|
88
47
|
const responseEtag = res.headers.get("etag");
|
|
89
48
|
if (res.status === 304) {
|
|
90
49
|
return { status: 304, body: null, etag: responseEtag ?? etag };
|
|
@@ -95,7 +54,10 @@ export async function getJsonWithEtag(url, token, etag, signal) {
|
|
|
95
54
|
return { status: res.status, body, etag: responseEtag };
|
|
96
55
|
}
|
|
97
56
|
export async function getText(url, signal) {
|
|
98
|
-
const
|
|
57
|
+
const init = {};
|
|
58
|
+
if (signal)
|
|
59
|
+
init.signal = signal;
|
|
60
|
+
const res = await fetch(url, init);
|
|
99
61
|
if (!res.ok)
|
|
100
62
|
throw new FloomApiError(res.status, await readError(res));
|
|
101
63
|
return res.text();
|
|
@@ -109,7 +71,9 @@ export async function postJson(url, token, body, signal) {
|
|
|
109
71
|
},
|
|
110
72
|
body: JSON.stringify(body),
|
|
111
73
|
};
|
|
112
|
-
|
|
74
|
+
if (signal)
|
|
75
|
+
init.signal = signal;
|
|
76
|
+
const res = await fetch(url, init);
|
|
113
77
|
if (!res.ok)
|
|
114
78
|
throw new FloomApiError(res.status, await readError(res));
|
|
115
79
|
return (await res.json());
|
|
@@ -123,7 +87,9 @@ export async function putJson(url, token, body, signal) {
|
|
|
123
87
|
},
|
|
124
88
|
body: JSON.stringify(body),
|
|
125
89
|
};
|
|
126
|
-
|
|
90
|
+
if (signal)
|
|
91
|
+
init.signal = signal;
|
|
92
|
+
const res = await fetch(url, init);
|
|
127
93
|
if (!res.ok)
|
|
128
94
|
throw new FloomApiError(res.status, await readError(res));
|
|
129
95
|
return (await res.json());
|
|
@@ -133,7 +99,9 @@ export async function deleteRequest(url, token, signal) {
|
|
|
133
99
|
method: "DELETE",
|
|
134
100
|
headers: { authorization: `Bearer ${token}` },
|
|
135
101
|
};
|
|
136
|
-
|
|
102
|
+
if (signal)
|
|
103
|
+
init.signal = signal;
|
|
104
|
+
const res = await fetch(url, init);
|
|
137
105
|
if (!res.ok)
|
|
138
106
|
throw new FloomApiError(res.status, await readError(res));
|
|
139
107
|
return (await res.json());
|
package/dist/lib/config.js
CHANGED
|
@@ -2,9 +2,8 @@ import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
|
2
2
|
import { dirname } from "node:path";
|
|
3
3
|
import { configPath } from "./paths.js";
|
|
4
4
|
export const DEFAULT_API_URL = "https://floom.dev";
|
|
5
|
-
const REFRESH_SKEW_SECONDS = 5 * 60;
|
|
6
5
|
export function apiUrlFromConfig(cfg) {
|
|
7
|
-
return (process.env.FLOOM_API_URL ??
|
|
6
|
+
return (cfg.apiUrl ?? process.env.FLOOM_API_URL ?? DEFAULT_API_URL).replace(/\/$/, "");
|
|
8
7
|
}
|
|
9
8
|
export async function readConfig() {
|
|
10
9
|
try {
|
|
@@ -19,9 +18,7 @@ export async function readConfig() {
|
|
|
19
18
|
...(typeof parsed.expiresAt === "number" ? { expiresAt: parsed.expiresAt } : {}),
|
|
20
19
|
...(typeof parsed.email === "string" || parsed.email === null ? { email: parsed.email } : {}),
|
|
21
20
|
};
|
|
22
|
-
|
|
23
|
-
return refreshConfig(cfg);
|
|
24
|
-
return cfg;
|
|
21
|
+
return await refreshConfigIfNeeded(cfg);
|
|
25
22
|
}
|
|
26
23
|
catch (err) {
|
|
27
24
|
if (err.code === "ENOENT")
|
|
@@ -29,53 +26,46 @@ export async function readConfig() {
|
|
|
29
26
|
throw err;
|
|
30
27
|
}
|
|
31
28
|
}
|
|
32
|
-
export function
|
|
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) {
|
|
29
|
+
export async function refreshConfigIfNeeded(cfg, opts = {}) {
|
|
38
30
|
if (!cfg.refreshToken)
|
|
39
31
|
return cfg;
|
|
40
|
-
const
|
|
41
|
-
|
|
32
|
+
const now = Math.floor(Date.now() / 1000);
|
|
33
|
+
if (!opts.force && (typeof cfg.expiresAt !== "number" || cfg.expiresAt > now + 120))
|
|
34
|
+
return cfg;
|
|
42
35
|
try {
|
|
43
|
-
|
|
36
|
+
const apiUrl = apiUrlFromConfig(cfg);
|
|
37
|
+
const res = await fetch(`${apiUrl}/api/auth/refresh`, {
|
|
44
38
|
method: "POST",
|
|
45
39
|
headers: { "content-type": "application/json" },
|
|
46
40
|
body: JSON.stringify({ refresh_token: cfg.refreshToken }),
|
|
47
41
|
});
|
|
42
|
+
if (!res.ok)
|
|
43
|
+
throw new Error(`refresh failed with ${res.status}`);
|
|
44
|
+
const data = (await res.json());
|
|
45
|
+
if (!data.access_token || !data.refresh_token)
|
|
46
|
+
throw new Error("refresh response missing tokens");
|
|
47
|
+
const expiresIn = Number(data.expires_in ?? "3600");
|
|
48
|
+
const refreshed = {
|
|
49
|
+
...cfg,
|
|
50
|
+
accessToken: data.access_token,
|
|
51
|
+
refreshToken: data.refresh_token,
|
|
52
|
+
expiresAt: Math.floor(Date.now() / 1000) + (Number.isFinite(expiresIn) ? expiresIn : 3600),
|
|
53
|
+
...(typeof data.email === "string" || data.email === null
|
|
54
|
+
? { email: data.email }
|
|
55
|
+
: typeof cfg.email === "string" || cfg.email === null
|
|
56
|
+
? { email: cfg.email }
|
|
57
|
+
: {}),
|
|
58
|
+
};
|
|
59
|
+
await writeConfig(refreshed);
|
|
60
|
+
return refreshed;
|
|
48
61
|
}
|
|
49
62
|
catch {
|
|
50
|
-
return
|
|
51
|
-
}
|
|
52
|
-
if (!res.ok)
|
|
53
|
-
return null;
|
|
54
|
-
let data;
|
|
55
|
-
try {
|
|
56
|
-
data = await res.json();
|
|
57
|
-
}
|
|
58
|
-
catch {
|
|
59
|
-
return null;
|
|
63
|
+
return cfg;
|
|
60
64
|
}
|
|
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
65
|
}
|
|
76
66
|
async function writeConfig(cfg) {
|
|
77
67
|
const target = configPath();
|
|
78
68
|
await mkdir(dirname(target), { recursive: true, mode: 0o700 });
|
|
79
|
-
await writeFile(target, JSON.stringify(cfg, null, 2)
|
|
69
|
+
await writeFile(target, `${JSON.stringify(cfg, null, 2)}\n`, { mode: 0o600 });
|
|
80
70
|
await chmod(target, 0o600);
|
|
81
71
|
}
|
package/dist/lib/manifest.js
CHANGED
|
@@ -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 || !
|
|
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.
|
|
8
|
+
const SERVER_VERSION = "1.0.28";
|
|
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:
|
|
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.
|
|
4
|
-
"description": "Lightweight Floom MCP server for
|
|
3
|
+
"version": "1.0.28",
|
|
4
|
+
"description": "Lightweight Floom MCP server for installing, publishing, and startup-syncing skills.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
@@ -9,12 +9,7 @@
|
|
|
9
9
|
},
|
|
10
10
|
"files": [
|
|
11
11
|
"bin",
|
|
12
|
-
"dist
|
|
13
|
-
"dist/lib",
|
|
14
|
-
"dist/server.js",
|
|
15
|
-
"dist/tools/get.js",
|
|
16
|
-
"dist/tools/search.js",
|
|
17
|
-
"dist/tools/status.js",
|
|
12
|
+
"dist",
|
|
18
13
|
"README.md",
|
|
19
14
|
"LICENSE"
|
|
20
15
|
],
|