@floomhq/floom-mcp-sync 1.0.11 → 1.0.13
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 +1 -1
- package/dist/auto-sync.js +32 -10
- package/dist/lib/api.js +53 -21
- package/dist/lib/config.js +58 -3
- package/dist/server.js +2 -2
- package/package.json +8 -3
- package/dist/tools/install.js +0 -117
- package/dist/tools/libraries.js +0 -28
- package/dist/tools/publish.js +0 -24
package/README.md
CHANGED
|
@@ -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
|
|
62
|
+
Configure the 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 } from "./lib/config.js";
|
|
4
|
+
import { apiUrlFromConfig, readConfig, refreshConfig } 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";
|
|
@@ -23,6 +23,17 @@ const PACKAGE_FILE_LIMIT = 100;
|
|
|
23
23
|
const PACKAGE_TOTAL_BYTES_LIMIT = 1_000_000;
|
|
24
24
|
const PACKAGE_FILE_BYTES_LIMIT = 500_000;
|
|
25
25
|
const BASE64_RE = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
|
|
26
|
+
function hasStructuredPath(skill) {
|
|
27
|
+
return Boolean(skill.library_slug || skill.folder);
|
|
28
|
+
}
|
|
29
|
+
function dedupeSyncSkills(skills) {
|
|
30
|
+
const structuredSlugs = new Set();
|
|
31
|
+
for (const skill of skills) {
|
|
32
|
+
if (hasStructuredPath(skill))
|
|
33
|
+
structuredSlugs.add(skill.slug);
|
|
34
|
+
}
|
|
35
|
+
return skills.filter((skill) => !structuredSlugs.has(skill.slug) || hasStructuredPath(skill));
|
|
36
|
+
}
|
|
26
37
|
async function localState(path) {
|
|
27
38
|
try {
|
|
28
39
|
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
@@ -243,21 +254,29 @@ export async function autoSync(log = (message) => process.stderr.write(`${messag
|
|
|
243
254
|
return await withSyncLock(async () => {
|
|
244
255
|
const manifest = await readSyncManifest();
|
|
245
256
|
const root = skillsDir();
|
|
246
|
-
|
|
257
|
+
let activeCfg = cfg;
|
|
258
|
+
const apiUrl = apiUrlFromConfig(activeCfg);
|
|
247
259
|
let response;
|
|
248
260
|
try {
|
|
249
|
-
response = await getJsonWithEtag(`${apiUrl}/api/v1/me/skills`,
|
|
261
|
+
response = await getJsonWithEtag(`${apiUrl}/api/v1/me/skills`, activeCfg.accessToken, cachedEtag, signal);
|
|
250
262
|
}
|
|
251
263
|
catch (err) {
|
|
252
264
|
if (err instanceof FloomApiError && err.status === 401) {
|
|
253
|
-
|
|
254
|
-
|
|
265
|
+
const refreshed = await refreshConfig(activeCfg);
|
|
266
|
+
if (!refreshed) {
|
|
267
|
+
maybeAuthWarning(log, "[floom] sign-in expired; skipping sync (run `npx -y @floomhq/floom login` again)");
|
|
268
|
+
return { synced: 0, unchanged: 0, updated: 0, conflicts: 0 };
|
|
269
|
+
}
|
|
270
|
+
activeCfg = refreshed;
|
|
271
|
+
response = await getJsonWithEtag(`${apiUrl}/api/v1/me/skills`, activeCfg.accessToken, cachedEtag, signal);
|
|
272
|
+
}
|
|
273
|
+
else {
|
|
274
|
+
throw err;
|
|
255
275
|
}
|
|
256
|
-
throw err;
|
|
257
276
|
}
|
|
258
277
|
if (response.status === 304) {
|
|
259
278
|
if (await manifestHasMissingTrackedFile(manifest, root)) {
|
|
260
|
-
response = await getJsonWithEtag(`${apiUrl}/api/v1/me/skills`,
|
|
279
|
+
response = await getJsonWithEtag(`${apiUrl}/api/v1/me/skills`, activeCfg.accessToken, null, signal);
|
|
261
280
|
}
|
|
262
281
|
else {
|
|
263
282
|
maybeHeartbeat(log);
|
|
@@ -280,9 +299,12 @@ export async function autoSync(log = (message) => process.stderr.write(`${messag
|
|
|
280
299
|
}
|
|
281
300
|
for (const skill of payload.skills)
|
|
282
301
|
validateSyncSkillShape(skill);
|
|
283
|
-
// Version 1
|
|
302
|
+
// Version 1 receives published, saved, and subscribed library skills through
|
|
303
|
+
// the single /me/skills response. If a slug appears both at top level and in
|
|
304
|
+
// a library/folder placement, keep only the structured placement so agents do
|
|
305
|
+
// not see duplicate native packages.
|
|
284
306
|
const buckets = [
|
|
285
|
-
{ skills: payload.skills, defaultLib: null },
|
|
307
|
+
{ skills: dedupeSyncSkills(payload.skills), defaultLib: null },
|
|
286
308
|
];
|
|
287
309
|
let unchanged = 0;
|
|
288
310
|
let updated = 0;
|
|
@@ -414,7 +436,7 @@ export async function autoSync(log = (message) => process.stderr.write(`${messag
|
|
|
414
436
|
if (updated > 0) {
|
|
415
437
|
// Activation telemetry counts syncs that write new content. Best-effort;
|
|
416
438
|
// never blocks or throws.
|
|
417
|
-
void emitSyncCompleted(apiUrl,
|
|
439
|
+
void emitSyncCompleted(apiUrl, activeCfg.accessToken, { total, updated, unchanged }, signal).catch(() => { });
|
|
418
440
|
}
|
|
419
441
|
}
|
|
420
442
|
else if (conflicts > 0) {
|
package/dist/lib/api.js
CHANGED
|
@@ -6,6 +6,53 @@ 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
|
+
}
|
|
9
56
|
async function readError(res) {
|
|
10
57
|
const text = await res.text();
|
|
11
58
|
if (!text)
|
|
@@ -24,10 +71,7 @@ export async function getJson(url, token, signal) {
|
|
|
24
71
|
const headers = {};
|
|
25
72
|
if (token)
|
|
26
73
|
headers.authorization = `Bearer ${token}`;
|
|
27
|
-
const
|
|
28
|
-
if (signal)
|
|
29
|
-
init.signal = signal;
|
|
30
|
-
const res = await fetch(url, init);
|
|
74
|
+
const res = await fetchWithTimeout(url, { headers }, signal);
|
|
31
75
|
if (!res.ok)
|
|
32
76
|
throw new FloomApiError(res.status, await readError(res));
|
|
33
77
|
return (await res.json());
|
|
@@ -40,10 +84,7 @@ export async function getJsonWithEtag(url, token, etag, signal) {
|
|
|
40
84
|
const headers = { authorization: `Bearer ${token}` };
|
|
41
85
|
if (etag)
|
|
42
86
|
headers["if-none-match"] = etag;
|
|
43
|
-
const
|
|
44
|
-
if (signal)
|
|
45
|
-
init.signal = signal;
|
|
46
|
-
const res = await fetch(url, init);
|
|
87
|
+
const res = await fetchWithTimeout(url, { headers }, signal);
|
|
47
88
|
const responseEtag = res.headers.get("etag");
|
|
48
89
|
if (res.status === 304) {
|
|
49
90
|
return { status: 304, body: null, etag: responseEtag ?? etag };
|
|
@@ -54,10 +95,7 @@ export async function getJsonWithEtag(url, token, etag, signal) {
|
|
|
54
95
|
return { status: res.status, body, etag: responseEtag };
|
|
55
96
|
}
|
|
56
97
|
export async function getText(url, signal) {
|
|
57
|
-
const
|
|
58
|
-
if (signal)
|
|
59
|
-
init.signal = signal;
|
|
60
|
-
const res = await fetch(url, init);
|
|
98
|
+
const res = await fetchWithTimeout(url, {}, signal);
|
|
61
99
|
if (!res.ok)
|
|
62
100
|
throw new FloomApiError(res.status, await readError(res));
|
|
63
101
|
return res.text();
|
|
@@ -71,9 +109,7 @@ export async function postJson(url, token, body, signal) {
|
|
|
71
109
|
},
|
|
72
110
|
body: JSON.stringify(body),
|
|
73
111
|
};
|
|
74
|
-
|
|
75
|
-
init.signal = signal;
|
|
76
|
-
const res = await fetch(url, init);
|
|
112
|
+
const res = await fetchWithTimeout(url, init, signal);
|
|
77
113
|
if (!res.ok)
|
|
78
114
|
throw new FloomApiError(res.status, await readError(res));
|
|
79
115
|
return (await res.json());
|
|
@@ -87,9 +123,7 @@ export async function putJson(url, token, body, signal) {
|
|
|
87
123
|
},
|
|
88
124
|
body: JSON.stringify(body),
|
|
89
125
|
};
|
|
90
|
-
|
|
91
|
-
init.signal = signal;
|
|
92
|
-
const res = await fetch(url, init);
|
|
126
|
+
const res = await fetchWithTimeout(url, init, signal);
|
|
93
127
|
if (!res.ok)
|
|
94
128
|
throw new FloomApiError(res.status, await readError(res));
|
|
95
129
|
return (await res.json());
|
|
@@ -99,9 +133,7 @@ export async function deleteRequest(url, token, signal) {
|
|
|
99
133
|
method: "DELETE",
|
|
100
134
|
headers: { authorization: `Bearer ${token}` },
|
|
101
135
|
};
|
|
102
|
-
|
|
103
|
-
init.signal = signal;
|
|
104
|
-
const res = await fetch(url, init);
|
|
136
|
+
const res = await fetchWithTimeout(url, init, signal);
|
|
105
137
|
if (!res.ok)
|
|
106
138
|
throw new FloomApiError(res.status, await readError(res));
|
|
107
139
|
return (await res.json());
|
package/dist/lib/config.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import { readFile } from "node:fs/promises";
|
|
1
|
+
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname } from "node:path";
|
|
2
3
|
import { configPath } from "./paths.js";
|
|
3
4
|
export const DEFAULT_API_URL = "https://floom.dev";
|
|
5
|
+
const REFRESH_SKEW_SECONDS = 5 * 60;
|
|
4
6
|
export function apiUrlFromConfig(cfg) {
|
|
5
|
-
return (
|
|
7
|
+
return (process.env.FLOOM_API_URL ?? cfg.apiUrl ?? DEFAULT_API_URL).replace(/\/$/, "");
|
|
6
8
|
}
|
|
7
9
|
export async function readConfig() {
|
|
8
10
|
try {
|
|
@@ -10,13 +12,16 @@ export async function readConfig() {
|
|
|
10
12
|
const parsed = JSON.parse(raw);
|
|
11
13
|
if (typeof parsed.accessToken !== "string" || parsed.accessToken.length === 0)
|
|
12
14
|
return null;
|
|
13
|
-
|
|
15
|
+
const cfg = {
|
|
14
16
|
accessToken: parsed.accessToken,
|
|
15
17
|
...(typeof parsed.apiUrl === "string" ? { apiUrl: parsed.apiUrl } : {}),
|
|
16
18
|
...(typeof parsed.refreshToken === "string" ? { refreshToken: parsed.refreshToken } : {}),
|
|
17
19
|
...(typeof parsed.expiresAt === "number" ? { expiresAt: parsed.expiresAt } : {}),
|
|
18
20
|
...(typeof parsed.email === "string" || parsed.email === null ? { email: parsed.email } : {}),
|
|
19
21
|
};
|
|
22
|
+
if (needsRefresh(cfg))
|
|
23
|
+
return refreshConfig(cfg);
|
|
24
|
+
return cfg;
|
|
20
25
|
}
|
|
21
26
|
catch (err) {
|
|
22
27
|
if (err.code === "ENOENT")
|
|
@@ -24,3 +29,53 @@ export async function readConfig() {
|
|
|
24
29
|
throw err;
|
|
25
30
|
}
|
|
26
31
|
}
|
|
32
|
+
export function needsRefresh(cfg) {
|
|
33
|
+
if (!cfg.refreshToken)
|
|
34
|
+
return false;
|
|
35
|
+
return !Number.isFinite(cfg.expiresAt) || Number(cfg.expiresAt) <= Math.floor(Date.now() / 1000) + REFRESH_SKEW_SECONDS;
|
|
36
|
+
}
|
|
37
|
+
export async function refreshConfig(cfg) {
|
|
38
|
+
if (!cfg.refreshToken)
|
|
39
|
+
return cfg;
|
|
40
|
+
const apiUrl = apiUrlFromConfig(cfg);
|
|
41
|
+
let res;
|
|
42
|
+
try {
|
|
43
|
+
res = await fetch(`${apiUrl}/api/v1/auth/refresh`, {
|
|
44
|
+
method: "POST",
|
|
45
|
+
headers: { "content-type": "application/json" },
|
|
46
|
+
body: JSON.stringify({ refresh_token: cfg.refreshToken }),
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
if (!res.ok)
|
|
53
|
+
return null;
|
|
54
|
+
let data;
|
|
55
|
+
try {
|
|
56
|
+
data = await res.json();
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
if (typeof data.access_token !== "string" || typeof data.refresh_token !== "string")
|
|
62
|
+
return null;
|
|
63
|
+
const expiresAt = typeof data.expires_at === "number" && Number.isFinite(data.expires_at)
|
|
64
|
+
? data.expires_at
|
|
65
|
+
: Math.floor(Date.now() / 1000) + (typeof data.expires_in === "number" && Number.isFinite(data.expires_in) ? data.expires_in : 3600);
|
|
66
|
+
const refreshed = {
|
|
67
|
+
apiUrl,
|
|
68
|
+
accessToken: data.access_token,
|
|
69
|
+
refreshToken: data.refresh_token,
|
|
70
|
+
expiresAt,
|
|
71
|
+
email: typeof data.email === "string" || data.email === null ? data.email : cfg.email ?? null,
|
|
72
|
+
};
|
|
73
|
+
await writeConfig(refreshed);
|
|
74
|
+
return refreshed;
|
|
75
|
+
}
|
|
76
|
+
async function writeConfig(cfg) {
|
|
77
|
+
const target = configPath();
|
|
78
|
+
await mkdir(dirname(target), { recursive: true, mode: 0o700 });
|
|
79
|
+
await writeFile(target, JSON.stringify(cfg, null, 2), { mode: 0o600 });
|
|
80
|
+
await chmod(target, 0o600);
|
|
81
|
+
}
|
package/dist/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.13";
|
|
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
|
|
33
|
+
Floom MCP server for Version 1 sync.
|
|
34
34
|
|
|
35
35
|
Usage
|
|
36
36
|
floom-mcp-sync
|
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.13",
|
|
4
|
+
"description": "Lightweight Floom MCP server for search, on-demand skill fetch, status, and sync.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
@@ -9,7 +9,12 @@
|
|
|
9
9
|
},
|
|
10
10
|
"files": [
|
|
11
11
|
"bin",
|
|
12
|
-
"dist",
|
|
12
|
+
"dist/auto-sync.js",
|
|
13
|
+
"dist/lib",
|
|
14
|
+
"dist/server.js",
|
|
15
|
+
"dist/tools/get.js",
|
|
16
|
+
"dist/tools/search.js",
|
|
17
|
+
"dist/tools/status.js",
|
|
13
18
|
"README.md",
|
|
14
19
|
"LICENSE"
|
|
15
20
|
],
|
package/dist/tools/install.js
DELETED
|
@@ -1,117 +0,0 @@
|
|
|
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
|
-
}
|
package/dist/tools/libraries.js
DELETED
|
@@ -1,28 +0,0 @@
|
|
|
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
|
-
}
|
package/dist/tools/publish.js
DELETED
|
@@ -1,24 +0,0 @@
|
|
|
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
|
-
}
|