@floomhq/floom 1.0.6 → 1.0.8

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,48 +3,52 @@
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 init brand-voice.md --template brand-voice
8
+ npx -y @floomhq/floom login
9
+ npx -y @floomhq/floom publish my-skill.md --share teammate@example.com
10
+ npx -y @floomhq/floom share my-skill --add teammate@example.com
11
+ npx -y @floomhq/floom search review
12
+ npx -y @floomhq/floom add awesome-skill --setup
13
+ npx -y @floomhq/floom agent-prompt
14
+ npx -y @floomhq/floom setup --target claude --dry-run
15
+ npx -y @floomhq/floom list
16
+ npx -y @floomhq/floom library list
16
17
  ```
17
18
 
18
19
  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
20
 
21
+ The package is designed for `npx -y @floomhq/floom ...`. Global installs expose `floom-skills` to avoid colliding with any older local Floom runtime CLI.
22
+
20
23
  ## Commands
21
24
 
22
- - `floom login` — sign in with Google. New accounts are created on first login. Token stored at `~/.floom/config.json`.
23
- - `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
- - `floom share <slug>` — email-share one of your skills. Optional `--add <email>`, `--remove <email>`, and `--list`.
26
- - `floom list` — show your published skills. Optional `--json`.
27
- - `floom add <url-or-slug>` — fetch a skill into `~/.claude/skills/<slug>.md`.
28
- - `floom info <url-or-slug>` — show skill metadata. Optional `--json`.
29
- - `floom search <query>` — search public skills and starter libraries. Optional `--library <slug>`, `--type knowledge|instruction|workflow|skill`, and `--json`.
30
- - `floom setup` — add Floom usage guidance to `CLAUDE.md` or `AGENTS.md`. Optional `--target claude|codex`, `--dry-run`, `--yes`.
31
- - `floom connect` — alias for `floom setup`.
32
- - `floom mcp` — print MCP setup commands for supported agent CLIs.
33
- - `floom sync` — preview: pull your published, saved, and subscribed library skills into `~/.claude/skills/`.
34
- - `floom watch` — preview: run `floom sync` repeatedly. Optional `--interval <seconds>`; minimum `10`.
35
- - `floom library list` — list public starter libraries. Optional `--json`.
36
- - `floom library create <slug> --name <name>` create a personal or starter library. Optional `--public` / `--private` / `--unlisted`.
37
- - `floom library add <library> <skill> [--folder <path>] [--tags a,b]` add a skill to a library.
38
- - `floom library subscribe <slug>` subscribe to a public or unlisted library so sync can pull it locally.
39
- - `floom move <slug> --folder <path>` — set your local folder override for a saved or library skill.
40
- - `floom delete <url-or-slug>` — delete one of your published skills. Optional `--yes`.
41
- - `floom doctor`diagnose your Floom setup.
25
+ - `npx -y @floomhq/floom login` — sign in with Google. New accounts are created on first login. Token stored at `~/.floom/config.json`.
26
+ - `npx -y @floomhq/floom init [file.md]` — create a starter skill file. Optional `--template generic|brand-voice|pr-review|sales|support|onboarding`.
27
+ - `npx -y @floomhq/floom publish <file.md>` — scan and upload a Markdown file. Optional `--public` / `--private` / `--unlisted`, `--type knowledge|instruction|workflow|skill`, `--installs-as <target>`, `--skill-version <label>`, and `--share <email>`. `--share` sends the normal link by email; no account is needed to add unlisted or public links.
28
+ - `npx -y @floomhq/floom share <slug>` — email-share one of your skills. Optional `--add <email>`, `--remove <email>`, and `--list`.
29
+ - `npx -y @floomhq/floom list` — show your published skills. Optional `--json`.
30
+ - `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.
31
+ - `npx -y @floomhq/floom info <url-or-slug>` — show skill metadata. Optional `--json`.
32
+ - `npx -y @floomhq/floom search <query>` — search public skills and starter libraries. Optional `--library <slug>`, `--type knowledge|instruction|workflow|skill`, and `--json`.
33
+ - `npx -y @floomhq/floom agent-prompt` — print the sentence to paste into Claude Code or Codex.
34
+ - `npx -y @floomhq/floom setup` — add Floom usage guidance to `CLAUDE.md` or `AGENTS.md`. Optional `--target claude|codex`, `--dry-run`, `--yes`.
35
+ - `npx -y @floomhq/floom connect` — alias for setup.
36
+ - `npx -y @floomhq/floom mcp` — print MCP setup commands for supported agent CLIs.
37
+ - `npx -y @floomhq/floom sync` — preview: pull your published, saved, and subscribed library skills into `~/.claude/skills/`.
38
+ - `npx -y @floomhq/floom watch` — preview: run sync repeatedly. Optional `--interval <seconds>`; minimum `10`.
39
+ - `npx -y @floomhq/floom library list`list public starter libraries. Optional `--json`.
40
+ - `npx -y @floomhq/floom library create <slug> --name <name>`create a personal or starter library. Optional `--public` / `--private` / `--unlisted`.
41
+ - `npx -y @floomhq/floom library add <library> <skill> [--folder <path>] [--tags a,b]` add a skill to a library.
42
+ - `npx -y @floomhq/floom library subscribe <slug>` — subscribe to a public or unlisted library so sync can pull it locally.
43
+ - `npx -y @floomhq/floom move <slug> --folder <path>` — set your local folder override for a saved or library skill.
44
+ - `npx -y @floomhq/floom delete <url-or-slug>` delete one of your published skills. Optional `--yes`.
45
+ - `npx -y @floomhq/floom doctor` — diagnose your Floom setup.
42
46
  - `floom whoami` — show the signed-in account.
43
47
  - `floom logout` — delete local credentials.
44
48
 
45
49
  ## Skill format
46
50
 
47
- Optional YAML-ish frontmatter (`title`, `description`, `version`), then freeform Markdown.
51
+ Optional YAML frontmatter (`title`, `description`, `version`, `type`, `installs_as`), then freeform Markdown.
48
52
 
49
53
  ```markdown
50
54
  ---
@@ -64,5 +68,5 @@ Override the API host with `FLOOM_API_URL` (defaults to `https://floom.dev`).
64
68
  `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
69
  The manifest records hashes for files Floom previously wrote. Version 1 sync writes missing files
66
70
  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.
71
+ conflicts. Symlinks are never followed. To replace a local skill manually, run
72
+ `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";
@@ -63,22 +63,25 @@ function usage() {
63
63
  }
64
64
  function commandUsage() {
65
65
  const out = `
66
- ${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]")}
67
68
 
68
69
  ${c.bold("Commands")}
69
70
  ${c.dim("Skills")}
70
71
  ${c.cyan("add")} ${c.dim("<url>")} Install a skill into the local agent skills folder
71
72
  ${c.dim("Alias: install")}
72
- ${c.dim("Flags: --target claude|codex (default: claude), --setup")}
73
+ ${c.dim("Flags: --target claude|codex (default: claude), --setup, --force")}
73
74
  ${c.cyan("search")} ${c.dim("<query>")} Find public skills and libraries
74
75
  ${c.cyan("info")} ${c.dim("<url>")} Show skill metadata
75
76
 
76
77
  ${c.dim("Publishing")}
77
78
  ${c.cyan("init")} ${c.dim("[path]")} Create a skill scaffold
79
+ ${c.dim("Flags: --template generic|brand-voice|pr-review|sales|support|onboarding")}
78
80
  ${c.cyan("scan")} ${c.dim("<path>")} Check for secrets, injection, exfiltration
79
81
  ${c.cyan("publish")} ${c.dim("<path>")} Scan, publish, and print a share link
80
82
  ${c.dim("Flags: --public, --private, --type knowledge|instruction|workflow|skill")}
81
- ${c.dim(" --skill-version <label>")}
83
+ ${c.dim(" --skill-version <label>, --share <email>")}
84
+ ${c.dim(" --share emails the normal link; no account is needed to add it")}
82
85
  ${c.cyan("share")} ${c.dim("<slug>")} Email-share one of your skills
83
86
  ${c.dim("Flags: --add <email>, --remove <email>, --list")}
84
87
 
@@ -94,6 +97,8 @@ function commandUsage() {
94
97
  ${c.cyan("setup")} Configure Claude Code or Codex instructions
95
98
  ${c.dim("Alias: connect")}
96
99
  ${c.dim("Flags: --target claude|codex, --yes, --dry-run")}
100
+ ${c.cyan("agent-prompt")} Print the sentence to paste into your agent
101
+ ${c.dim("Alias: paste")}
97
102
  ${c.cyan("doctor")} Troubleshoot auth, API, and local folders
98
103
 
99
104
  ${c.dim("Advanced")}
@@ -105,20 +110,22 @@ function commandUsage() {
105
110
  ${c.cyan("watch")} Preview polling sync loop
106
111
 
107
112
  ${c.bold("Examples")}
108
- ${c.cyan("floom add")} ${c.dim("https://floom.dev/s/ffas93ud")}
109
- ${c.cyan("floom publish")} ${c.dim("support-tone.md --type instruction --public")}
110
- ${c.cyan("floom setup")} ${c.dim("--target claude --yes")}
113
+ ${c.cyan("npx -y @floomhq/floom add")} ${c.dim("https://floom.dev/s/ffas93ud --setup")}
114
+ ${c.cyan("npx -y @floomhq/floom publish")} ${c.dim("support-tone.md --type instruction --public")}
115
+ ${c.cyan("npx -y @floomhq/floom publish")} ${c.dim("support-tone.md --share teammate@example.com")}
116
+ ${c.cyan("npx -y @floomhq/floom setup")} ${c.dim("--target claude --yes")}
111
117
 
112
118
  ${c.bold("Help")}
113
- ${c.cyan("floom commands")} Show this reference
119
+ ${c.cyan("npx -y @floomhq/floom commands")} Show this reference
114
120
  ${c.cyan("--help")} Show this reference
115
121
  ${c.cyan("--version")} Show CLI version
116
122
 
117
- ${c.dim("Run")} ${c.cyan("floom")} ${c.dim("with no command for the guided start screen.")}
123
+ ${c.dim("Run")} ${c.cyan("npx -y @floomhq/floom")} ${c.dim("with no command for the guided start screen.")}
118
124
  `;
119
125
  process.stdout.write(out);
120
126
  }
121
127
  const ASSET_TYPES = new Set(["knowledge", "instruction", "workflow", "skill"]);
128
+ const INIT_TEMPLATES = new Set(["generic", "brand-voice", "pr-review", "sales", "support", "onboarding"]);
122
129
  const INSTALL_TARGETS = new Set([
123
130
  "claude_skill",
124
131
  "memory",
@@ -139,7 +146,7 @@ function readFlagValue(argv, index, flag) {
139
146
  return { value, nextIndex: index + 1 };
140
147
  }
141
148
  function parseFlags(argv) {
142
- const out = { visibility: "unlisted", update: false, rest: [] };
149
+ const out = { visibility: "unlisted", update: false, shareEmails: [], explicitVisibility: false, rest: [] };
143
150
  let visibilityFlag = null;
144
151
  for (let i = 0; i < argv.length; i++) {
145
152
  const a = argv[i] ?? "";
@@ -150,12 +157,15 @@ function parseFlags(argv) {
150
157
  }
151
158
  visibilityFlag = nextVisibility;
152
159
  out.visibility = nextVisibility;
160
+ out.explicitVisibility = true;
153
161
  }
154
162
  else if (a === "--update") {
155
163
  throw new FloomError(V1_NOT_AVAILABLE, "`floom publish --update` is planned for a later Floom release.");
156
164
  }
157
165
  else if (a === "--share" || a.startsWith("--share=")) {
158
- throw new FloomError(V1_NOT_AVAILABLE, "`floom publish --share` is planned for a later Floom release.");
166
+ const { value, nextIndex } = readFlagValue(argv, i, "--share");
167
+ out.shareEmails.push(...parseEmailList(value, "--share"));
168
+ i = nextIndex;
159
169
  }
160
170
  else if (a === "--type" || a.startsWith("--type=")) {
161
171
  const { value, nextIndex } = readFlagValue(argv, i, "--type");
@@ -173,23 +183,48 @@ function parseFlags(argv) {
173
183
  out.installsAs = value;
174
184
  i = nextIndex;
175
185
  }
176
- else if (a === "--skill-version" || a.startsWith("--skill-version=") || a === "--version" || a.startsWith("--version=")) {
177
- const flagName = a.startsWith("--skill-version") ? "--skill-version" : "--version";
178
- const { value, nextIndex } = readFlagValue(argv, i, flagName);
186
+ else if (a === "--skill-version" || a.startsWith("--skill-version=")) {
187
+ const { value, nextIndex } = readFlagValue(argv, i, "--skill-version");
179
188
  if (!VERSION_RE.test(value)) {
180
- throw new FloomError(`Invalid ${flagName}: ${value}`, "Use 1-64 characters: letters, numbers, dots, underscores, plus, or hyphen.");
189
+ throw new FloomError(`Invalid --skill-version: ${value}`, "Use 1-64 characters: letters, numbers, dots, underscores, plus, or hyphen.");
181
190
  }
182
191
  out.version = value;
183
192
  i = nextIndex;
184
193
  }
194
+ else if (a === "--version" || a.startsWith("--version=")) {
195
+ throw new FloomError("`--version` prints the Floom CLI version at the top level.", "For skill version labels, use `--skill-version <label>`.");
196
+ }
185
197
  else if (a.startsWith("--")) {
186
198
  throw new FloomError(`Unknown flag: ${a}`, "Try `floom publish skill.md --type instruction --public`.");
187
199
  }
188
200
  else
189
201
  out.rest.push(a);
190
202
  }
203
+ if (out.shareEmails.length > 0) {
204
+ out.shareEmails = dedupeEmails(out.shareEmails);
205
+ if (out.shareEmails.length > 200) {
206
+ throw new FloomError("Too many --share recipients.", "Use 200 email addresses or fewer.");
207
+ }
208
+ if (out.visibility === "private") {
209
+ throw new FloomError("`--private --share` would email a link recipients cannot open.", "Use `--unlisted --share` for invite emails, or `floom share <slug> --add <email>` for email-gated access after publishing.");
210
+ }
211
+ }
191
212
  return out;
192
213
  }
214
+ function parseEmailList(value, source) {
215
+ const emails = value.split(",").map((email) => email.trim().toLowerCase()).filter(Boolean);
216
+ if (emails.length === 0)
217
+ throw new FloomError(`Missing value for ${source}.`, `Try \`${source} teammate@example.com\`.`);
218
+ for (const email of emails) {
219
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
220
+ throw new FloomError(`Invalid email for ${source}: ${email}`, "Use an address like teammate@example.com.");
221
+ }
222
+ }
223
+ return emails;
224
+ }
225
+ function dedupeEmails(emails) {
226
+ return [...new Set(emails)];
227
+ }
193
228
  function parseShareFlags(argv) {
194
229
  const out = { list: false, add: [], remove: [] };
195
230
  for (let i = 0; i < argv.length; i++) {
@@ -229,16 +264,27 @@ function parseShareFlags(argv) {
229
264
  }
230
265
  function parseInitArgs(argv) {
231
266
  let file;
232
- for (const a of argv) {
267
+ let template = "generic";
268
+ for (let i = 0; i < argv.length; i++) {
269
+ const a = argv[i] ?? "";
270
+ if (a === "--template" || a.startsWith("--template=")) {
271
+ const { value, nextIndex } = readFlagValue(argv, i, "--template");
272
+ if (!INIT_TEMPLATES.has(value)) {
273
+ throw new FloomError(`Invalid --template: ${value}`, "Use one of: generic, brand-voice, pr-review, sales, support, onboarding.");
274
+ }
275
+ template = value;
276
+ i = nextIndex;
277
+ continue;
278
+ }
233
279
  if (a.startsWith("--")) {
234
- throw new FloomError(`Unknown flag: ${a}`, "Try `floom init skill.md`.");
280
+ throw new FloomError(`Unknown flag: ${a}`, "Try `floom init skill.md --template brand-voice`.");
235
281
  }
236
282
  if (file) {
237
283
  throw new FloomError(`Unexpected argument: ${a}`, "Try `floom init skill.md`.");
238
284
  }
239
285
  file = a;
240
286
  }
241
- return file ? { file } : {};
287
+ return file ? { file, template } : { template };
242
288
  }
243
289
  function parseListFlags(argv) {
244
290
  const out = { json: false };
@@ -272,6 +318,7 @@ function parseAddArgs(argv) {
272
318
  let slug;
273
319
  let target;
274
320
  let setup = false;
321
+ let force = false;
275
322
  for (let i = 0; i < argv.length; i++) {
276
323
  const a = argv[i] ?? "";
277
324
  if (a === "--target" || a.startsWith("--target=")) {
@@ -285,6 +332,9 @@ function parseAddArgs(argv) {
285
332
  else if (a === "--setup") {
286
333
  setup = true;
287
334
  }
335
+ else if (a === "--force") {
336
+ force = true;
337
+ }
288
338
  else if (a.startsWith("--")) {
289
339
  throw new FloomError(`Unknown flag: ${a}`, "Try `floom add <url-or-slug> --setup`.");
290
340
  }
@@ -297,7 +347,7 @@ function parseAddArgs(argv) {
297
347
  if (!slug) {
298
348
  throw new FloomError("Missing skill slug.", "Try: `floom add <url-or-slug> --setup`");
299
349
  }
300
- return target ? { slug, target, setup } : { slug, setup };
350
+ return target ? { slug, target, setup, force } : { slug, setup, force };
301
351
  }
302
352
  function parseSearchFlags(argv) {
303
353
  const out = { json: false };
@@ -559,6 +609,9 @@ function parseSingleFileArg(argv, usageHint) {
559
609
  throw new FloomError("Missing file argument.", usageHint);
560
610
  return file;
561
611
  }
612
+ function agentPrompt() {
613
+ process.stdout.write("Use my installed Floom skills when they fit the task. Search ~/.claude/skills first.\n");
614
+ }
562
615
  function sleep(ms, signal) {
563
616
  if (signal.aborted)
564
617
  return Promise.resolve();
@@ -571,6 +624,10 @@ function sleep(ms, signal) {
571
624
  });
572
625
  }
573
626
  async function watch(intervalSeconds) {
627
+ const cfg = await readConfig();
628
+ if (!cfg) {
629
+ 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.");
630
+ }
574
631
  const controller = new AbortController();
575
632
  let stopping = false;
576
633
  const stop = () => {
@@ -644,7 +701,7 @@ async function main() {
644
701
  return;
645
702
  case "init": {
646
703
  const flags = parseInitArgs(rest);
647
- await init(flags.file);
704
+ await init(flags.file, flags.template);
648
705
  return;
649
706
  }
650
707
  case "publish": {
@@ -663,6 +720,7 @@ async function main() {
663
720
  ...(flags.assetType ? { assetType: flags.assetType } : {}),
664
721
  ...(flags.installsAs ? { installsAs: flags.installsAs } : {}),
665
722
  ...(flags.version ? { version: flags.version } : {}),
723
+ ...(flags.shareEmails.length > 0 ? { sharedWithEmails: flags.shareEmails } : {}),
666
724
  });
667
725
  return;
668
726
  }
@@ -712,6 +770,7 @@ async function main() {
712
770
  await install(flags.slug, {
713
771
  ...(flags.target ? { target: flags.target } : {}),
714
772
  setup: flags.setup,
773
+ force: flags.force,
715
774
  });
716
775
  if (flags.setup) {
717
776
  await setupAgent({ target: flags.target ?? "claude", dryRun: false, yes: true });
@@ -728,6 +787,11 @@ async function main() {
728
787
  await setupAgent(flags);
729
788
  return;
730
789
  }
790
+ case "agent-prompt":
791
+ case "paste":
792
+ rejectArgs(rest, "Try `floom agent-prompt`.");
793
+ agentPrompt();
794
+ return;
731
795
  case "watch": {
732
796
  const flags = parseWatchFlags(rest);
733
797
  await watch(flags.intervalSeconds);
@@ -771,7 +835,7 @@ async function main() {
771
835
  }
772
836
  }
773
837
  catch (e) {
774
- printError(e);
838
+ printError(e, { json: rest.includes("--json") });
775
839
  process.exit(1);
776
840
  }
777
841
  }
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/init.js CHANGED
@@ -4,7 +4,8 @@ import { createInterface } from "node:readline/promises";
4
4
  import { stdin as input, stdout as output } from "node:process";
5
5
  import { c, symbols } from "./ui.js";
6
6
  import { FloomError } from "./errors.js";
7
- const TEMPLATE = `---
7
+ const TEMPLATES = {
8
+ generic: `---
8
9
  title:
9
10
  description:
10
11
  version: 1.0
@@ -26,10 +27,156 @@ version: 1.0
26
27
 
27
28
 
28
29
  # Examples
29
- `;
30
- export async function init(filename) {
30
+ `,
31
+ "brand-voice": `---
32
+ title: Brand voice
33
+ description: Help an agent write in our company voice.
34
+ type: knowledge
35
+ installs_as: memory
36
+ version: 1.0
37
+ ---
38
+
39
+ # Brand Voice
40
+
41
+ ## Use when
42
+ - Writing customer-facing copy
43
+ - Rewriting drafts to match our tone
44
+ - Reviewing messaging before it ships
45
+
46
+ ## Voice rules
47
+ - Sound clear, direct, and useful.
48
+ - Prefer concrete nouns and short sentences.
49
+ - Avoid hype, filler, and generic AI language.
50
+
51
+ ## Words we use
52
+ - Replace this list with approved terms.
53
+
54
+ ## Words we avoid
55
+ - Replace this list with banned or overused terms.
56
+
57
+ ## Examples
58
+ - Before:
59
+ - After:
60
+ `,
61
+ "pr-review": `---
62
+ title: PR review
63
+ description: Review code changes with risk-first feedback.
64
+ type: workflow
65
+ installs_as: claude_skill
66
+ version: 1.0
67
+ ---
68
+
69
+ # PR Review
70
+
71
+ ## Use when
72
+ - Reviewing a pull request or diff
73
+ - Checking a change before merge
74
+
75
+ ## Review order
76
+ 1. Correctness and regressions
77
+ 2. Security and data safety
78
+ 3. Tests and missing edge cases
79
+ 4. Maintainability
80
+
81
+ ## Output
82
+ - Lead with findings.
83
+ - Include file paths and line references.
84
+ - Keep style comments out unless they affect behavior.
85
+ - If no issues are found, say that clearly and name any test gaps.
86
+ `,
87
+ sales: `---
88
+ title: Sales research
89
+ description: Prepare concise account research and outreach context.
90
+ type: workflow
91
+ installs_as: memory
92
+ version: 1.0
93
+ ---
94
+
95
+ # Sales Research
96
+
97
+ ## Use when
98
+ - Preparing for a prospect call
99
+ - Writing a relevant outbound message
100
+ - Summarizing account context for the team
101
+
102
+ ## Gather
103
+ - Company
104
+ - Buyer persona
105
+ - Recent trigger
106
+ - Likely pain
107
+ - Existing tools or workflow
108
+
109
+ ## Output
110
+ - 5 bullet account summary
111
+ - 3 likely pain points
112
+ - 2 tailored opener angles
113
+ - 1 clear next action
114
+ `,
115
+ support: `---
116
+ title: Support tone
117
+ description: Answer support tickets with a clear and calm company voice.
118
+ type: instruction
119
+ installs_as: memory
120
+ version: 1.0
121
+ ---
122
+
123
+ # Support Tone
124
+
125
+ ## Use when
126
+ - Replying to customer support messages
127
+ - Summarizing customer issues
128
+ - Drafting escalation notes
129
+
130
+ ## Rules
131
+ - Acknowledge the issue in plain language.
132
+ - Give the next concrete step.
133
+ - Do not over-apologize.
134
+ - Do not invent product behavior.
135
+ - Escalate when data, billing, or account access is involved.
136
+
137
+ ## Reply shape
138
+ 1. Short acknowledgement
139
+ 2. Direct answer or next step
140
+ 3. What happens next
141
+ `,
142
+ onboarding: `---
143
+ title: Team onboarding
144
+ description: Help a new teammate understand how this team works.
145
+ type: knowledge
146
+ installs_as: memory
147
+ version: 1.0
148
+ ---
149
+
150
+ # Team Onboarding
151
+
152
+ ## Use when
153
+ - A new teammate asks how work gets done
154
+ - An agent needs company or team context
155
+ - Creating first-week task plans
156
+
157
+ ## Team context
158
+ - Mission:
159
+ - Customers:
160
+ - Current priorities:
161
+ - Tools:
162
+
163
+ ## How we work
164
+ - Decision rules:
165
+ - Review process:
166
+ - Communication norms:
167
+ - Definition of done:
168
+
169
+ ## First-week checklist
170
+ - Read:
171
+ - Set up:
172
+ - Ask:
173
+ - Ship:
174
+ `,
175
+ };
176
+ export async function init(filename, template = "generic") {
31
177
  const target = filename ?? "skill.md";
32
178
  const filePath = resolve(process.cwd(), target);
179
+ const body = TEMPLATES[template];
33
180
  const exists = await fileExists(filePath);
34
181
  if (exists) {
35
182
  if (!process.stdin.isTTY) {
@@ -44,7 +191,7 @@ export async function init(filename) {
44
191
  }
45
192
  }
46
193
  try {
47
- await writeFile(filePath, TEMPLATE, "utf8");
194
+ await writeFile(filePath, body, "utf8");
48
195
  }
49
196
  catch (err) {
50
197
  const code = err.code;
@@ -57,10 +204,11 @@ export async function init(filename) {
57
204
  throw new FloomError(`Couldn't create ${target}: ${err.message}`);
58
205
  }
59
206
  process.stdout.write(`\n${symbols.ok} Created ${c.bold(basename(filePath))}\n`);
207
+ process.stdout.write(` ${c.dim(`Template: ${template}`)}\n`);
60
208
  process.stdout.write(`\n ${c.bold("Next")}\n`);
61
209
  process.stdout.write(` ${c.dim("1.")} Fill in the title, description, and instructions.\n`);
62
- process.stdout.write(` ${c.dim("2.")} Check it: ${c.cyan(`floom scan ${shellQuote(target)}`)}\n`);
63
- process.stdout.write(` ${c.dim("3.")} Publish: ${c.cyan(`floom publish ${shellQuote(target)} --type instruction --public`)}\n\n`);
210
+ process.stdout.write(` ${c.dim("2.")} Check it: ${c.cyan(`npx -y @floomhq/floom scan ${shellQuote(target)}`)}\n`);
211
+ process.stdout.write(` ${c.dim("3.")} Publish: ${c.cyan(`npx -y @floomhq/floom publish ${shellQuote(target)} --public`)}\n\n`);
64
212
  }
65
213
  function shellQuote(value) {
66
214
  if (/^[A-Za-z0-9_./:@%+=,-]+$/.test(value))
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.");
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;
@@ -173,10 +192,15 @@ export async function publish(opts) {
173
192
  spinner.stop();
174
193
  const versionTag = version ? c.dim(` (${formatVersionLabel(version)})`) : "";
175
194
  const titleLabel = data.title ? `"${data.title}"` : opts.file;
195
+ const invitedEmails = opts.sharedWithEmails ?? [];
176
196
  process.stdout.write(`\n${symbols.ok} Published ${c.bold(titleLabel)}${versionTag}\n\n`);
197
+ process.stdout.write(` ${c.bold("Send this to someone:")}\n`);
177
198
  process.stdout.write(` ${c.cyan(humanUrl)}\n\n`);
178
- if (data.visibility === "shared" && data.shared_with_emails?.length) {
179
- process.stdout.write(` ${c.dim(`Shared with ${data.shared_with_emails.join(", ")}`)}\n\n`);
199
+ process.stdout.write(` ${c.bold("They run:")}\n`);
200
+ process.stdout.write(` ${c.cyan(`npx -y @floomhq/floom add ${humanUrl} --setup`)}\n\n`);
201
+ if (invitedEmails.length) {
202
+ process.stdout.write(` ${c.bold("Email invite:")}\n`);
203
+ process.stdout.write(` ${c.dim(invitedEmails.join(", "))}\n\n`);
180
204
  }
181
205
  let copied = false;
182
206
  try {
@@ -187,14 +211,11 @@ export async function publish(opts) {
187
211
  copied = false;
188
212
  }
189
213
  if (copied) {
190
- process.stdout.write(` ${c.dim("Copied to clipboard. Share it anywhere.")}\n\n`);
214
+ process.stdout.write(` ${c.dim("Copied link to clipboard.")}\n\n`);
191
215
  }
192
216
  else {
193
217
  process.stdout.write(` ${c.dim("Share it anywhere.")}\n\n`);
194
218
  }
195
- 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`);
197
- 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`);
199
- process.stdout.write(` ${c.dim("4.")} Agent reads the installed Markdown from the local skills folder.\n\n`);
219
+ process.stdout.write(` ${c.bold("Agent prompt:")}\n`);
220
+ process.stdout.write(` ${c.cyan("npx -y @floomhq/floom agent-prompt")}\n\n`);
200
221
  }
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/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@floomhq/floom",
3
- "version": "1.0.6",
3
+ "version": "1.0.8",
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",