@floomhq/floom 1.0.14 → 1.0.16
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 +36 -30
- package/dist/cli.js +127 -233
- package/dist/doctor.js +119 -38
- package/dist/errors.js +1 -1
- package/dist/info.js +1 -1
- package/dist/init.js +87 -92
- package/dist/install.js +140 -67
- package/dist/library.js +4 -8
- package/dist/list.js +7 -8
- package/dist/login.js +81 -46
- package/dist/mcp.js +4 -7
- package/dist/package.js +313 -0
- package/dist/publish.js +51 -51
- package/dist/scan.js +18 -23
- package/dist/secrets.js +3 -29
- package/dist/setup.js +12 -14
- package/dist/sync-manifest.js +65 -16
- package/dist/sync.js +216 -172
- package/package.json +3 -2
- package/dist/targets.js +0 -16
package/dist/sync-manifest.js
CHANGED
|
@@ -1,16 +1,14 @@
|
|
|
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 { join, relative, resolve, sep } from "node:path";
|
|
4
4
|
import { CONFIG_DIR } from "./config.js";
|
|
5
5
|
const MANIFEST_VERSION = 1;
|
|
6
|
+
const MANIFEST_PATH = join(CONFIG_DIR, "sync-manifest.json");
|
|
7
|
+
const LOCK_PATH = join(CONFIG_DIR, "sync.lock");
|
|
8
|
+
const LOCK_TIMEOUT_MS = 15_000;
|
|
9
|
+
const LOCK_STALE_MS = 5 * 60_000;
|
|
6
10
|
const SLUG_RE = /^[A-Za-z0-9_-]{1,128}$/;
|
|
7
11
|
const FD_PATH_ROOT = "/proc/self/fd";
|
|
8
|
-
function manifestFilename(scope = "claude") {
|
|
9
|
-
return scope === "claude" ? "sync-manifest.json" : `sync-manifest.${scope}.json`;
|
|
10
|
-
}
|
|
11
|
-
function manifestPath(scope = "claude") {
|
|
12
|
-
return join(CONFIG_DIR, manifestFilename(scope));
|
|
13
|
-
}
|
|
14
12
|
function emptyManifest() {
|
|
15
13
|
return { version: MANIFEST_VERSION, files: {} };
|
|
16
14
|
}
|
|
@@ -18,18 +16,34 @@ function isEntryForKey(key, value) {
|
|
|
18
16
|
if (!value || typeof value !== "object")
|
|
19
17
|
return false;
|
|
20
18
|
const entry = value;
|
|
21
|
-
|
|
19
|
+
if (typeof entry.hash === "string" &&
|
|
22
20
|
typeof entry.slug === "string" &&
|
|
23
21
|
typeof entry.target === "string" &&
|
|
24
22
|
typeof entry.syncedAt === "string" &&
|
|
25
23
|
entry.target === key &&
|
|
26
|
-
SLUG_RE.test(entry.slug)
|
|
27
|
-
key.split("/")
|
|
24
|
+
SLUG_RE.test(entry.slug)) {
|
|
25
|
+
const segments = key.split("/");
|
|
26
|
+
const legacyFile = segments.at(-1) === `${entry.slug}.md`;
|
|
27
|
+
const slugIndex = segments.lastIndexOf(entry.slug);
|
|
28
|
+
const packagePath = slugIndex >= 0 ? segments.slice(slugIndex + 1) : [];
|
|
29
|
+
return legacyFile || 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);
|
|
28
42
|
}
|
|
29
|
-
export async function readSyncManifest(
|
|
43
|
+
export async function readSyncManifest() {
|
|
30
44
|
try {
|
|
31
45
|
await ensureSyncManifestDir();
|
|
32
|
-
const handle = await open(
|
|
46
|
+
const handle = await open(MANIFEST_PATH, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
33
47
|
let body;
|
|
34
48
|
try {
|
|
35
49
|
body = await handle.readFile("utf8");
|
|
@@ -56,11 +70,10 @@ export async function readSyncManifest(scope = "claude") {
|
|
|
56
70
|
throw err;
|
|
57
71
|
}
|
|
58
72
|
}
|
|
59
|
-
export async function writeSyncManifest(manifest
|
|
73
|
+
export async function writeSyncManifest(manifest) {
|
|
60
74
|
await ensureSyncManifestDir();
|
|
61
75
|
const dir = await open(CONFIG_DIR, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
|
62
|
-
const
|
|
63
|
-
const tmpBase = `${filename}.${process.pid}.${Date.now()}`;
|
|
76
|
+
const tmpBase = `sync-manifest.json.${process.pid}.${Date.now()}`;
|
|
64
77
|
const body = JSON.stringify(manifest, null, 2);
|
|
65
78
|
try {
|
|
66
79
|
for (let attempt = 0; attempt < 10; attempt += 1) {
|
|
@@ -72,7 +85,7 @@ export async function writeSyncManifest(manifest, scope = "claude") {
|
|
|
72
85
|
await handle.writeFile(body, "utf8");
|
|
73
86
|
await handle.close();
|
|
74
87
|
handle = null;
|
|
75
|
-
await rename(tmpPath, childPath(dir, CONFIG_DIR,
|
|
88
|
+
await rename(tmpPath, childPath(dir, CONFIG_DIR, "sync-manifest.json"));
|
|
76
89
|
return;
|
|
77
90
|
}
|
|
78
91
|
catch (err) {
|
|
@@ -109,6 +122,42 @@ export async function ensureSyncManifestDir() {
|
|
|
109
122
|
throw err;
|
|
110
123
|
}
|
|
111
124
|
}
|
|
125
|
+
export async function withSyncLock(fn) {
|
|
126
|
+
await ensureSyncManifestDir();
|
|
127
|
+
const startedAt = Date.now();
|
|
128
|
+
for (;;) {
|
|
129
|
+
try {
|
|
130
|
+
await mkdir(LOCK_PATH, { mode: 0o700 });
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
catch (err) {
|
|
134
|
+
if (err.code !== "EEXIST")
|
|
135
|
+
throw err;
|
|
136
|
+
try {
|
|
137
|
+
const lockStat = await stat(LOCK_PATH);
|
|
138
|
+
if (Date.now() - lockStat.mtimeMs > LOCK_STALE_MS) {
|
|
139
|
+
await rm(LOCK_PATH, { recursive: true, force: true });
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
catch (statErr) {
|
|
144
|
+
if (statErr.code === "ENOENT")
|
|
145
|
+
continue;
|
|
146
|
+
throw statErr;
|
|
147
|
+
}
|
|
148
|
+
if (Date.now() - startedAt > LOCK_TIMEOUT_MS) {
|
|
149
|
+
throw new Error("Timed out waiting for Floom sync lock.");
|
|
150
|
+
}
|
|
151
|
+
await new Promise((resolveDelay) => setTimeout(resolveDelay, 50));
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
try {
|
|
155
|
+
return await fn();
|
|
156
|
+
}
|
|
157
|
+
finally {
|
|
158
|
+
await rm(LOCK_PATH, { recursive: true, force: true }).catch(() => { });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
112
161
|
export function manifestKey(root, target) {
|
|
113
162
|
const relativeTarget = relative(resolve(root), resolve(target));
|
|
114
163
|
if (relativeTarget === ".." || relativeTarget.startsWith(`..${sep}`)) {
|
package/dist/sync.js
CHANGED
|
@@ -1,18 +1,26 @@
|
|
|
1
1
|
import { constants } from "node:fs";
|
|
2
2
|
import { lstat, mkdir, open } from "node:fs/promises";
|
|
3
3
|
import { createHash } from "node:crypto";
|
|
4
|
+
import { homedir } from "node:os";
|
|
4
5
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
6
|
import ora from "ora";
|
|
6
7
|
import { readConfig, resolveApiUrl } from "./config.js";
|
|
7
8
|
import { getJson } from "./lib/api.js";
|
|
8
9
|
import { c, symbols } from "./ui.js";
|
|
9
10
|
import { FloomError } from "./errors.js";
|
|
10
|
-
import { ensureSyncManifestDir, manifestKey, markSynced, readSyncManifest, unmarkSynced, writeSyncManifest } from "./sync-manifest.js";
|
|
11
|
-
import {
|
|
11
|
+
import { ensureSyncManifestDir, manifestKey, markSynced, readSyncManifest, unmarkSynced, withSyncLock, writeSyncManifest } from "./sync-manifest.js";
|
|
12
|
+
import { normalizeRemotePackageFiles } from "./package.js";
|
|
12
13
|
const SLUG_RE = /^[A-Za-z0-9_-]{1,128}$/;
|
|
13
14
|
const PATH_SEGMENT_RE = /^[a-z0-9._-]{1,128}$/;
|
|
14
15
|
const MANIFEST_SEGMENT_RE = /^[A-Za-z0-9._-]{1,128}$/;
|
|
15
16
|
const FD_PATH_ROOT = "/proc/self/fd";
|
|
17
|
+
function skillsDir(target = "claude") {
|
|
18
|
+
if (target === "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");
|
|
23
|
+
}
|
|
16
24
|
function sha256(input) {
|
|
17
25
|
return createHash("sha256").update(input).digest("hex");
|
|
18
26
|
}
|
|
@@ -24,7 +32,7 @@ async function localState(path) {
|
|
|
24
32
|
if (!stat.isFile()) {
|
|
25
33
|
return { kind: "conflict", reason: "path is blocked by an existing local file or directory" };
|
|
26
34
|
}
|
|
27
|
-
return { kind: "file", hash: sha256(await handle.readFile(
|
|
35
|
+
return { kind: "file", hash: sha256(await handle.readFile()) };
|
|
28
36
|
}
|
|
29
37
|
finally {
|
|
30
38
|
await handle.close();
|
|
@@ -53,13 +61,14 @@ function safePathSegments(value, label) {
|
|
|
53
61
|
}
|
|
54
62
|
return segments;
|
|
55
63
|
}
|
|
56
|
-
function skillPath(
|
|
64
|
+
function skillPath(skill, targetAgent) {
|
|
57
65
|
if (!SLUG_RE.test(skill.slug))
|
|
58
66
|
throw new FloomError(`Invalid skill slug: ${skill.slug}`);
|
|
67
|
+
const root = skillsDir(targetAgent);
|
|
59
68
|
const segments = [root];
|
|
60
69
|
segments.push(...safePathSegments(skill.library_slug, "library slug"));
|
|
61
70
|
segments.push(...safePathSegments(skill.folder, "folder"));
|
|
62
|
-
segments.push(
|
|
71
|
+
segments.push(skill.slug, "SKILL.md");
|
|
63
72
|
const target = join(...segments);
|
|
64
73
|
const relativeTarget = relative(resolve(root), resolve(target));
|
|
65
74
|
if (relativeTarget === ".." || relativeTarget.startsWith(`..${sep}`) || isAbsolute(relativeTarget)) {
|
|
@@ -128,7 +137,7 @@ function childCreatePath(parent, fallbackParent, name) {
|
|
|
128
137
|
return join(resolve(fallbackParent), name);
|
|
129
138
|
}
|
|
130
139
|
async function writeAll(handle, body) {
|
|
131
|
-
const buffer = Buffer.from(body, "utf8");
|
|
140
|
+
const buffer = Buffer.isBuffer(body) ? body : Buffer.from(body, "utf8");
|
|
132
141
|
let offset = 0;
|
|
133
142
|
while (offset < buffer.length) {
|
|
134
143
|
const result = await handle.write(buffer, offset, buffer.length - offset, offset);
|
|
@@ -137,6 +146,63 @@ async function writeAll(handle, body) {
|
|
|
137
146
|
offset += result.bytesWritten;
|
|
138
147
|
}
|
|
139
148
|
}
|
|
149
|
+
function syncPackageFiles(target, body, files) {
|
|
150
|
+
return [
|
|
151
|
+
{ target, bytes: body, hash: sha256(body) },
|
|
152
|
+
...files.map((file) => ({
|
|
153
|
+
target: join(dirname(target), file.path),
|
|
154
|
+
bytes: file.bytes,
|
|
155
|
+
hash: file.sha256,
|
|
156
|
+
})),
|
|
157
|
+
];
|
|
158
|
+
}
|
|
159
|
+
async function planPackageSync(root, files, manifest) {
|
|
160
|
+
let missing = 0;
|
|
161
|
+
let unchanged = 0;
|
|
162
|
+
for (const file of files) {
|
|
163
|
+
const targetKey = manifestKey(root, file.target);
|
|
164
|
+
const tracked = manifest.files[targetKey];
|
|
165
|
+
try {
|
|
166
|
+
await assertSafeExistingParentDirectory(root, file.target);
|
|
167
|
+
}
|
|
168
|
+
catch (err) {
|
|
169
|
+
const code = err.code;
|
|
170
|
+
if (code === "ELOOP")
|
|
171
|
+
return { kind: "conflict", target: file.target, reason: "path contains a symbolic link" };
|
|
172
|
+
if (code === "ENOTDIR" || code === "EISDIR")
|
|
173
|
+
return { kind: "conflict", target: file.target, reason: "path is blocked by an existing local file or directory" };
|
|
174
|
+
if (code === "EEXIST" || code === "ENOENT")
|
|
175
|
+
return { kind: "conflict", target: file.target, reason: err instanceof Error ? err.message : "local file changed during Floom sync" };
|
|
176
|
+
throw err;
|
|
177
|
+
}
|
|
178
|
+
const state = await localState(file.target);
|
|
179
|
+
if (state.kind === "conflict")
|
|
180
|
+
return { kind: "conflict", target: state.conflictTarget ?? file.target, reason: state.reason };
|
|
181
|
+
if (state.kind === "missing") {
|
|
182
|
+
if (tracked && files.length > 1)
|
|
183
|
+
return { kind: "conflict", target: file.target, reason: "local package file missing since the last Floom sync" };
|
|
184
|
+
missing += 1;
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if (!tracked)
|
|
188
|
+
return { kind: "conflict", target: file.target, reason: "existing file is not tracked by Floom sync" };
|
|
189
|
+
if (state.hash !== tracked.hash)
|
|
190
|
+
return { kind: "conflict", target: file.target, reason: "local file changed since the last Floom sync" };
|
|
191
|
+
if (state.hash !== file.hash)
|
|
192
|
+
return { kind: "conflict", target: file.target, reason: "remote skill changed; move or delete the local file to accept the Floom version" };
|
|
193
|
+
unchanged += 1;
|
|
194
|
+
}
|
|
195
|
+
if (unchanged === files.length)
|
|
196
|
+
return { kind: "unchanged" };
|
|
197
|
+
if (missing === files.length)
|
|
198
|
+
return { kind: "write" };
|
|
199
|
+
const missingFile = files.find((file) => !manifest.files[manifestKey(root, file.target)]);
|
|
200
|
+
return {
|
|
201
|
+
kind: "conflict",
|
|
202
|
+
target: missingFile?.target ?? files[0]?.target ?? root,
|
|
203
|
+
reason: "local package is only partially installed",
|
|
204
|
+
};
|
|
205
|
+
}
|
|
140
206
|
async function ensureSafeParentDirectory(root, target) {
|
|
141
207
|
const resolvedRoot = resolve(root);
|
|
142
208
|
const resolvedParent = resolve(dirname(target));
|
|
@@ -199,14 +265,13 @@ function conflictError(message, code) {
|
|
|
199
265
|
return err;
|
|
200
266
|
}
|
|
201
267
|
export async function sync(opts = {}) {
|
|
268
|
+
const targetAgent = opts.target ?? "claude";
|
|
202
269
|
const cfg = await readConfig();
|
|
203
270
|
if (!cfg)
|
|
204
271
|
throw new FloomError("Not signed in.", "Run `npx -y @floomhq/floom login` first.");
|
|
205
|
-
const targetAgent = opts.target ?? "claude";
|
|
206
|
-
const root = resolveSkillsDir(targetAgent);
|
|
207
272
|
await ensureSyncManifestDir();
|
|
208
273
|
const apiUrl = resolveApiUrl(cfg);
|
|
209
|
-
const spinner = opts.spinner === false ? null : ora({ text: c.dim(
|
|
274
|
+
const spinner = opts.spinner === false ? null : ora({ text: c.dim("Syncing skills..."), color: "yellow" }).start();
|
|
210
275
|
let payload;
|
|
211
276
|
try {
|
|
212
277
|
payload = await getJson(`${apiUrl}/api/v1/me/skills`, "load your skills", cfg.accessToken);
|
|
@@ -215,186 +280,165 @@ export async function sync(opts = {}) {
|
|
|
215
280
|
spinner?.stop();
|
|
216
281
|
throw err;
|
|
217
282
|
}
|
|
218
|
-
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
219
|
-
if (!Array.isArray(payload.skills)) {
|
|
220
|
-
throw new FloomError("Invalid sync response.");
|
|
221
|
-
}
|
|
222
|
-
for (const skill of payload.skills)
|
|
223
|
-
validateSyncSkillShape(skill);
|
|
224
|
-
// Version 1 preview syncs published, saved, and followed library skills.
|
|
225
|
-
const all = payload.skills;
|
|
226
|
-
const seen = new Set();
|
|
227
|
-
let unchanged = 0;
|
|
228
|
-
let updated = 0;
|
|
229
|
-
let skipped = 0;
|
|
230
|
-
let conflicts = 0;
|
|
231
|
-
const conflictNotes = [];
|
|
232
|
-
const manifest = await readSyncManifest(targetAgent);
|
|
233
|
-
const activeTargetKeys = new Set();
|
|
234
|
-
const pruneBlockedSlugs = new Set();
|
|
235
|
-
let manifestChanged = false;
|
|
236
|
-
const noteConflict = (target, reason) => {
|
|
237
|
-
conflicts += 1;
|
|
238
|
-
const rel = manifestKey(root, target);
|
|
239
|
-
conflictNotes.push(`${rel} (${reason})`);
|
|
240
|
-
};
|
|
241
|
-
const noteManifestConflict = (key, reason) => {
|
|
242
|
-
conflicts += 1;
|
|
243
|
-
conflictNotes.push(`${key} (${reason})`);
|
|
244
|
-
};
|
|
245
283
|
try {
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
if (
|
|
249
|
-
|
|
250
|
-
seen.add(key);
|
|
251
|
-
if (!SLUG_RE.test(skill.slug)) {
|
|
252
|
-
skipped += 1;
|
|
253
|
-
continue;
|
|
254
|
-
}
|
|
255
|
-
let target;
|
|
256
|
-
try {
|
|
257
|
-
target = skillPath(root, skill);
|
|
258
|
-
}
|
|
259
|
-
catch (err) {
|
|
260
|
-
if (err instanceof FloomError) {
|
|
261
|
-
pruneBlockedSlugs.add(skill.slug);
|
|
262
|
-
skipped += 1;
|
|
263
|
-
continue;
|
|
264
|
-
}
|
|
265
|
-
throw err;
|
|
266
|
-
}
|
|
267
|
-
const targetKey = manifestKey(root, target);
|
|
268
|
-
activeTargetKeys.add(targetKey);
|
|
269
|
-
const remoteHash = sha256(skill.body_md);
|
|
270
|
-
const tracked = manifest.files[targetKey];
|
|
271
|
-
try {
|
|
272
|
-
await assertSafeExistingParentDirectory(root, target);
|
|
273
|
-
}
|
|
274
|
-
catch (err) {
|
|
275
|
-
const code = err.code;
|
|
276
|
-
if (code === "ELOOP") {
|
|
277
|
-
noteConflict(target, "path contains a symbolic link");
|
|
278
|
-
continue;
|
|
279
|
-
}
|
|
280
|
-
if (code === "ENOTDIR" || code === "EISDIR") {
|
|
281
|
-
noteConflict(target, "path is blocked by an existing local file or directory");
|
|
282
|
-
continue;
|
|
283
|
-
}
|
|
284
|
-
if (code === "EEXIST" || code === "ENOENT") {
|
|
285
|
-
noteConflict(target, err instanceof Error ? err.message : "local file changed during Floom sync");
|
|
286
|
-
continue;
|
|
287
|
-
}
|
|
288
|
-
throw err;
|
|
289
|
-
}
|
|
290
|
-
const state = await localState(target);
|
|
291
|
-
if (state.kind === "conflict") {
|
|
292
|
-
noteConflict(target, state.reason);
|
|
293
|
-
continue;
|
|
294
|
-
}
|
|
295
|
-
if (state.kind === "file" && !tracked) {
|
|
296
|
-
noteConflict(target, "existing file is not tracked by Floom sync");
|
|
297
|
-
continue;
|
|
298
|
-
}
|
|
299
|
-
if (state.kind === "file" && state.hash !== tracked?.hash) {
|
|
300
|
-
noteConflict(target, "local file changed since the last Floom sync");
|
|
301
|
-
continue;
|
|
302
|
-
}
|
|
303
|
-
if (state.kind === "file" && state.hash === remoteHash) {
|
|
304
|
-
unchanged += 1;
|
|
305
|
-
continue;
|
|
306
|
-
}
|
|
307
|
-
if (state.kind === "file") {
|
|
308
|
-
noteConflict(target, "remote skill changed; move or delete the local file to accept the Floom version");
|
|
309
|
-
continue;
|
|
284
|
+
return await withSyncLock(async () => {
|
|
285
|
+
await mkdir(skillsDir(targetAgent), { recursive: true, mode: 0o700 });
|
|
286
|
+
if (!Array.isArray(payload.skills)) {
|
|
287
|
+
throw new FloomError("Invalid sync response.");
|
|
310
288
|
}
|
|
289
|
+
for (const skill of payload.skills)
|
|
290
|
+
validateSyncSkillShape(skill);
|
|
291
|
+
// Version 1 preview syncs published, saved, and subscribed library skills.
|
|
292
|
+
const all = payload.skills;
|
|
293
|
+
const seen = new Set();
|
|
294
|
+
let unchanged = 0;
|
|
295
|
+
let updated = 0;
|
|
296
|
+
let skipped = 0;
|
|
297
|
+
let conflicts = 0;
|
|
298
|
+
const conflictNotes = [];
|
|
299
|
+
const manifest = await readSyncManifest();
|
|
300
|
+
const root = skillsDir(targetAgent);
|
|
301
|
+
const activeTargetKeys = new Set();
|
|
302
|
+
const pruneBlockedSlugs = new Set();
|
|
303
|
+
let manifestChanged = false;
|
|
304
|
+
const noteConflict = (target, reason) => {
|
|
305
|
+
conflicts += 1;
|
|
306
|
+
const rel = manifestKey(root, target);
|
|
307
|
+
conflictNotes.push(`${rel} (${reason})`);
|
|
308
|
+
};
|
|
309
|
+
const noteManifestConflict = (key, reason) => {
|
|
310
|
+
conflicts += 1;
|
|
311
|
+
conflictNotes.push(`${key} (${reason})`);
|
|
312
|
+
};
|
|
311
313
|
try {
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
}
|
|
320
|
-
if (code === "ENOTDIR" || code === "EISDIR") {
|
|
321
|
-
noteConflict(target, "path is blocked by an existing local file or directory");
|
|
322
|
-
continue;
|
|
323
|
-
}
|
|
324
|
-
throw err;
|
|
325
|
-
}
|
|
326
|
-
markSynced(manifest, targetKey, skill.slug, remoteHash);
|
|
327
|
-
await writeSyncManifest(manifest, targetAgent);
|
|
328
|
-
updated += 1;
|
|
329
|
-
}
|
|
330
|
-
if (payload.full_sync === true) {
|
|
331
|
-
for (const [key, entry] of Object.entries(manifest.files)) {
|
|
332
|
-
if (activeTargetKeys.has(key))
|
|
333
|
-
continue;
|
|
334
|
-
if (pruneBlockedSlugs.has(entry.slug)) {
|
|
335
|
-
noteManifestConflict(key, "remote metadata is invalid for this skill");
|
|
336
|
-
continue;
|
|
337
|
-
}
|
|
338
|
-
let target;
|
|
339
|
-
try {
|
|
340
|
-
target = targetFromManifestKey(root, key);
|
|
341
|
-
await assertSafeExistingParentDirectory(root, target);
|
|
342
|
-
}
|
|
343
|
-
catch (err) {
|
|
344
|
-
if (err instanceof FloomError) {
|
|
345
|
-
noteManifestConflict(key, "invalid manifest target path");
|
|
314
|
+
for (const skill of all) {
|
|
315
|
+
const key = syncKey(skill);
|
|
316
|
+
if (seen.has(key))
|
|
317
|
+
continue;
|
|
318
|
+
seen.add(key);
|
|
319
|
+
if (!SLUG_RE.test(skill.slug)) {
|
|
320
|
+
skipped += 1;
|
|
346
321
|
continue;
|
|
347
322
|
}
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
323
|
+
let target;
|
|
324
|
+
try {
|
|
325
|
+
target = skillPath(skill, targetAgent);
|
|
326
|
+
}
|
|
327
|
+
catch (err) {
|
|
328
|
+
if (err instanceof FloomError) {
|
|
329
|
+
pruneBlockedSlugs.add(skill.slug);
|
|
330
|
+
skipped += 1;
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
throw err;
|
|
334
|
+
}
|
|
335
|
+
const remotePackageFiles = normalizeRemotePackageFiles(skill.package_files ?? skill.files);
|
|
336
|
+
const packageFiles = syncPackageFiles(target, skill.body_md, remotePackageFiles);
|
|
337
|
+
for (const file of packageFiles)
|
|
338
|
+
activeTargetKeys.add(manifestKey(root, file.target));
|
|
339
|
+
const plan = await planPackageSync(root, packageFiles, manifest);
|
|
340
|
+
if (plan.kind === "conflict") {
|
|
341
|
+
noteConflict(plan.target, plan.reason);
|
|
351
342
|
continue;
|
|
352
343
|
}
|
|
353
|
-
if (
|
|
354
|
-
|
|
344
|
+
if (plan.kind === "unchanged") {
|
|
345
|
+
unchanged += 1;
|
|
355
346
|
continue;
|
|
356
347
|
}
|
|
357
|
-
|
|
348
|
+
try {
|
|
349
|
+
for (const file of packageFiles)
|
|
350
|
+
await writeSyncedFile(root, file.target, file.bytes);
|
|
351
|
+
}
|
|
352
|
+
catch (err) {
|
|
353
|
+
const code = err.code;
|
|
354
|
+
if (code === "ELOOP") {
|
|
355
|
+
noteConflict(target, "path contains a symbolic link");
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
if (code === "ENOTDIR" || code === "EISDIR") {
|
|
359
|
+
noteConflict(target, "path is blocked by an existing local file or directory");
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
throw err;
|
|
363
|
+
}
|
|
364
|
+
for (const file of packageFiles)
|
|
365
|
+
markSynced(manifest, manifestKey(root, file.target), skill.slug, file.hash);
|
|
366
|
+
await writeSyncManifest(manifest);
|
|
367
|
+
updated += 1;
|
|
358
368
|
}
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
369
|
+
if (payload.full_sync === true) {
|
|
370
|
+
for (const [key, entry] of Object.entries(manifest.files)) {
|
|
371
|
+
if (activeTargetKeys.has(key))
|
|
372
|
+
continue;
|
|
373
|
+
if (pruneBlockedSlugs.has(entry.slug)) {
|
|
374
|
+
noteManifestConflict(key, "remote metadata is invalid for this skill");
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
let target;
|
|
378
|
+
try {
|
|
379
|
+
target = targetFromManifestKey(root, key);
|
|
380
|
+
await assertSafeExistingParentDirectory(root, target);
|
|
381
|
+
}
|
|
382
|
+
catch (err) {
|
|
383
|
+
if (err instanceof FloomError) {
|
|
384
|
+
noteManifestConflict(key, "invalid manifest target path");
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
const code = err.code;
|
|
388
|
+
if (code === "ELOOP") {
|
|
389
|
+
noteManifestConflict(key, "path contains a symbolic link");
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
if (code === "ENOTDIR" || code === "EISDIR") {
|
|
393
|
+
noteManifestConflict(key, "path is blocked by an existing local file or directory");
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
throw err;
|
|
397
|
+
}
|
|
398
|
+
const state = await localState(target);
|
|
399
|
+
if (state.kind === "missing") {
|
|
400
|
+
unmarkSynced(manifest, key);
|
|
401
|
+
manifestChanged = true;
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
if (state.kind === "conflict") {
|
|
405
|
+
noteConflict(target, state.reason);
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
if (state.hash !== entry.hash) {
|
|
409
|
+
noteConflict(target, "local file changed since the last Floom sync");
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
unmarkSynced(manifest, key);
|
|
413
|
+
manifestChanged = true;
|
|
414
|
+
}
|
|
364
415
|
}
|
|
365
|
-
if (
|
|
366
|
-
|
|
367
|
-
|
|
416
|
+
if (manifestChanged)
|
|
417
|
+
await writeSyncManifest(manifest);
|
|
418
|
+
}
|
|
419
|
+
catch (err) {
|
|
420
|
+
spinner?.stop();
|
|
421
|
+
throw err;
|
|
422
|
+
}
|
|
423
|
+
spinner?.stop();
|
|
424
|
+
const synced = activeTargetKeys.size;
|
|
425
|
+
const skippedNote = skipped > 0 ? c.dim(` (${skipped} skipped — invalid path)`) : "";
|
|
426
|
+
const conflictNote = conflicts > 0 ? c.dim(`, ${conflicts} conflict${conflicts === 1 ? "" : "s"} skipped`) : "";
|
|
427
|
+
const result = { synced, unchanged, updated, skipped, conflicts };
|
|
428
|
+
if (!(opts.quietUnchanged && updated === 0 && skipped === 0 && conflicts === 0)) {
|
|
429
|
+
for (const note of conflictNotes) {
|
|
430
|
+
process.stderr.write(`${symbols.bullet} [floom] skipped local conflict: ${note}\n`);
|
|
368
431
|
}
|
|
369
|
-
if (
|
|
370
|
-
|
|
371
|
-
continue;
|
|
432
|
+
if (conflicts > 0) {
|
|
433
|
+
process.stderr.write(` ${c.dim("Move or delete the local file, then run `npx -y @floomhq/floom sync` again.")}\n`);
|
|
372
434
|
}
|
|
373
|
-
|
|
374
|
-
manifestChanged = true;
|
|
435
|
+
process.stdout.write(`\n${symbols.ok} [floom] synced ${synced} skills (${unchanged} unchanged, ${updated} updated${conflictNote})${skippedNote}\n\n`);
|
|
375
436
|
}
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
await writeSyncManifest(manifest, targetAgent);
|
|
437
|
+
return result;
|
|
438
|
+
});
|
|
379
439
|
}
|
|
380
440
|
catch (err) {
|
|
381
441
|
spinner?.stop();
|
|
382
442
|
throw err;
|
|
383
443
|
}
|
|
384
|
-
spinner?.stop();
|
|
385
|
-
const synced = activeTargetKeys.size;
|
|
386
|
-
const skippedNote = skipped > 0 ? c.dim(` (${skipped} skipped — invalid path)`) : "";
|
|
387
|
-
const conflictNote = conflicts > 0 ? c.dim(`, ${conflicts} conflict${conflicts === 1 ? "" : "s"} skipped`) : "";
|
|
388
|
-
const result = { synced, unchanged, updated, skipped, conflicts };
|
|
389
|
-
if (!(opts.quietUnchanged && updated === 0 && skipped === 0 && conflicts === 0)) {
|
|
390
|
-
for (const note of conflictNotes) {
|
|
391
|
-
process.stderr.write(`${symbols.bullet} [floom] skipped local conflict: ${note}\n`);
|
|
392
|
-
}
|
|
393
|
-
if (conflicts > 0) {
|
|
394
|
-
const targetFlag = targetAgent === "claude" ? "" : ` --target ${targetAgent}`;
|
|
395
|
-
process.stderr.write(` ${c.dim(`Move or delete the local file, then run \`npx -y @floomhq/floom sync${targetFlag}\` again.`)}\n`);
|
|
396
|
-
}
|
|
397
|
-
process.stdout.write(`\n${symbols.ok} [floom] synced ${synced} skills (${unchanged} unchanged, ${updated} updated${conflictNote})${skippedNote}\n\n`);
|
|
398
|
-
}
|
|
399
|
-
return result;
|
|
400
444
|
}
|
package/package.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@floomhq/floom",
|
|
3
|
-
"version": "1.0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.0.16",
|
|
4
|
+
"description": "Sync AI skills across agents and machines.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
8
|
+
"floom": "bin/floom.js",
|
|
8
9
|
"floom-skills": "bin/floom.js"
|
|
9
10
|
},
|
|
10
11
|
"files": [
|
package/dist/targets.js
DELETED
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
import { homedir } from "node:os";
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
-
export function resolveSkillsDir(target) {
|
|
4
|
-
if (process.env.FLOOM_SKILLS_DIR)
|
|
5
|
-
return process.env.FLOOM_SKILLS_DIR;
|
|
6
|
-
if (target === "codex") {
|
|
7
|
-
const codexHome = process.env.CODEX_HOME ?? join(homedir(), ".codex");
|
|
8
|
-
return process.env.CODEX_SKILLS_DIR ?? join(codexHome, "skills");
|
|
9
|
-
}
|
|
10
|
-
return process.env.CLAUDE_SKILLS_DIR ?? join(homedir(), ".claude", "skills");
|
|
11
|
-
}
|
|
12
|
-
export function skillsDirHint(target) {
|
|
13
|
-
if (process.env.FLOOM_SKILLS_DIR)
|
|
14
|
-
return "FLOOM_SKILLS_DIR";
|
|
15
|
-
return target === "codex" ? "CODEX_SKILLS_DIR" : "CLAUDE_SKILLS_DIR";
|
|
16
|
-
}
|