@floomhq/floom 1.0.5 → 1.0.7

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 CHANGED
@@ -3,28 +3,29 @@
3
3
  Publish AI skills from your terminal. Share them with a link. Add other people's skills with one command.
4
4
 
5
5
  ```bash
6
- npm install -g @floomhq/floom
7
- floom init my-skill.md
8
- floom login
9
- floom publish my-skill.md
10
- floom share my-skill --add teammate@example.com
11
- floom search review
12
- floom add awesome-skill
13
- floom setup --target claude --dry-run
14
- floom list
15
- floom library list
6
+ npx -y @floomhq/floom init my-skill.md
7
+ npx -y @floomhq/floom login
8
+ npx -y @floomhq/floom publish my-skill.md
9
+ npx -y @floomhq/floom share my-skill --add teammate@example.com
10
+ npx -y @floomhq/floom search review
11
+ npx -y @floomhq/floom add awesome-skill --setup
12
+ npx -y @floomhq/floom setup --target claude --dry-run
13
+ npx -y @floomhq/floom list
14
+ npx -y @floomhq/floom library list
16
15
  ```
17
16
 
18
17
  Returns a shareable link like `https://floom.dev/s/ffas93ud`. Anyone with the URL can read the raw Markdown — drop it into any AI tool that accepts skills.
19
18
 
19
+ The package is designed for `npx -y @floomhq/floom ...`. Global installs expose `floom-skills` to avoid colliding with any older local Floom runtime CLI.
20
+
20
21
  ## Commands
21
22
 
22
- - `floom login` — sign in with Google. New accounts are created on first login. Token stored at `~/.floom/config.json`.
23
+ - `npx -y @floomhq/floom login` — sign in with Google. New accounts are created on first login. Token stored at `~/.floom/config.json`.
23
24
  - `floom init [file.md]` — create a starter skill file.
24
- - `floom publish <file.md>` — upload a Markdown file. Optional `--public` / `--private` / `--unlisted`, `--type knowledge|instruction|workflow|skill`, `--installs-as <target>`, and `--version <label>`.
25
+ - `npx -y @floomhq/floom publish <file.md>` — upload a Markdown file. Optional `--public` / `--private` / `--unlisted`, `--type knowledge|instruction|workflow|skill`, `--installs-as <target>`, and `--skill-version <label>`.
25
26
  - `floom share <slug>` — email-share one of your skills. Optional `--add <email>`, `--remove <email>`, and `--list`.
26
27
  - `floom list` — show your published skills. Optional `--json`.
27
- - `floom add <url-or-slug>` — fetch a skill into `~/.claude/skills/<slug>.md`.
28
+ - `npx -y @floomhq/floom add <url-or-slug>` — fetch a skill into `~/.claude/skills/<slug>.md`. Optional `--setup` connects Claude Code and `--force` replaces an existing local copy.
28
29
  - `floom info <url-or-slug>` — show skill metadata. Optional `--json`.
29
30
  - `floom search <query>` — search public skills and starter libraries. Optional `--library <slug>`, `--type knowledge|instruction|workflow|skill`, and `--json`.
30
31
  - `floom setup` — add Floom usage guidance to `CLAUDE.md` or `AGENTS.md`. Optional `--target claude|codex`, `--dry-run`, `--yes`.
@@ -38,7 +39,7 @@ Returns a shareable link like `https://floom.dev/s/ffas93ud`. Anyone with the UR
38
39
  - `floom library subscribe <slug>` — subscribe to a public or unlisted library so sync can pull it locally.
39
40
  - `floom move <slug> --folder <path>` — set your local folder override for a saved or library skill.
40
41
  - `floom delete <url-or-slug>` — delete one of your published skills. Optional `--yes`.
41
- - `floom doctor` — diagnose your Floom setup.
42
+ - `npx -y @floomhq/floom doctor` — diagnose your Floom setup.
42
43
  - `floom whoami` — show the signed-in account.
43
44
  - `floom logout` — delete local credentials.
44
45
 
@@ -64,5 +65,5 @@ Override the API host with `FLOOM_API_URL` (defaults to `https://floom.dev`).
64
65
  `floom sync` and `floom watch` are Version 1 preview commands for published, saved, and subscribed library skills. They store a machine-local manifest at `~/.floom/sync-manifest.json`.
65
66
  The manifest records hashes for files Floom previously wrote. Version 1 sync writes missing files
66
67
  only. Remote updates, existing untracked files, and locally edited tracked files are skipped as
67
- conflicts. Symlinks are never followed. To accept the Floom version, move or delete the local file
68
- and run `floom sync` again.
68
+ conflicts. Symlinks are never followed. To replace a local skill manually, run
69
+ `npx -y @floomhq/floom add <url-or-slug> --force`.
package/dist/cli.js CHANGED
@@ -4,7 +4,7 @@ import { login } from "./login.js";
4
4
  import { publish } from "./publish.js";
5
5
  import { whoami } from "./whoami.js";
6
6
  import { init } from "./init.js";
7
- import { deleteConfig } from "./config.js";
7
+ import { deleteConfig, readConfig } from "./config.js";
8
8
  import { list } from "./list.js";
9
9
  import { install } from "./install.js";
10
10
  import { info } from "./info.js";
@@ -24,49 +24,53 @@ const PKG = { name: "@floomhq/floom", version: CLI_VERSION };
24
24
  const V1_NOT_AVAILABLE = "Not available in Floom Version 1.";
25
25
  function usage() {
26
26
  const out = `
27
- ${c.coral(" ________")}
28
- ${c.coral(" / ____/ /___ ____ ____ ___")} ${c.dim(`v${CLI_VERSION}`)}
29
- ${c.coral(" / /_ / / __ \\/ __ \\/ __ `__ \\")}
30
- ${c.coral(" / __/ / / /_/ / /_/ / / / / / /")}
31
- ${c.coral(" /_/ /_/\\____/\\____/_/ /_/ /_/")}
27
+ ${c.blue(" ________ ")}
28
+ ${c.blue(" / ____/ /___ ____ ____ ___ ")} ${c.dim(`v${CLI_VERSION}`)}
29
+ ${c.blue("/ /_ / / __ \\/ __ \\/ __ `__ \\ ")}
30
+ ${c.blue("/ __/ / / /_/ / /_/ / / / / / / ")}
31
+ ${c.blue("/_/ /_/\\____/\\____/_/ /_/ /_/ ")}
32
32
 
33
- ${c.bold("Share AI agent skills with a link.")}
34
- ${c.dim("Publish knowledge, instructions, and workflows from your terminal.")}
33
+ ${c.bold("Floom lets you share AI workflows with anyone.")}
34
+ ${c.dim("A skill is reusable knowledge, instructions, or a workflow for your AI agent.")}
35
+ ${c.dim("Examples: brand voice, PR review checklist, sales research workflow.")}
35
36
 
36
- ${c.bold("Do this next")}
37
- ${c.dim("1. Add a skill someone sent you")}
38
- ${c.cyan("npx -y @floomhq/floom add")} ${c.dim("https://floom.dev/s/ffas93ud --target claude")}
37
+ ${c.bold("You installed Floom. Copy one recipe:")}
39
38
 
40
- ${c.dim("2. Publish your own skill")}
41
- ${c.cyan("npx -y @floomhq/floom init")} ${c.dim("support-tone.md")}
42
- ${c.dim("# edit support-tone.md")}
43
- ${c.cyan("npx -y @floomhq/floom scan")} ${c.dim("support-tone.md")}
44
- ${c.cyan("npx -y @floomhq/floom login")}
45
- ${c.cyan("npx -y @floomhq/floom publish")} ${c.dim("support-tone.md --type instruction --public")}
39
+ ${c.bold("1. I received a Floom link")}
40
+ ${c.dim("Replace <skill-link> with the full Floom URL someone sent you:")}
41
+ ${c.cyan("npx -y @floomhq/floom add")} ${c.dim("<skill-link> --setup")}
42
+ ${c.dim('Then tell Claude Code: "Use my Floom skills when they fit this task."')}
46
43
 
47
- ${c.dim("3. Tell your agent where skills land")}
48
- ${c.cyan("npx -y @floomhq/floom setup")} ${c.dim("--target claude --yes")}
49
- ${c.cyan("npx -y @floomhq/floom setup")} ${c.dim("--target codex --yes")}
44
+ ${c.bold("2. I want to make a share link")}
45
+ ${c.cyan("npx -y @floomhq/floom init")} ${c.dim("my-skill.md")}
46
+ ${c.dim("Write what your agent needs to know or do in my-skill.md.")}
47
+ ${c.cyan("npx -y @floomhq/floom login")}
48
+ ${c.cyan("npx -y @floomhq/floom publish")} ${c.dim("my-skill.md --public")}
49
+ ${c.dim("Floom scans it, prints a link, and copies the link when possible.")}
50
50
 
51
- ${c.bold("Safety")}
52
- ${c.yellow("!")} ${c.dim("publish scans for API keys, prompt injection, and exfiltration.")}
51
+ ${c.bold("Good to know")}
52
+ ${symbols.ok} ${c.dim("No account is needed to add a shared skill.")}
53
+ ${symbols.ok} ${c.dim("Sign in only when you publish or manage your skills.")}
54
+ ${symbols.ok} ${c.dim("Every command prints success or the exact problem to fix.")}
53
55
 
54
- ${c.bold("More")}
55
- ${c.cyan("floom commands")} ${c.dim("Full command list")}
56
- ${c.cyan("floom doctor")} ${c.dim("Check auth, API, and local folders")}
57
- ${c.dim("Docs")} https://floom.dev
56
+ ${c.bold("Stuck?")}
57
+ ${c.cyan("npx -y @floomhq/floom doctor")} ${c.dim("Find the problem")}
58
+ ${c.cyan("npx -y @floomhq/floom scan my-skill.md")} ${c.dim("Check a file before publishing")}
59
+ ${c.cyan("npx -y @floomhq/floom commands")} ${c.dim("See every command")}
60
+ ${c.dim("Step-by-step guide")} https://floom.dev/docs/getting-started
58
61
  `;
59
62
  process.stdout.write(out);
60
63
  }
61
64
  function commandUsage() {
62
65
  const out = `
63
- ${c.bold("Usage:")} ${c.cyan("floom")} ${c.dim("<command> [flags]")}
66
+ ${c.bold("Usage:")} ${c.cyan("npx -y @floomhq/floom")} ${c.dim("<command> [flags]")}
67
+ ${c.dim("Global install binary:")} ${c.cyan("floom-skills")} ${c.dim("<command> [flags]")}
64
68
 
65
69
  ${c.bold("Commands")}
66
70
  ${c.dim("Skills")}
67
71
  ${c.cyan("add")} ${c.dim("<url>")} Install a skill into the local agent skills folder
68
72
  ${c.dim("Alias: install")}
69
- ${c.dim("Flags: --target claude|codex (default: claude)")}
73
+ ${c.dim("Flags: --target claude|codex (default: claude), --setup, --force")}
70
74
  ${c.cyan("search")} ${c.dim("<query>")} Find public skills and libraries
71
75
  ${c.cyan("info")} ${c.dim("<url>")} Show skill metadata
72
76
 
@@ -102,16 +106,16 @@ function commandUsage() {
102
106
  ${c.cyan("watch")} Preview polling sync loop
103
107
 
104
108
  ${c.bold("Examples")}
105
- ${c.cyan("floom add")} ${c.dim("https://floom.dev/s/ffas93ud")}
106
- ${c.cyan("floom publish")} ${c.dim("support-tone.md --type instruction --public")}
107
- ${c.cyan("floom setup")} ${c.dim("--target claude --yes")}
109
+ ${c.cyan("npx -y @floomhq/floom add")} ${c.dim("https://floom.dev/s/ffas93ud --setup")}
110
+ ${c.cyan("npx -y @floomhq/floom publish")} ${c.dim("support-tone.md --type instruction --public")}
111
+ ${c.cyan("npx -y @floomhq/floom setup")} ${c.dim("--target claude --yes")}
108
112
 
109
113
  ${c.bold("Help")}
110
- ${c.cyan("floom commands")} Show this reference
114
+ ${c.cyan("npx -y @floomhq/floom commands")} Show this reference
111
115
  ${c.cyan("--help")} Show this reference
112
116
  ${c.cyan("--version")} Show CLI version
113
117
 
114
- ${c.dim("Run")} ${c.cyan("floom")} ${c.dim("with no command for the guided start screen.")}
118
+ ${c.dim("Run")} ${c.cyan("npx -y @floomhq/floom")} ${c.dim("with no command for the guided start screen.")}
115
119
  `;
116
120
  process.stdout.write(out);
117
121
  }
@@ -170,15 +174,17 @@ function parseFlags(argv) {
170
174
  out.installsAs = value;
171
175
  i = nextIndex;
172
176
  }
173
- else if (a === "--skill-version" || a.startsWith("--skill-version=") || a === "--version" || a.startsWith("--version=")) {
174
- const flagName = a.startsWith("--skill-version") ? "--skill-version" : "--version";
175
- const { value, nextIndex } = readFlagValue(argv, i, flagName);
177
+ else if (a === "--skill-version" || a.startsWith("--skill-version=")) {
178
+ const { value, nextIndex } = readFlagValue(argv, i, "--skill-version");
176
179
  if (!VERSION_RE.test(value)) {
177
- throw new FloomError(`Invalid ${flagName}: ${value}`, "Use 1-64 characters: letters, numbers, dots, underscores, plus, or hyphen.");
180
+ throw new FloomError(`Invalid --skill-version: ${value}`, "Use 1-64 characters: letters, numbers, dots, underscores, plus, or hyphen.");
178
181
  }
179
182
  out.version = value;
180
183
  i = nextIndex;
181
184
  }
185
+ else if (a === "--version" || a.startsWith("--version=")) {
186
+ throw new FloomError("`--version` prints the Floom CLI version at the top level.", "For skill version labels, use `--skill-version <label>`.");
187
+ }
182
188
  else if (a.startsWith("--")) {
183
189
  throw new FloomError(`Unknown flag: ${a}`, "Try `floom publish skill.md --type instruction --public`.");
184
190
  }
@@ -268,6 +274,8 @@ function parseInfoFlags(argv) {
268
274
  function parseAddArgs(argv) {
269
275
  let slug;
270
276
  let target;
277
+ let setup = false;
278
+ let force = false;
271
279
  for (let i = 0; i < argv.length; i++) {
272
280
  const a = argv[i] ?? "";
273
281
  if (a === "--target" || a.startsWith("--target=")) {
@@ -278,19 +286,25 @@ function parseAddArgs(argv) {
278
286
  target = value;
279
287
  i = nextIndex;
280
288
  }
289
+ else if (a === "--setup") {
290
+ setup = true;
291
+ }
292
+ else if (a === "--force") {
293
+ force = true;
294
+ }
281
295
  else if (a.startsWith("--")) {
282
- throw new FloomError(`Unknown flag: ${a}`, "Try `floom add <url-or-slug> --target claude`.");
296
+ throw new FloomError(`Unknown flag: ${a}`, "Try `floom add <url-or-slug> --setup`.");
283
297
  }
284
298
  else if (slug) {
285
- throw new FloomError(`Unexpected argument: ${a}`, "Try `floom add <url-or-slug> --target claude`.");
299
+ throw new FloomError(`Unexpected argument: ${a}`, "Try `floom add <url-or-slug> --setup`.");
286
300
  }
287
301
  else
288
302
  slug = a;
289
303
  }
290
304
  if (!slug) {
291
- throw new FloomError("Missing skill slug.", "Try: `floom add <url-or-slug> --target claude`");
305
+ throw new FloomError("Missing skill slug.", "Try: `floom add <url-or-slug> --setup`");
292
306
  }
293
- return target ? { slug, target } : { slug };
307
+ return target ? { slug, target, setup, force } : { slug, setup, force };
294
308
  }
295
309
  function parseSearchFlags(argv) {
296
310
  const out = { json: false };
@@ -564,6 +578,10 @@ function sleep(ms, signal) {
564
578
  });
565
579
  }
566
580
  async function watch(intervalSeconds) {
581
+ const cfg = await readConfig();
582
+ if (!cfg) {
583
+ throw new FloomError("Not signed in.", "Run `npx -y @floomhq/floom login` before watch, or use `npx -y @floomhq/floom add <link>` without an account.");
584
+ }
567
585
  const controller = new AbortController();
568
586
  let stopping = false;
569
587
  const stop = () => {
@@ -702,7 +720,14 @@ async function main() {
702
720
  case "add":
703
721
  case "install": {
704
722
  const flags = parseAddArgs(rest);
705
- await install(flags.slug, flags.target ? { target: flags.target } : {});
723
+ await install(flags.slug, {
724
+ ...(flags.target ? { target: flags.target } : {}),
725
+ setup: flags.setup,
726
+ force: flags.force,
727
+ });
728
+ if (flags.setup) {
729
+ await setupAgent({ target: flags.target ?? "claude", dryRun: false, yes: true });
730
+ }
706
731
  return;
707
732
  }
708
733
  case "sync":
@@ -758,7 +783,7 @@ async function main() {
758
783
  }
759
784
  }
760
785
  catch (e) {
761
- printError(e);
786
+ printError(e, { json: rest.includes("--json") });
762
787
  process.exit(1);
763
788
  }
764
789
  }
package/dist/errors.js CHANGED
@@ -20,6 +20,9 @@ export function friendlyHttp(status, action) {
20
20
  return new FloomError(`You don't have permission to ${action}.`);
21
21
  }
22
22
  if (status === 404) {
23
+ if (/fetch|inspect|add|install|show|get|search|list|info/i.test(action)) {
24
+ return new FloomError("Skill not found.", "Check the link or slug, then try again.");
25
+ }
23
26
  return new FloomError("Skill not found.", "Run `floom publish` without `--update` to create a new one.");
24
27
  }
25
28
  if (status === 413) {
@@ -37,7 +40,14 @@ export function friendlyNetwork(err) {
37
40
  }
38
41
  return new FloomError(msg);
39
42
  }
40
- export function printError(err) {
43
+ export function printError(err, opts = {}) {
44
+ if (opts.json) {
45
+ const error = err instanceof FloomError
46
+ ? { error: err.message, hint: err.hint ?? null }
47
+ : { error: err instanceof Error ? err.message : String(err), hint: null };
48
+ process.stderr.write(`${JSON.stringify(error)}\n`);
49
+ return;
50
+ }
41
51
  if (err instanceof FloomError) {
42
52
  process.stderr.write(`\n${symbols.fail} ${err.message}\n`);
43
53
  if (err.hint) {
package/dist/install.js CHANGED
@@ -77,6 +77,18 @@ async function writeInstallFile(root, target, body) {
77
77
  await parent.close();
78
78
  }
79
79
  }
80
+ async function overwriteInstallFile(target, body) {
81
+ const handle = await open(target, constants.O_WRONLY | constants.O_TRUNC | constants.O_NOFOLLOW);
82
+ try {
83
+ const stat = await handle.stat();
84
+ if (!stat.isFile())
85
+ throw new FloomError("Local path is blocked by an existing file or directory.");
86
+ await writeAll(handle, body);
87
+ }
88
+ finally {
89
+ await handle.close();
90
+ }
91
+ }
80
92
  async function openSafeParentDirectory(root, target) {
81
93
  await ensureSafeParentDirectory(root, target);
82
94
  return open(dirname(target), constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
@@ -175,8 +187,20 @@ export async function install(slugInput, opts = {}) {
175
187
  if (existing === remoteHash) {
176
188
  action = "unchanged";
177
189
  }
190
+ else if (existing !== null && opts.force) {
191
+ try {
192
+ await overwriteInstallFile(target, detail.body_md);
193
+ }
194
+ catch (err) {
195
+ const code = err.code;
196
+ if (code === "ELOOP")
197
+ throw new FloomError("Local path is a symbolic link.", "Move or delete the local path, then run `floom add` again.");
198
+ throw err;
199
+ }
200
+ action = "updated";
201
+ }
178
202
  else if (existing !== null) {
179
- throw new FloomError("Local skill already exists with different content.", "Move or delete the local file, then run `floom add` again.");
203
+ 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.");
180
204
  }
181
205
  else {
182
206
  try {
@@ -185,7 +209,7 @@ export async function install(slugInput, opts = {}) {
185
209
  catch (err) {
186
210
  const code = err.code;
187
211
  if (code === "EEXIST") {
188
- throw new FloomError("Local skill already exists with different content.", "Move or delete the local file, then run `floom add` again.");
212
+ 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.");
189
213
  }
190
214
  if (code === "ELOOP") {
191
215
  throw new FloomError("Local path is a symbolic link.", "Move or delete the local path, then run `floom add` again.");
@@ -201,6 +225,12 @@ export async function install(slugInput, opts = {}) {
201
225
  process.stdout.write(`\n${symbols.ok} [floom] ${action} ${c.bold(slug)}\n`);
202
226
  process.stdout.write(` ${c.dim(target)}\n\n`);
203
227
  process.stdout.write(` ${c.bold("Next")}\n`);
204
- process.stdout.write(` ${c.dim("1.")} Tell your agent to use ${c.bold(slug)} when it matches the task.\n`);
205
- process.stdout.write(` ${c.dim("2.")} One-time setup: ${c.cyan(setupCommand(targetAgent))}\n\n`);
228
+ if (opts.setup) {
229
+ process.stdout.write(` ${c.dim("1.")} Floom is connecting ${targetAgent === "claude" ? "Claude Code" : "Codex"} now.\n`);
230
+ process.stdout.write(` ${c.dim("2.")} Tell your agent to use ${c.bold(slug)} when it matches the task.\n\n`);
231
+ }
232
+ else {
233
+ process.stdout.write(` ${c.dim("1.")} Tell your agent to use ${c.bold(slug)} when it matches the task.\n`);
234
+ process.stdout.write(` ${c.dim("2.")} One-time setup: ${c.cyan(setupCommand(targetAgent))}\n\n`);
235
+ }
206
236
  }
package/dist/login.js CHANGED
@@ -8,7 +8,6 @@ const DEFAULT_PORT = 7456;
8
8
  const TIMEOUT_MS = 5 * 60 * 1000;
9
9
  export async function login() {
10
10
  const apiUrl = getApiUrl();
11
- const port = await pickPort();
12
11
  process.stdout.write(header());
13
12
  process.stdout.write(`${symbols.arrow} Opening browser to sign in with Google...\n\n`);
14
13
  const spinner = ora({
@@ -17,7 +16,7 @@ export async function login() {
17
16
  }).start();
18
17
  let tokens;
19
18
  try {
20
- tokens = await waitForCallback(port);
19
+ tokens = await waitForCallback();
21
20
  }
22
21
  catch (err) {
23
22
  spinner.stop();
@@ -57,16 +56,11 @@ export async function login() {
57
56
  process.stdout.write(`${symbols.ok} Signed in as ${c.bold(me.email ?? me.id)}\n`);
58
57
  process.stdout.write(` ${c.dim("Your token is saved at ~/.floom/config.json")}\n\n`);
59
58
  }
60
- /** Reserve a free port. Defaults to 7456 (must be in Supabase uri_allow_list). */
61
- async function pickPort() {
62
- // Supabase uri_allow_list whitelists 7456 explicitly. If it's busy, we fail
63
- // loudly rather than silently using a port that won't be allowed.
64
- return DEFAULT_PORT;
65
- }
66
- function waitForCallback(port) {
59
+ function waitForCallback() {
67
60
  return new Promise((resolve, reject) => {
68
61
  const apiUrl = getApiUrl();
69
62
  let settled = false;
63
+ let retriedEphemeralPort = false;
70
64
  const server = createServer((req, res) => {
71
65
  // CORS preflight from the browser bridge page.
72
66
  const origin = req.headers.origin ?? "*";
@@ -128,13 +122,27 @@ function waitForCallback(port) {
128
122
  server.close();
129
123
  }
130
124
  server.on("error", (err) => {
125
+ const code = err.code;
126
+ if (!settled && !retriedEphemeralPort && code === "EADDRINUSE") {
127
+ retriedEphemeralPort = true;
128
+ server.listen(0, "127.0.0.1");
129
+ return;
130
+ }
131
131
  if (settled)
132
132
  return;
133
133
  settled = true;
134
134
  clearTimeout(timer);
135
- reject(new FloomError(`Local auth server failed on port ${port}.`, `Is port ${port} already in use? (${err.message})`));
135
+ reject(new FloomError("Local auth server failed.", err.message));
136
136
  });
137
- server.listen(port, "127.0.0.1", () => {
137
+ server.listen(DEFAULT_PORT, "127.0.0.1", () => {
138
+ const address = server.address();
139
+ if (!address || typeof address === "string") {
140
+ settled = true;
141
+ cleanup();
142
+ reject(new FloomError("Could not reserve a local sign-in port."));
143
+ return;
144
+ }
145
+ const port = address.port;
138
146
  const target = `${apiUrl}/auth/cli?port=${port}`;
139
147
  open(target).catch((e) => {
140
148
  const msg = e instanceof Error ? e.message : String(e);
package/dist/publish.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { basename, resolve } from "node:path";
3
+ import { parse as parseYaml } from "yaml";
3
4
  import ora from "ora";
4
5
  import clipboard from "clipboardy";
5
6
  import { getWebUrl, readConfig, resolveApiUrl } from "./config.js";
@@ -34,21 +35,27 @@ function parseFrontmatter(input) {
34
35
  const headerBlock = trimmed.slice(3, end).trim();
35
36
  const rest = trimmed.slice(end + 4).replace(/^\r?\n/, "");
36
37
  const meta = {};
37
- const lines = headerBlock.split(/\r?\n/);
38
- for (let i = 0; i < lines.length; i++) {
39
- const rawLine = lines[i] ?? "";
40
- const line = rawLine.trim();
41
- if (!line || line.startsWith("#"))
38
+ let parsed;
39
+ try {
40
+ parsed = headerBlock ? parseYaml(headerBlock) : {};
41
+ }
42
+ catch (err) {
43
+ return {
44
+ meta,
45
+ body: rest,
46
+ error: {
47
+ message: err instanceof Error ? err.message.replace(/\n.*/s, "") : "Invalid YAML.",
48
+ line: yamlErrorLine(err),
49
+ },
50
+ };
51
+ }
52
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
53
+ return { meta, body: rest };
54
+ for (const [rawKey, rawValue] of Object.entries(parsed)) {
55
+ const key = rawKey.trim().toLowerCase();
56
+ const value = frontmatterScalar(rawValue);
57
+ if (value === undefined)
42
58
  continue;
43
- const colon = line.indexOf(":");
44
- if (colon === -1) {
45
- return { meta, body: rest, error: { message: `Couldn't parse line: \`${rawLine}\``, line: i + 2 } };
46
- }
47
- const key = line.slice(0, colon).trim().toLowerCase();
48
- let value = line.slice(colon + 1).trim();
49
- if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
50
- value = value.slice(1, -1);
51
- }
52
59
  if (key === "title"
53
60
  || key === "description"
54
61
  || key === "version"
@@ -67,6 +74,18 @@ function parseFrontmatter(input) {
67
74
  }
68
75
  return { meta, body: rest };
69
76
  }
77
+ function yamlErrorLine(err) {
78
+ const linePos = err.linePos;
79
+ const line = linePos?.[0]?.line;
80
+ return typeof line === "number" && Number.isFinite(line) ? line + 1 : 2;
81
+ }
82
+ function frontmatterScalar(value) {
83
+ if (typeof value === "string")
84
+ return value;
85
+ if (typeof value === "number" || typeof value === "boolean")
86
+ return String(value);
87
+ return undefined;
88
+ }
70
89
  function parseAssetType(value, source) {
71
90
  if (!value)
72
91
  return undefined;
@@ -193,8 +212,8 @@ export async function publish(opts) {
193
212
  process.stdout.write(` ${c.dim("Share it anywhere.")}\n\n`);
194
213
  }
195
214
  process.stdout.write(` ${c.bold("Next")}\n`);
196
- process.stdout.write(` ${c.dim("1.")} Test locally: ${c.cyan(`npx -y @floomhq/floom add ${humanUrl} --target claude`)}\n`);
215
+ process.stdout.write(` ${c.dim("1.")} Test locally: ${c.cyan(`npx -y @floomhq/floom add ${humanUrl} --setup`)}\n`);
197
216
  process.stdout.write(` ${c.dim("2.")} Send the link.\n`);
198
- process.stdout.write(` ${c.dim("3.")} Receiver runs ${c.cyan(`npx -y @floomhq/floom add ${humanUrl} --target claude`)}\n`);
217
+ process.stdout.write(` ${c.dim("3.")} Receiver runs ${c.cyan(`npx -y @floomhq/floom add ${humanUrl} --setup`)}\n`);
199
218
  process.stdout.write(` ${c.dim("4.")} Agent reads the installed Markdown from the local skills folder.\n\n`);
200
219
  }
package/dist/scan.js CHANGED
@@ -17,6 +17,9 @@ export async function scanSkill(file) {
17
17
  throw new FloomError(`That's a directory, not a file: ${file}`);
18
18
  throw new FloomError(`Couldn't read ${file}: ${err.message}`);
19
19
  }
20
+ if (!raw.trim()) {
21
+ throw new FloomError(`File is empty: ${file}`, "Add skill instructions before scanning or publishing.");
22
+ }
20
23
  const findings = detectSkillSecurityFindings(raw);
21
24
  if (findings.length > 0) {
22
25
  throw new FloomError("Security scan failed.", `${formatSecurityFindings(findings)}\nRemove secrets, prompt-injection text, or data-exfiltration instructions before publishing.`);
package/dist/secrets.js CHANGED
@@ -12,7 +12,8 @@ const SECRET_PATTERNS = [
12
12
  { label: "Private key", regex: /-----BEGIN (?:RSA |EC |OPENSSH |)PRIVATE KEY-----/g },
13
13
  ];
14
14
  const GENERIC_ASSIGNMENT_RE = /\b(?:api[_-]?key|secret|access[_-]?token|auth[_-]?token|bearer[_-]?token)\b\s*[:=]\s*["']?([A-Za-z0-9_./+=-]{24,})["']?/gi;
15
- const PLACEHOLDER_RE = /^(?:your|example|placeholder|replace|changeme|todo|xxx|test|demo|dummy|redacted)/i;
15
+ const PROVIDER_LIKE_ASSIGNMENT_RE = /\b(?:api[_-]?key|secret|access[_-]?token|auth[_-]?token|bearer[_-]?token)\b\s*[:=]\s*["']?((?:sk|pk|rk)-[A-Za-z0-9_-]{8,}|sbp_[A-Za-z0-9]{12,}|xox[baprs]-[A-Za-z0-9-]{12,})["']?/gi;
16
+ const PLACEHOLDER_RE = /(?:^|[_./+=-])(?:your|example|placeholder|replace|changeme|todo|xxx|test|demo|dummy|fake|redacted)(?:$|[_./+=-])/i;
16
17
  const PROMPT_INJECTION_PATTERNS = [
17
18
  { label: "Prompt injection instruction", regex: /\bignore (?:all )?(?:previous|prior|above|earlier) instructions\b/gi },
18
19
  { label: "Prompt injection instruction", regex: /\bdisregard (?:all )?(?:previous|prior|above|earlier) instructions\b/gi },
@@ -62,6 +63,13 @@ export function detectSecrets(input) {
62
63
  continue;
63
64
  pushFinding(findings, seen, "Possible secret assignment", lineNumberAt(input, match.index ?? 0), value);
64
65
  }
66
+ PROVIDER_LIKE_ASSIGNMENT_RE.lastIndex = 0;
67
+ for (const match of input.matchAll(PROVIDER_LIKE_ASSIGNMENT_RE)) {
68
+ const value = match[1] ?? "";
69
+ if (!value)
70
+ continue;
71
+ pushFinding(findings, seen, "Provider-like secret assignment", lineNumberAt(input, match.index ?? 0), value);
72
+ }
65
73
  return findings.sort((a, b) => a.line - b.line || a.label.localeCompare(b.label));
66
74
  }
67
75
  function detectPatternFindings(input, patterns, category) {
package/dist/ui.js CHANGED
@@ -1,12 +1,9 @@
1
1
  import pc from "picocolors";
2
- // Coral / teal palette to match the warm Lovable vibe.
3
- // picocolors only supports ANSI named colors, so we map:
4
- // - coral / primary action: yellow (warm) for the dot, red for emphasis when needed
5
- // - success: green
6
- // - muted: gray (dim)
2
+ // Cool, restrained terminal palette. Keep orange out of the default CLI surface.
7
3
  const isTty = process.stdout.isTTY === true;
8
4
  export const c = {
9
- coral: (s) => (isTty ? `\x1b[38;5;209m${s}\x1b[0m` : s),
5
+ coral: (s) => (isTty ? `\x1b[38;5;45m${s}\x1b[0m` : s),
6
+ blue: (s) => (isTty ? `\x1b[38;5;75m${s}\x1b[0m` : s),
10
7
  teal: (s) => (isTty ? `\x1b[38;5;73m${s}\x1b[0m` : s),
11
8
  green: pc.green,
12
9
  red: pc.red,
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@floomhq/floom",
3
- "version": "1.0.5",
3
+ "version": "1.0.7",
4
4
  "description": "Publish AI skills from your terminal. Share with a link.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "bin": {
8
- "floom": "bin/floom.js"
8
+ "floom-skills": "bin/floom.js"
9
9
  },
10
10
  "files": [
11
11
  "bin",
@@ -30,7 +30,8 @@
30
30
  "open": "10.1.0",
31
31
  "ora": "8.1.1",
32
32
  "picocolors": "1.1.1",
33
- "update-notifier": "7.3.1"
33
+ "update-notifier": "7.3.1",
34
+ "yaml": "2.8.4"
34
35
  },
35
36
  "devDependencies": {
36
37
  "@types/node": "22.10.5",