@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 +37 -33
- package/dist/cli.js +85 -21
- package/dist/errors.js +11 -1
- package/dist/init.js +154 -6
- package/dist/install.js +26 -2
- package/dist/login.js +19 -11
- package/dist/publish.js +43 -22
- package/dist/scan.js +3 -0
- package/dist/secrets.js +9 -1
- package/package.json +4 -3
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
|
-
|
|
7
|
-
floom init
|
|
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
|
-
|
|
14
|
-
floom
|
|
15
|
-
floom
|
|
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 `--
|
|
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
|
|
31
|
-
- `floom
|
|
32
|
-
- `floom
|
|
33
|
-
- `floom
|
|
34
|
-
- `floom
|
|
35
|
-
- `floom
|
|
36
|
-
- `floom library
|
|
37
|
-
- `floom library
|
|
38
|
-
- `floom library
|
|
39
|
-
- `floom
|
|
40
|
-
- `floom
|
|
41
|
-
- `floom
|
|
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
|
|
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
|
|
68
|
-
|
|
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
|
|
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")}
|
|
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
|
-
|
|
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=")
|
|
177
|
-
const
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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)} --
|
|
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.", "
|
|
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.", "
|
|
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(
|
|
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
|
-
|
|
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(
|
|
135
|
+
reject(new FloomError("Local auth server failed.", err.message));
|
|
136
136
|
});
|
|
137
|
-
server.listen(
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
179
|
-
|
|
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.
|
|
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("
|
|
196
|
-
process.stdout.write(` ${c.
|
|
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
|
|
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.
|
|
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",
|