@floomhq/floom 1.0.18 → 1.0.20

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/dist/cli.js CHANGED
@@ -71,7 +71,9 @@ function commandUsage() {
71
71
  ${c.dim("Skills")}
72
72
  ${c.cyan("add")} ${c.dim("<url>")} Install a skill into the local agent skills folder
73
73
  ${c.dim("Alias: install")}
74
- ${c.dim("Flags: --target claude|codex (default: claude), --setup, --force")}
74
+ ${c.dim("Flags: --target claude|codex (default: claude), --setup, --force, --json")}
75
+ ${c.cyan("update")} ${c.dim("<url>")} Refresh or migrate a local skill
76
+ ${c.dim("Flags: --target claude|codex (default: claude), --json")}
75
77
  ${c.cyan("search")} ${c.dim("<query>")} Find public skills and libraries
76
78
  ${c.cyan("info")} ${c.dim("<url>")} Show skill metadata
77
79
 
@@ -98,7 +100,7 @@ function commandUsage() {
98
100
  ${c.dim("Alias: connect")}
99
101
  ${c.dim("Flags: --target claude|codex, --yes, --dry-run")}
100
102
  ${c.cyan("doctor")} Troubleshoot auth, API, and local folders
101
- ${c.dim("Flags: --target claude|codex (default: claude)")}
103
+ ${c.dim("Flags: --target claude|codex (default: claude), --json")}
102
104
 
103
105
  ${c.dim("Advanced")}
104
106
  ${c.cyan("library")} Create, browse, and subscribe to libraries
@@ -108,6 +110,10 @@ function commandUsage() {
108
110
  ${c.cyan("sync")} Preview pull of published, saved, and library skills
109
111
  ${c.dim("Flags: --target claude|codex (default: claude)")}
110
112
  ${c.cyan("watch")} Preview polling sync loop
113
+ ${c.dim("Flags: --target claude|codex (default: claude), --interval <seconds>")}
114
+ ${c.cyan("agent-prompt")} Print the one-line agent instruction
115
+ ${c.dim("Alias: paste")}
116
+ ${c.dim("Flags: --target claude|codex (default: claude)")}
111
117
 
112
118
  ${c.bold("Examples")}
113
119
  ${c.cyan("npx -y @floomhq/floom add")} ${c.dim("https://floom.dev/s/ffas93ud --setup")}
@@ -123,6 +129,112 @@ function commandUsage() {
123
129
  `;
124
130
  process.stdout.write(out);
125
131
  }
132
+ function subcommandUsage(cmd) {
133
+ const key = cmd === "install" ? "add" : cmd === "rm" ? "delete" : cmd === "connect" ? "setup" : cmd === "paste" ? "agent-prompt" : cmd;
134
+ const usageByCommand = {
135
+ add: `${c.bold("Usage:")} ${c.cyan(`${CLI_COMMAND} add`)} ${c.dim("<url-or-slug> [flags]")}
136
+
137
+ Install a Floom skill into a local agent skills folder.
138
+
139
+ ${c.bold("Flags")}
140
+ ${c.cyan("--target")} ${c.dim("claude|codex")} Install for Claude Code or Codex. Default: claude.
141
+ ${c.cyan("--setup")} Add Floom guidance to the matching agent instructions file.
142
+ ${c.cyan("--force")} Replace the local copy when remote content differs.
143
+ ${c.cyan("--json")} Print machine-readable install output.
144
+
145
+ ${c.bold("Examples")}
146
+ ${c.cyan(`${CLI_COMMAND} add https://floom.dev/s/ffas93ud --setup`)}
147
+ ${c.cyan(`${CLI_COMMAND} add ffas93ud --target codex --json`)}
148
+ `,
149
+ update: `${c.bold("Usage:")} ${c.cyan(`${CLI_COMMAND} update`)} ${c.dim("<url-or-slug> [flags]")}
150
+
151
+ Refresh or migrate one local skill through the same installer as add.
152
+
153
+ ${c.bold("Flags")}
154
+ ${c.cyan("--target")} ${c.dim("claude|codex")} Update Claude Code or Codex local skills. Default: claude.
155
+ ${c.cyan("--setup")} Also refresh agent setup instructions.
156
+ ${c.cyan("--json")} Print machine-readable install output.
157
+ `,
158
+ doctor: `${c.bold("Usage:")} ${c.cyan(`${CLI_COMMAND} doctor`)} ${c.dim("[flags]")}
159
+
160
+ Diagnose auth, API reachability, PATH collisions, MCP setup, and local skills folders.
161
+
162
+ ${c.bold("Flags")}
163
+ ${c.cyan("--target")} ${c.dim("claude|codex")} Check Claude Code or Codex paths. Default: claude.
164
+ ${c.cyan("--json")} Print structured checks for scripts.
165
+ `,
166
+ publish: `${c.bold("Usage:")} ${c.cyan(`${CLI_COMMAND} publish`)} ${c.dim("<path> [flags]")}
167
+
168
+ Scan and publish a Markdown skill file or skill folder. Prints a Floom share URL.
169
+
170
+ ${c.bold("Flags")}
171
+ ${c.cyan("--public")} | ${c.cyan("--private")} | ${c.cyan("--unlisted")} Set visibility. Default: unlisted.
172
+ ${c.cyan("--type")} ${c.dim("knowledge|instruction|workflow|skill")}
173
+ ${c.cyan("--installs-as")} ${c.dim("claude_skill|memory|rule|codex_instruction|opencode_instruction|cursor_rule|other")}
174
+ ${c.cyan("--skill-version")} ${c.dim("<label>")}
175
+ `,
176
+ init: `${c.bold("Usage:")} ${c.cyan(`${CLI_COMMAND} init`)} ${c.dim("[path] [flags]")}
177
+
178
+ Create a starter skill folder with SKILL.md.
179
+
180
+ ${c.bold("Flags")}
181
+ ${c.cyan("--template")} ${c.dim(`generic|${INIT_TEMPLATES.filter((t) => t !== "generic").join("|")}`)}
182
+ `,
183
+ sync: `${c.bold("Usage:")} ${c.cyan(`${CLI_COMMAND} sync`)} ${c.dim("[flags]")}
184
+
185
+ Preview pull of published, saved, and subscribed library skills.
186
+
187
+ ${c.bold("Flags")}
188
+ ${c.cyan("--target")} ${c.dim("claude|codex")} Sync into Claude Code or Codex skills. Default: claude.
189
+ `,
190
+ watch: `${c.bold("Usage:")} ${c.cyan(`${CLI_COMMAND} watch`)} ${c.dim("[flags]")}
191
+
192
+ Poll sync on an interval. Preview behavior.
193
+
194
+ ${c.bold("Flags")}
195
+ ${c.cyan("--target")} ${c.dim("claude|codex")} Watch Claude Code or Codex skills. Default: claude.
196
+ ${c.cyan("--interval")} ${c.dim("<seconds>")} Poll interval. Minimum: 10.
197
+ `,
198
+ setup: `${c.bold("Usage:")} ${c.cyan(`${CLI_COMMAND} setup`)} ${c.dim("[flags]")}
199
+
200
+ Add Floom guidance to CLAUDE.md or AGENTS.md.
201
+
202
+ ${c.bold("Flags")}
203
+ ${c.cyan("--target")} ${c.dim("claude|codex")} Configure Claude Code or Codex.
204
+ ${c.cyan("--file")} ${c.dim("<path>")} Write a specific instructions file.
205
+ ${c.cyan("--yes")} Write without prompting.
206
+ ${c.cyan("--dry-run")} Preview the change only.
207
+ `,
208
+ search: `${c.bold("Usage:")} ${c.cyan(`${CLI_COMMAND} search`)} ${c.dim("<query> [flags]")}
209
+
210
+ Find public skills and libraries.
211
+
212
+ ${c.bold("Flags")}
213
+ ${c.cyan("--library")} ${c.dim("<slug>")} Limit search to one library.
214
+ ${c.cyan("--type")} ${c.dim("knowledge|instruction|workflow|skill")}
215
+ ${c.cyan("--json")} Print machine-readable results.
216
+ `,
217
+ info: `${c.bold("Usage:")} ${c.cyan(`${CLI_COMMAND} info`)} ${c.dim("<url-or-slug> [flags]")}
218
+
219
+ Show public skill metadata.
220
+
221
+ ${c.bold("Flags")}
222
+ ${c.cyan("--json")} Print machine-readable metadata.
223
+ `,
224
+ "agent-prompt": `${c.bold("Usage:")} ${c.cyan(`${CLI_COMMAND} agent-prompt`)} ${c.dim("[flags]")}
225
+
226
+ Print the one-line instruction to paste into your agent.
227
+
228
+ ${c.bold("Flags")}
229
+ ${c.cyan("--target")} ${c.dim("claude|codex")} Print the right local skills path. Default: claude.
230
+ `,
231
+ };
232
+ const body = key ? usageByCommand[key] : undefined;
233
+ if (!body)
234
+ return false;
235
+ process.stdout.write(`${body}\n`);
236
+ return true;
237
+ }
126
238
  const ASSET_TYPES = new Set(["knowledge", "instruction", "workflow", "skill"]);
127
239
  const INSTALL_TARGETS = new Set([
128
240
  "claude_skill",
@@ -312,6 +424,7 @@ function parseAddArgs(argv) {
312
424
  let target;
313
425
  let setup = false;
314
426
  let force = false;
427
+ let json = false;
315
428
  for (let i = 0; i < argv.length; i++) {
316
429
  const a = argv[i] ?? "";
317
430
  if (a === "--target" || a.startsWith("--target=")) {
@@ -328,6 +441,9 @@ function parseAddArgs(argv) {
328
441
  else if (a === "--force") {
329
442
  force = true;
330
443
  }
444
+ else if (a === "--json") {
445
+ json = true;
446
+ }
331
447
  else if (a.startsWith("--")) {
332
448
  throw new FloomError(`Unknown flag: ${a}`, `Try \`${CLI_COMMAND} add <url-or-slug> --setup\`.`);
333
449
  }
@@ -340,7 +456,10 @@ function parseAddArgs(argv) {
340
456
  if (!slug) {
341
457
  throw new FloomError("Missing skill slug.", `Try: \`${CLI_COMMAND} add <url-or-slug> --setup\``);
342
458
  }
343
- return target ? { slug, target, setup, force } : { slug, setup, force };
459
+ if (json && setup) {
460
+ throw new FloomError("Cannot combine --json with --setup.", "Run the install first, then run `npx -y @floomhq/floom setup --target claude --yes`.");
461
+ }
462
+ return target ? { slug, target, setup, force, json } : { slug, setup, force, json };
344
463
  }
345
464
  function parseSearchFlags(argv) {
346
465
  const out = { json: false };
@@ -426,7 +545,7 @@ function parseSetupFlags(argv) {
426
545
  return out;
427
546
  }
428
547
  function parseDoctorFlags(argv) {
429
- const out = {};
548
+ const out = { json: false };
430
549
  for (let i = 0; i < argv.length; i++) {
431
550
  const a = argv[i] ?? "";
432
551
  if (a === "--target" || a.startsWith("--target=")) {
@@ -437,8 +556,11 @@ function parseDoctorFlags(argv) {
437
556
  out.target = value;
438
557
  i = nextIndex;
439
558
  }
559
+ else if (a === "--json") {
560
+ out.json = true;
561
+ }
440
562
  else if (a.startsWith("--")) {
441
- throw new FloomError(`Unknown flag: ${a}`, `Try \`${CLI_COMMAND} doctor --target codex\`.`);
563
+ throw new FloomError(`Unknown flag: ${a}`, `Try \`${CLI_COMMAND} doctor --target codex --json\`.`);
442
564
  }
443
565
  else {
444
566
  throw new FloomError(`Unexpected argument: ${a}`, `Try \`${CLI_COMMAND} doctor --target claude\`.`);
@@ -598,11 +720,19 @@ function parseWatchFlags(argv) {
598
720
  out.intervalSeconds = interval;
599
721
  i = nextIndex;
600
722
  }
723
+ else if (a === "--target" || a.startsWith("--target=")) {
724
+ const { value, nextIndex } = readFlagValue(argv, i, "--target");
725
+ if (value !== "claude" && value !== "codex") {
726
+ throw new FloomError("Invalid --target.", "Use claude or codex.");
727
+ }
728
+ out.target = value;
729
+ i = nextIndex;
730
+ }
601
731
  else if (a.startsWith("--")) {
602
- throw new FloomError(`Unknown flag: ${a}`, `Try \`${CLI_COMMAND} watch --interval 60\`.`);
732
+ throw new FloomError(`Unknown flag: ${a}`, `Try \`${CLI_COMMAND} watch --target claude --interval 60\`.`);
603
733
  }
604
734
  else {
605
- throw new FloomError(`Unexpected argument: ${a}`, `Try \`${CLI_COMMAND} watch --interval 60\`.`);
735
+ throw new FloomError(`Unexpected argument: ${a}`, `Try \`${CLI_COMMAND} watch --target claude --interval 60\`.`);
606
736
  }
607
737
  }
608
738
  return out;
@@ -610,6 +740,31 @@ function parseWatchFlags(argv) {
610
740
  function notAvailable(feature) {
611
741
  throw new FloomError(V1_NOT_AVAILABLE, `${feature} is planned for a later Floom release.`);
612
742
  }
743
+ function parseAgentPromptFlags(argv) {
744
+ const out = {};
745
+ for (let i = 0; i < argv.length; i++) {
746
+ const a = argv[i] ?? "";
747
+ if (a === "--target" || a.startsWith("--target=")) {
748
+ const { value, nextIndex } = readFlagValue(argv, i, "--target");
749
+ if (value !== "claude" && value !== "codex") {
750
+ throw new FloomError("Invalid --target.", "Use `claude` or `codex`.");
751
+ }
752
+ out.target = value;
753
+ i = nextIndex;
754
+ }
755
+ else if (a.startsWith("--")) {
756
+ throw new FloomError(`Unknown flag: ${a}`, `Try \`${CLI_COMMAND} agent-prompt --target codex\`.`);
757
+ }
758
+ else {
759
+ throw new FloomError(`Unexpected argument: ${a}`, `Try \`${CLI_COMMAND} agent-prompt --target claude\`.`);
760
+ }
761
+ }
762
+ return out;
763
+ }
764
+ function printAgentPrompt(target = "claude") {
765
+ const folder = target === "codex" ? "~/.codex/skills" : "~/.claude/skills";
766
+ process.stdout.write(`Use my installed Floom skills when they fit the task. Search ${folder} first.\n`);
767
+ }
613
768
  function parseSingleFileArg(argv, usageHint) {
614
769
  let file;
615
770
  for (const a of argv) {
@@ -634,7 +789,7 @@ function sleep(ms, signal) {
634
789
  }, { once: true });
635
790
  });
636
791
  }
637
- async function watch(intervalSeconds) {
792
+ async function watch(intervalSeconds, target) {
638
793
  const cfg = await readConfig();
639
794
  if (!cfg) {
640
795
  throw new FloomError("Not signed in.", `Run \`${CLI_COMMAND} login\` before \`${CLI_COMMAND} watch\`, or use \`${CLI_COMMAND} add <link>\` without an account.`);
@@ -651,9 +806,9 @@ async function watch(intervalSeconds) {
651
806
  };
652
807
  process.on("SIGINT", stop);
653
808
  process.on("SIGTERM", stop);
654
- process.stdout.write(`${symbols.bullet} Watching Floom sync every ${intervalSeconds}s. Press Ctrl-C to stop.\n`);
809
+ process.stdout.write(`${symbols.bullet} Watching Floom sync for ${target ?? "claude"} every ${intervalSeconds}s. Press Ctrl-C to stop.\n`);
655
810
  while (!controller.signal.aborted) {
656
- await sync({ spinner: false, quietUnchanged: true });
811
+ await sync({ spinner: false, quietUnchanged: true, ...(target ? { target } : {}) });
657
812
  await sleep(intervalSeconds * 1000, controller.signal);
658
813
  }
659
814
  }
@@ -673,10 +828,9 @@ async function main() {
673
828
  // never block on update-notifier
674
829
  }
675
830
  }
676
- // Subcommand --help: any rest arg = --help/-h/help → show top-level usage.
677
- // Subcommands are simple enough that one help screen is fine for Version 1.
678
831
  if (rest.includes("--help") || rest.includes("-h") || rest.includes("help")) {
679
- usage();
832
+ if (!subcommandUsage(cmd))
833
+ commandUsage();
680
834
  return;
681
835
  }
682
836
  try {
@@ -781,6 +935,20 @@ async function main() {
781
935
  ...(flags.target ? { target: flags.target } : {}),
782
936
  setup: flags.setup,
783
937
  force: flags.force,
938
+ json: flags.json,
939
+ });
940
+ if (flags.setup) {
941
+ await setupAgent({ target: flags.target ?? "claude", dryRun: false, yes: true });
942
+ }
943
+ return;
944
+ }
945
+ case "update": {
946
+ const flags = parseAddArgs(rest);
947
+ await install(flags.slug, {
948
+ ...(flags.target ? { target: flags.target } : {}),
949
+ setup: flags.setup,
950
+ force: true,
951
+ json: flags.json,
784
952
  });
785
953
  if (flags.setup) {
786
954
  await setupAgent({ target: flags.target ?? "claude", dryRun: false, yes: true });
@@ -798,7 +966,7 @@ async function main() {
798
966
  }
799
967
  case "watch": {
800
968
  const flags = parseWatchFlags(rest);
801
- await watch(flags.intervalSeconds);
969
+ await watch(flags.intervalSeconds, flags.target);
802
970
  return;
803
971
  }
804
972
  case "delete":
@@ -830,6 +998,13 @@ async function main() {
830
998
  rejectArgs(rest, `Try \`${CLI_COMMAND} mcp\`.`);
831
999
  printMcpSetup();
832
1000
  return;
1001
+ case "agent-prompt":
1002
+ case "paste":
1003
+ {
1004
+ const flags = parseAgentPromptFlags(rest);
1005
+ printAgentPrompt(flags.target);
1006
+ }
1007
+ return;
833
1008
  case "doctor":
834
1009
  await doctor(parseDoctorFlags(rest));
835
1010
  return;
package/dist/doctor.js CHANGED
@@ -335,7 +335,6 @@ async function readExecutableVersion(path) {
335
335
  }
336
336
  export async function doctor(opts = {}) {
337
337
  const target = opts.target ?? "claude";
338
- process.stdout.write(`\n${c.bold("floom doctor")} ${c.dim(`(${formatVersionLabel(CLI_VERSION)}, target: ${target})`)}\n\n`);
339
338
  const checks = await Promise.all([
340
339
  checkCliCommand(),
341
340
  checkAuth(),
@@ -344,6 +343,22 @@ export async function doctor(opts = {}) {
344
343
  checkLastSync(target),
345
344
  checkVersion(),
346
345
  ]);
346
+ const anyFail = checks.some((check) => check.status === "fail");
347
+ const anyWarn = checks.some((check) => check.status === "warn");
348
+ if (opts.json) {
349
+ process.stdout.write(`${JSON.stringify({
350
+ ok: !anyFail,
351
+ status: anyFail ? "fail" : anyWarn ? "warn" : "ok",
352
+ version: CLI_VERSION,
353
+ target,
354
+ checks,
355
+ configPath: CONFIG_PATH,
356
+ })}\n`);
357
+ if (anyFail)
358
+ process.exit(1);
359
+ return;
360
+ }
361
+ process.stdout.write(`\n${c.bold("floom doctor")} ${c.dim(`(${formatVersionLabel(CLI_VERSION)}, target: ${target})`)}\n\n`);
347
362
  // Compute column widths for clean table output.
348
363
  const nameW = Math.max(...checks.map((c) => c.name.length), 6);
349
364
  for (const check of checks) {
@@ -353,8 +368,6 @@ export async function doctor(opts = {}) {
353
368
  process.stdout.write(` ${c.dim("→ " + check.hint)}\n`);
354
369
  }
355
370
  }
356
- const anyFail = checks.some((c) => c.status === "fail");
357
- const anyWarn = checks.some((c) => c.status === "warn");
358
371
  process.stdout.write("\n");
359
372
  if (anyFail) {
360
373
  process.stdout.write(` ${c.red("✗ Some checks failed.")} See hints above.\n\n`);
package/dist/install.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { constants } from "node:fs";
2
- import { lstat, mkdir, open } from "node:fs/promises";
2
+ import { lstat, mkdir, open, readdir, rmdir, unlink } from "node:fs/promises";
3
3
  import { homedir } from "node:os";
4
4
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
5
  import ora from "ora";
@@ -66,12 +66,14 @@ async function readLocalFile(path) {
66
66
  throw err;
67
67
  }
68
68
  }
69
- async function localPackageHash(root, slug, target, files) {
69
+ async function localPackageState(root, slug, target, files) {
70
70
  const main = await readLocalFile(target);
71
71
  if (main === null) {
72
72
  const legacy = await readLocalFile(legacySkillPath(root, slug));
73
- if (legacy !== null && files.length === 0)
74
- return packageHash(legacy.toString("utf8"), []);
73
+ if (legacy !== null && legacy.length === 0)
74
+ return null;
75
+ if (legacy !== null)
76
+ return { hash: packageHash(legacy.toString("utf8"), []), source: "legacy" };
75
77
  return null;
76
78
  }
77
79
  const localFiles = [];
@@ -81,7 +83,7 @@ async function localPackageHash(root, slug, target, files) {
81
83
  return null;
82
84
  localFiles.push({ path: file.path, bytes, sha256: file.sha256 });
83
85
  }
84
- return packageHash(main.toString("utf8"), localFiles);
86
+ return { hash: packageHash(main.toString("utf8"), localFiles), source: "native" };
85
87
  }
86
88
  async function markInstallSynced(root, slug, files) {
87
89
  const manifest = await readSyncManifest();
@@ -119,6 +121,33 @@ async function writeInstallFile(root, target, body) {
119
121
  await parent.close();
120
122
  }
121
123
  }
124
+ async function removeLegacySkillFile(root, slug) {
125
+ try {
126
+ await unlink(legacySkillPath(root, slug));
127
+ }
128
+ catch (err) {
129
+ const code = err.code;
130
+ if (code === "ENOENT")
131
+ return;
132
+ if (code === "EISDIR" || code === "ELOOP" || code === "EPERM")
133
+ return;
134
+ throw err;
135
+ }
136
+ }
137
+ async function removeEmptyNativeSkillDir(root, slug) {
138
+ try {
139
+ const dir = dirname(skillPath(root, slug));
140
+ const entries = await readdir(dir);
141
+ if (entries.length === 0)
142
+ await rmdir(dir);
143
+ }
144
+ catch (err) {
145
+ const code = err.code;
146
+ if (code === "ENOENT" || code === "ENOTEMPTY" || code === "EEXIST")
147
+ return;
148
+ throw err;
149
+ }
150
+ }
122
151
  async function overwriteInstallFile(root, target, body) {
123
152
  const parent = await openSafeParentDirectory(root, target);
124
153
  const handle = await open(childCreatePath(parent, dirname(target), basename(target)), constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | constants.O_NOFOLLOW, 0o600);
@@ -203,7 +232,7 @@ export async function install(slugInput, opts = {}) {
203
232
  }
204
233
  const cfg = await readConfig();
205
234
  const apiUrl = resolveApiUrl(cfg);
206
- const spinner = ora({ text: c.dim(`Adding ${slug}...`), color: "yellow" }).start();
235
+ const spinner = opts.json ? null : ora({ text: c.dim(`Adding ${slug}...`), color: "yellow" }).start();
207
236
  let detail;
208
237
  try {
209
238
  detail = await getJson(`${apiUrl}/api/v1/skills/${encodeURIComponent(slug)}`, "fetch skill", cfg?.accessToken);
@@ -212,7 +241,7 @@ export async function install(slugInput, opts = {}) {
212
241
  }
213
242
  }
214
243
  catch (err) {
215
- spinner.stop();
244
+ spinner?.stop();
216
245
  throw err;
217
246
  }
218
247
  const target = skillPath(root, slug);
@@ -238,21 +267,29 @@ export async function install(slugInput, opts = {}) {
238
267
  }
239
268
  throw err;
240
269
  }
241
- await ensureSafeParentDirectory(root, target);
242
- const existing = await localPackageHash(root, slug, target, remotePackageFiles);
270
+ const existing = await localPackageState(root, slug, target, remotePackageFiles);
243
271
  const conflictingTarget = await preflightInstallPackage(root, installFiles, opts.force ? { force: true } : {});
244
272
  if (conflictingTarget) {
245
273
  throw new FloomError("Local skill already exists with different content.", `Run \`npx -y @floomhq/floom add <link> --force\` to replace it, or move the local file first: ${relative(root, conflictingTarget).split(sep).join("/")}`);
246
274
  }
247
- if (existing === remoteHash) {
275
+ if (existing?.hash === remoteHash && existing.source === "native") {
276
+ await removeLegacySkillFile(root, slug);
248
277
  action = "unchanged";
249
278
  }
250
- else if (existing !== null && opts.force) {
279
+ else if (existing !== null && (opts.force || (existing.source === "legacy" && existing.hash === remoteHash))) {
251
280
  try {
252
- await overwriteInstallFile(root, target, detail.body_md);
281
+ if (existing.source === "legacy")
282
+ await writeInstallFile(root, target, detail.body_md);
283
+ else
284
+ await overwriteInstallFile(root, target, detail.body_md);
253
285
  for (const file of remotePackageFiles) {
254
- await overwriteInstallFile(root, join(dirname(target), file.path), file.bytes);
286
+ const fileTarget = join(dirname(target), file.path);
287
+ if (existing.source === "legacy")
288
+ await writeInstallFile(root, fileTarget, file.bytes);
289
+ else
290
+ await overwriteInstallFile(root, fileTarget, file.bytes);
255
291
  }
292
+ await removeLegacySkillFile(root, slug);
256
293
  }
257
294
  catch (err) {
258
295
  const code = err.code;
@@ -260,7 +297,7 @@ export async function install(slugInput, opts = {}) {
260
297
  throw new FloomError("Local path is a symbolic link.", "Move or delete the local path, then run `npx -y @floomhq/floom add` again.");
261
298
  throw err;
262
299
  }
263
- action = "updated";
300
+ action = existing.source === "legacy" ? "installed" : "updated";
264
301
  }
265
302
  else if (existing !== null) {
266
303
  throw new FloomError("Local skill already exists with different content.", "Run `npx -y @floomhq/floom add <link> --force` to replace it, or move the local file first.");
@@ -271,6 +308,7 @@ export async function install(slugInput, opts = {}) {
271
308
  for (const file of remotePackageFiles) {
272
309
  await writeInstallFile(root, join(dirname(target), file.path), file.bytes);
273
310
  }
311
+ await removeLegacySkillFile(root, slug);
274
312
  }
275
313
  catch (err) {
276
314
  const code = err.code;
@@ -294,7 +332,17 @@ export async function install(slugInput, opts = {}) {
294
332
  manifestWarning = err instanceof Error ? err.message : String(err);
295
333
  }
296
334
  });
297
- spinner.stop();
335
+ spinner?.stop();
336
+ if (opts.json) {
337
+ process.stdout.write(`${JSON.stringify({
338
+ slug,
339
+ action,
340
+ target: targetAgent,
341
+ path: target,
342
+ setup: Boolean(opts.setup),
343
+ })}\n`);
344
+ return;
345
+ }
298
346
  process.stdout.write(`\n${symbols.ok} [floom] ${action} ${c.bold(slug)}\n`);
299
347
  process.stdout.write(` ${c.dim(dirname(target))}\n\n`);
300
348
  if (manifestWarning) {
package/dist/login.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { createServer } from "node:http";
2
2
  import { createServer as createNetServer } from "node:net";
3
+ import { randomBytes } from "node:crypto";
3
4
  import open from "open";
4
5
  import ora from "ora";
5
6
  import { getApiUrl, writeConfig } from "./config.js";
@@ -92,6 +93,7 @@ function reserveEphemeralPort() {
92
93
  function waitForCallback(port) {
93
94
  return new Promise((resolve, reject) => {
94
95
  const apiUrl = getApiUrl();
96
+ const state = randomBytes(32).toString("base64url");
95
97
  let settled = false;
96
98
  const server = createServer((req, res) => {
97
99
  // CORS preflight from the browser bridge page.
@@ -122,6 +124,15 @@ function waitForCallback(port) {
122
124
  res.end(localCallbackPage("Missing tokens from OAuth response."));
123
125
  return;
124
126
  }
127
+ if (data.state !== state) {
128
+ res.writeHead(400, {
129
+ "access-control-allow-origin": origin,
130
+ "access-control-allow-private-network": "true",
131
+ "content-type": "text/html; charset=utf-8",
132
+ });
133
+ res.end(localCallbackPage("Invalid sign-in state."));
134
+ return;
135
+ }
125
136
  res.writeHead(200, {
126
137
  "access-control-allow-origin": origin,
127
138
  "access-control-allow-private-network": "true",
@@ -161,7 +172,7 @@ function waitForCallback(port) {
161
172
  reject(new FloomError(`Local auth server failed on port ${port}.`, `Is port ${port} already in use? (${err.message})`));
162
173
  });
163
174
  server.listen(port, "127.0.0.1", () => {
164
- const target = `${apiUrl}/auth/cli?port=${port}`;
175
+ const target = `${apiUrl}/auth/cli?port=${port}&state=${encodeURIComponent(state)}`;
165
176
  open(target).catch((e) => {
166
177
  const msg = e instanceof Error ? e.message : String(e);
167
178
  process.stdout.write(c.yellow(`Could not auto-open browser (${msg}).\n`) +
@@ -175,7 +186,7 @@ function parseCallbackBody(body, contentType) {
175
186
  if (type.includes("application/x-www-form-urlencoded")) {
176
187
  const params = new URLSearchParams(body);
177
188
  const parsed = {};
178
- for (const key of ["access_token", "refresh_token", "expires_in", "token_type"]) {
189
+ for (const key of ["access_token", "refresh_token", "expires_in", "token_type", "state"]) {
179
190
  const value = params.get(key);
180
191
  if (value)
181
192
  parsed[key] = value;
package/dist/sync.js CHANGED
@@ -79,6 +79,17 @@ function skillPath(skill, targetAgent) {
79
79
  function syncKey(skill) {
80
80
  return `${skill.library_slug ?? ""}\0${skill.folder ?? ""}\0${skill.slug}`;
81
81
  }
82
+ function hasStructuredPath(skill) {
83
+ return Boolean(skill.library_slug || skill.folder);
84
+ }
85
+ function dedupeSyncSkills(skills) {
86
+ const structuredSlugs = new Set();
87
+ for (const skill of skills) {
88
+ if (hasStructuredPath(skill))
89
+ structuredSlugs.add(skill.slug);
90
+ }
91
+ return skills.filter((skill) => !structuredSlugs.has(skill.slug) || hasStructuredPath(skill));
92
+ }
82
93
  function validateSyncSkillShape(skill) {
83
94
  if (!skill || typeof skill !== "object")
84
95
  throw new FloomError("Invalid sync response.");
@@ -288,7 +299,7 @@ export async function sync(opts = {}) {
288
299
  for (const skill of payload.skills)
289
300
  validateSyncSkillShape(skill);
290
301
  // Version 1 preview syncs published, saved, and subscribed library skills.
291
- const all = payload.skills;
302
+ const all = dedupeSyncSkills(payload.skills);
292
303
  const seen = new Set();
293
304
  let unchanged = 0;
294
305
  let updated = 0;
@@ -0,0 +1,16 @@
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floomhq/floom",
3
- "version": "1.0.18",
3
+ "version": "1.0.20",
4
4
  "description": "Sync AI skills across agents and machines.",
5
5
  "license": "MIT",
6
6
  "type": "module",