@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 +17 -16
- package/dist/cli.js +68 -43
- package/dist/errors.js +11 -1
- package/dist/install.js +34 -4
- package/dist/login.js +19 -11
- package/dist/publish.js +35 -16
- package/dist/scan.js +3 -0
- package/dist/secrets.js +9 -1
- package/dist/ui.js +3 -6
- package/package.json +4 -3
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
|
-
|
|
7
|
-
floom
|
|
8
|
-
floom
|
|
9
|
-
floom
|
|
10
|
-
|
|
11
|
-
floom
|
|
12
|
-
floom
|
|
13
|
-
|
|
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
|
|
68
|
-
|
|
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.
|
|
28
|
-
${c.
|
|
29
|
-
${c.
|
|
30
|
-
${c.
|
|
31
|
-
${c.
|
|
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("
|
|
34
|
-
${c.dim("
|
|
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("
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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("
|
|
52
|
-
|
|
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("
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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")}
|
|
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=")
|
|
174
|
-
const
|
|
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
|
|
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> --
|
|
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> --
|
|
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> --
|
|
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,
|
|
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.", "
|
|
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.");
|
|
@@ -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
|
-
|
|
205
|
-
|
|
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(
|
|
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;
|
|
@@ -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} --
|
|
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} --
|
|
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
|
|
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
|
-
//
|
|
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;
|
|
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.
|
|
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",
|