@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.
@@ -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
- return (typeof entry.hash === "string" &&
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("/").at(-1) === `${entry.slug}.md`);
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(scope = "claude") {
43
+ export async function readSyncManifest() {
30
44
  try {
31
45
  await ensureSyncManifestDir();
32
- const handle = await open(manifestPath(scope), constants.O_RDONLY | constants.O_NOFOLLOW);
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, scope = "claude") {
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 filename = manifestFilename(scope);
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, filename));
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 { resolveSkillsDir } from "./targets.js";
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("utf8")) };
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(root, skill) {
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(`${skill.slug}.md`);
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(`Syncing ${targetAgent} skills...`), color: "yellow" }).start();
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
- for (const skill of all) {
247
- const key = syncKey(skill);
248
- if (seen.has(key))
249
- continue;
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
- await writeSyncedFile(root, target, skill.body_md);
313
- }
314
- catch (err) {
315
- const code = err.code;
316
- if (code === "ELOOP") {
317
- noteConflict(target, "path contains a symbolic link");
318
- continue;
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
- const code = err.code;
349
- if (code === "ELOOP") {
350
- noteManifestConflict(key, "path contains a symbolic link");
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 (code === "ENOTDIR" || code === "EISDIR") {
354
- noteManifestConflict(key, "path is blocked by an existing local file or directory");
344
+ if (plan.kind === "unchanged") {
345
+ unchanged += 1;
355
346
  continue;
356
347
  }
357
- throw err;
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
- const state = await localState(target);
360
- if (state.kind === "missing") {
361
- unmarkSynced(manifest, key);
362
- manifestChanged = true;
363
- continue;
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 (state.kind === "conflict") {
366
- noteConflict(target, state.reason);
367
- continue;
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 (state.hash !== entry.hash) {
370
- noteConflict(target, "local file changed since the last Floom sync");
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
- unmarkSynced(manifest, key);
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
- if (manifestChanged)
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.14",
4
- "description": "Publish AI skills from your terminal. Share with a link.",
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
- }