@floomhq/floom 1.0.3 → 1.0.5
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 +2 -0
- package/dist/cli.js +248 -70
- package/dist/config.js +3 -0
- package/dist/delete.js +2 -2
- package/dist/doctor.js +18 -15
- package/dist/errors.js +6 -2
- package/dist/info.js +6 -2
- package/dist/init.js +23 -4
- package/dist/install.js +43 -12
- package/dist/lib/api.js +13 -5
- package/dist/library.js +8 -8
- package/dist/list.js +4 -3
- package/dist/mcp.js +2 -8
- package/dist/publish.js +27 -16
- package/dist/scan.js +26 -0
- package/dist/search.js +2 -2
- package/dist/secrets.js +105 -0
- package/dist/setup.js +16 -8
- package/dist/share.js +2 -2
- package/dist/sync.js +2 -2
- package/dist/version.js +25 -0
- package/dist/whoami.js +9 -6
- package/package.json +1 -1
package/dist/info.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import ora from "ora";
|
|
2
|
-
import {
|
|
2
|
+
import { readConfig, resolveApiUrl } from "./config.js";
|
|
3
3
|
import { getJson } from "./lib/api.js";
|
|
4
4
|
import { extractRequires, formatToolList, formatType } from "./lib/skill-labels.js";
|
|
5
5
|
import { c, symbols } from "./ui.js";
|
|
6
6
|
import { FloomError } from "./errors.js";
|
|
7
|
+
const SLUG_RE = /^[A-Za-z0-9_-]{1,128}$/;
|
|
7
8
|
function slugFromInput(input) {
|
|
8
9
|
const trimmed = input.trim();
|
|
9
10
|
try {
|
|
@@ -19,8 +20,11 @@ export async function info(opts) {
|
|
|
19
20
|
const slug = slugFromInput(opts.slug);
|
|
20
21
|
if (!slug)
|
|
21
22
|
throw new FloomError("Missing skill slug.", "Try: `floom info <slug>`");
|
|
23
|
+
if (!SLUG_RE.test(slug)) {
|
|
24
|
+
throw new FloomError(`Invalid skill slug: ${opts.slug}`, "Use a Floom skill slug or URL.");
|
|
25
|
+
}
|
|
22
26
|
const cfg = await readConfig();
|
|
23
|
-
const apiUrl = cfg
|
|
27
|
+
const apiUrl = resolveApiUrl(cfg);
|
|
24
28
|
const spinner = opts.json ? null : ora({ text: c.dim(`Loading ${slug}...`), color: "yellow" }).start();
|
|
25
29
|
let detail;
|
|
26
30
|
try {
|
package/dist/init.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { writeFile, access } from "node:fs/promises";
|
|
2
|
-
import { resolve, basename } from "node:path";
|
|
2
|
+
import { dirname, resolve, basename } from "node:path";
|
|
3
3
|
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";
|
|
@@ -43,10 +43,29 @@ export async function init(filename) {
|
|
|
43
43
|
return;
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
|
-
|
|
46
|
+
try {
|
|
47
|
+
await writeFile(filePath, TEMPLATE, "utf8");
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
const code = err.code;
|
|
51
|
+
if (code === "ENOENT") {
|
|
52
|
+
throw new FloomError(`Directory not found: ${dirname(target)}`, "Create the directory first, or choose a filename in the current directory.");
|
|
53
|
+
}
|
|
54
|
+
if (code === "EISDIR") {
|
|
55
|
+
throw new FloomError(`That's a directory, not a file: ${target}`);
|
|
56
|
+
}
|
|
57
|
+
throw new FloomError(`Couldn't create ${target}: ${err.message}`);
|
|
58
|
+
}
|
|
47
59
|
process.stdout.write(`\n${symbols.ok} Created ${c.bold(basename(filePath))}\n`);
|
|
48
|
-
process.stdout.write(
|
|
49
|
-
process.stdout.write(` ${c.
|
|
60
|
+
process.stdout.write(`\n ${c.bold("Next")}\n`);
|
|
61
|
+
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`);
|
|
64
|
+
}
|
|
65
|
+
function shellQuote(value) {
|
|
66
|
+
if (/^[A-Za-z0-9_./:@%+=,-]+$/.test(value))
|
|
67
|
+
return value;
|
|
68
|
+
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
50
69
|
}
|
|
51
70
|
async function fileExists(p) {
|
|
52
71
|
try {
|
package/dist/install.js
CHANGED
|
@@ -4,7 +4,7 @@ import { createHash } from "node:crypto";
|
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
5
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
6
6
|
import ora from "ora";
|
|
7
|
-
import {
|
|
7
|
+
import { readConfig, resolveApiUrl } from "./config.js";
|
|
8
8
|
import { getJson } from "./lib/api.js";
|
|
9
9
|
import { c, symbols } from "./ui.js";
|
|
10
10
|
import { FloomError } from "./errors.js";
|
|
@@ -21,11 +21,21 @@ function slugFromInput(input) {
|
|
|
21
21
|
return trimmed.replace(/\.(md|json)$/i, "");
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
|
-
function skillsDir() {
|
|
24
|
+
function skillsDir(target) {
|
|
25
|
+
if (target === "codex") {
|
|
26
|
+
const codexHome = process.env.CODEX_HOME ?? join(homedir(), ".codex");
|
|
27
|
+
return process.env.CODEX_SKILLS_DIR ?? join(codexHome, "skills");
|
|
28
|
+
}
|
|
25
29
|
return process.env.CLAUDE_SKILLS_DIR ?? join(homedir(), ".claude", "skills");
|
|
26
30
|
}
|
|
27
|
-
function skillPath(slug) {
|
|
28
|
-
return join(
|
|
31
|
+
function skillPath(root, slug) {
|
|
32
|
+
return join(root, `${slug}.md`);
|
|
33
|
+
}
|
|
34
|
+
function skillsDirHint(target) {
|
|
35
|
+
return target === "codex" ? "CODEX_SKILLS_DIR" : "CLAUDE_SKILLS_DIR";
|
|
36
|
+
}
|
|
37
|
+
function setupCommand(target) {
|
|
38
|
+
return `npx -y @floomhq/floom setup --target ${target} --yes`;
|
|
29
39
|
}
|
|
30
40
|
function sha256(input) {
|
|
31
41
|
return createHash("sha256").update(input).digest("hex");
|
|
@@ -55,8 +65,8 @@ async function localHash(path) {
|
|
|
55
65
|
throw err;
|
|
56
66
|
}
|
|
57
67
|
}
|
|
58
|
-
async function writeInstallFile(target, body) {
|
|
59
|
-
const parent = await openSafeParentDirectory(
|
|
68
|
+
async function writeInstallFile(root, target, body) {
|
|
69
|
+
const parent = await openSafeParentDirectory(root, target);
|
|
60
70
|
let handle = null;
|
|
61
71
|
try {
|
|
62
72
|
handle = await open(childCreatePath(parent, dirname(target), basename(target)), constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
@@ -93,7 +103,15 @@ async function ensureSafeParentDirectory(root, target) {
|
|
|
93
103
|
if (relativeParent === ".." || relativeParent.startsWith(`..${sep}`) || isAbsolute(relativeParent)) {
|
|
94
104
|
throw new FloomError("Invalid skill target path.");
|
|
95
105
|
}
|
|
96
|
-
|
|
106
|
+
try {
|
|
107
|
+
await mkdir(resolvedRoot, { recursive: true, mode: 0o700 });
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
if (err.code === "EEXIST") {
|
|
111
|
+
throw new FloomError("Skills directory points to a file, not a directory.", "Set the skills directory env var to a directory, or remove the file blocking it.");
|
|
112
|
+
}
|
|
113
|
+
throw err;
|
|
114
|
+
}
|
|
97
115
|
await assertSafeDirectory(resolvedRoot);
|
|
98
116
|
if (!relativeParent || relativeParent === ".")
|
|
99
117
|
return;
|
|
@@ -120,13 +138,15 @@ async function assertSafeDirectory(path) {
|
|
|
120
138
|
throw new FloomError("Local path is blocked by an existing file or directory.");
|
|
121
139
|
}
|
|
122
140
|
}
|
|
123
|
-
export async function install(slugInput) {
|
|
141
|
+
export async function install(slugInput, opts = {}) {
|
|
142
|
+
const targetAgent = opts.target ?? "claude";
|
|
143
|
+
const root = skillsDir(targetAgent);
|
|
124
144
|
const slug = slugFromInput(slugInput);
|
|
125
145
|
if (!SLUG_RE.test(slug)) {
|
|
126
146
|
throw new FloomError(`Invalid skill slug: ${slugInput}`);
|
|
127
147
|
}
|
|
128
148
|
const cfg = await readConfig();
|
|
129
|
-
const apiUrl = cfg
|
|
149
|
+
const apiUrl = resolveApiUrl(cfg);
|
|
130
150
|
const spinner = ora({ text: c.dim(`Adding ${slug}...`), color: "yellow" }).start();
|
|
131
151
|
let detail;
|
|
132
152
|
try {
|
|
@@ -139,8 +159,16 @@ export async function install(slugInput) {
|
|
|
139
159
|
spinner.stop();
|
|
140
160
|
throw err;
|
|
141
161
|
}
|
|
142
|
-
|
|
143
|
-
|
|
162
|
+
try {
|
|
163
|
+
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
164
|
+
}
|
|
165
|
+
catch (err) {
|
|
166
|
+
if (err.code === "EEXIST") {
|
|
167
|
+
throw new FloomError(`${skillsDirHint(targetAgent)} points to a file, not a directory.`, `Set ${skillsDirHint(targetAgent)} to a directory, or remove the file blocking it.`);
|
|
168
|
+
}
|
|
169
|
+
throw err;
|
|
170
|
+
}
|
|
171
|
+
const target = skillPath(root, slug);
|
|
144
172
|
const remoteHash = sha256(detail.body_md);
|
|
145
173
|
const existing = await localHash(target);
|
|
146
174
|
let action;
|
|
@@ -152,7 +180,7 @@ export async function install(slugInput) {
|
|
|
152
180
|
}
|
|
153
181
|
else {
|
|
154
182
|
try {
|
|
155
|
-
await writeInstallFile(target, detail.body_md);
|
|
183
|
+
await writeInstallFile(root, target, detail.body_md);
|
|
156
184
|
}
|
|
157
185
|
catch (err) {
|
|
158
186
|
const code = err.code;
|
|
@@ -172,4 +200,7 @@ export async function install(slugInput) {
|
|
|
172
200
|
spinner.stop();
|
|
173
201
|
process.stdout.write(`\n${symbols.ok} [floom] ${action} ${c.bold(slug)}\n`);
|
|
174
202
|
process.stdout.write(` ${c.dim(target)}\n\n`);
|
|
203
|
+
process.stdout.write(` ${c.bold("Next")}\n`);
|
|
204
|
+
process.stdout.write(` ${c.dim("1.")} Tell your agent to use ${c.bold(slug)} when it matches the task.\n`);
|
|
205
|
+
process.stdout.write(` ${c.dim("2.")} One-time setup: ${c.cyan(setupCommand(targetAgent))}\n\n`);
|
|
175
206
|
}
|
package/dist/lib/api.js
CHANGED
|
@@ -4,15 +4,15 @@ import { friendlyHttp, friendlyNetwork, FloomError } from "../errors.js";
|
|
|
4
4
|
* patterns but throws FloomError instances so the CLI's printError gives
|
|
5
5
|
* users a clean message instead of a stack trace.
|
|
6
6
|
*/
|
|
7
|
-
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
8
|
-
async function floomFetch(url, action, opts = {}) {
|
|
9
|
-
const headers = {};
|
|
7
|
+
export const DEFAULT_TIMEOUT_MS = 15_000;
|
|
8
|
+
export async function floomFetch(url, action, opts = {}) {
|
|
9
|
+
const headers = { ...(opts.headers ?? {}) };
|
|
10
10
|
if (opts.token)
|
|
11
11
|
headers.authorization = `Bearer ${opts.token}`;
|
|
12
12
|
if (opts.body !== undefined)
|
|
13
13
|
headers["content-type"] = "application/json";
|
|
14
14
|
const controller = new AbortController();
|
|
15
|
-
const timer = setTimeout(() => controller.abort(), opts.timeoutMs
|
|
15
|
+
const timer = setTimeout(() => controller.abort(), requestTimeoutMs(opts.timeoutMs));
|
|
16
16
|
let res;
|
|
17
17
|
try {
|
|
18
18
|
res = await fetch(url, {
|
|
@@ -31,11 +31,19 @@ async function floomFetch(url, action, opts = {}) {
|
|
|
31
31
|
finally {
|
|
32
32
|
clearTimeout(timer);
|
|
33
33
|
}
|
|
34
|
-
if (!res.ok) {
|
|
34
|
+
if (opts.checkOk !== false && !res.ok) {
|
|
35
35
|
throw friendlyHttp(res.status, action);
|
|
36
36
|
}
|
|
37
37
|
return res;
|
|
38
38
|
}
|
|
39
|
+
function requestTimeoutMs(override) {
|
|
40
|
+
if (override !== undefined)
|
|
41
|
+
return override;
|
|
42
|
+
const fromEnv = Number(process.env.FLOOM_REQUEST_TIMEOUT_MS);
|
|
43
|
+
if (Number.isFinite(fromEnv) && fromEnv > 0)
|
|
44
|
+
return fromEnv;
|
|
45
|
+
return DEFAULT_TIMEOUT_MS;
|
|
46
|
+
}
|
|
39
47
|
export async function getJson(url, action, token) {
|
|
40
48
|
const res = await floomFetch(url, action, token ? { token } : {});
|
|
41
49
|
return (await res.json());
|
package/dist/library.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import ora from "ora";
|
|
2
|
-
import {
|
|
2
|
+
import { readConfig, resolveApiUrl } from "./config.js";
|
|
3
3
|
import { deleteRequest, getJson, postJson, putJson } from "./lib/api.js";
|
|
4
4
|
import { c, symbols } from "./ui.js";
|
|
5
5
|
import { FloomError } from "./errors.js";
|
|
@@ -10,7 +10,7 @@ function formatLibraryRow(lib) {
|
|
|
10
10
|
}
|
|
11
11
|
export async function libraryList(opts = {}) {
|
|
12
12
|
const cfg = await readConfig();
|
|
13
|
-
const apiUrl = cfg
|
|
13
|
+
const apiUrl = resolveApiUrl(cfg);
|
|
14
14
|
const spinner = opts.json ? null : ora({ text: c.dim("Loading libraries..."), color: "yellow" }).start();
|
|
15
15
|
let result;
|
|
16
16
|
try {
|
|
@@ -36,7 +36,7 @@ export async function libraryCreate(opts) {
|
|
|
36
36
|
const cfg = await readConfig();
|
|
37
37
|
if (!cfg)
|
|
38
38
|
throw new FloomError("Not signed in.", "Run `floom login` first.");
|
|
39
|
-
const apiUrl = cfg
|
|
39
|
+
const apiUrl = resolveApiUrl(cfg);
|
|
40
40
|
const result = await postJson(`${apiUrl}/api/v1/libraries`, "create library", cfg.accessToken, {
|
|
41
41
|
slug: opts.slug,
|
|
42
42
|
name: opts.name,
|
|
@@ -51,7 +51,7 @@ export async function libraryAddSkill(opts) {
|
|
|
51
51
|
const cfg = await readConfig();
|
|
52
52
|
if (!cfg)
|
|
53
53
|
throw new FloomError("Not signed in.", "Run `floom login` first.");
|
|
54
|
-
const apiUrl = cfg
|
|
54
|
+
const apiUrl = resolveApiUrl(cfg);
|
|
55
55
|
await postJson(`${apiUrl}/api/v1/libraries/${encodeURIComponent(opts.librarySlug)}/skills`, "add skill to library", cfg.accessToken, {
|
|
56
56
|
skill_slug: opts.skillSlug,
|
|
57
57
|
...(opts.folder !== undefined ? { folder: opts.folder } : {}),
|
|
@@ -67,7 +67,7 @@ export async function libraryRemoveSkill(librarySlug, skillSlug) {
|
|
|
67
67
|
const cfg = await readConfig();
|
|
68
68
|
if (!cfg)
|
|
69
69
|
throw new FloomError("Not signed in.", "Run `floom login` first.");
|
|
70
|
-
const apiUrl = cfg
|
|
70
|
+
const apiUrl = resolveApiUrl(cfg);
|
|
71
71
|
await deleteRequest(`${apiUrl}/api/v1/libraries/${encodeURIComponent(librarySlug)}/skills/${encodeURIComponent(skillSlug)}`, "remove skill from library", cfg.accessToken);
|
|
72
72
|
process.stdout.write(`\n${symbols.ok} Removed ${c.cyan(skillSlug)} from ${c.cyan(librarySlug)}\n\n`);
|
|
73
73
|
}
|
|
@@ -75,7 +75,7 @@ export async function librarySubscribe(slug) {
|
|
|
75
75
|
const cfg = await readConfig();
|
|
76
76
|
if (!cfg)
|
|
77
77
|
throw new FloomError("Not signed in.", "Run `floom login` first.");
|
|
78
|
-
const apiUrl = cfg
|
|
78
|
+
const apiUrl = resolveApiUrl(cfg);
|
|
79
79
|
await postJson(`${apiUrl}/api/v1/me/subscriptions`, "subscribe to library", cfg.accessToken, { library_slug: slug });
|
|
80
80
|
process.stdout.write(`\n${symbols.ok} Subscribed to ${c.cyan(slug)}\n`);
|
|
81
81
|
process.stdout.write(` ${c.dim("Skills will sync into ~/.claude/skills/" + slug + "/")}\n\n`);
|
|
@@ -84,7 +84,7 @@ export async function libraryUnsubscribe(slug) {
|
|
|
84
84
|
const cfg = await readConfig();
|
|
85
85
|
if (!cfg)
|
|
86
86
|
throw new FloomError("Not signed in.", "Run `floom login` first.");
|
|
87
|
-
const apiUrl = cfg
|
|
87
|
+
const apiUrl = resolveApiUrl(cfg);
|
|
88
88
|
await deleteRequest(`${apiUrl}/api/v1/me/subscriptions/${encodeURIComponent(slug)}`, "unsubscribe from library", cfg.accessToken);
|
|
89
89
|
process.stdout.write(`\n${symbols.ok} Unsubscribed from ${c.cyan(slug)}\n\n`);
|
|
90
90
|
}
|
|
@@ -92,7 +92,7 @@ export async function moveSkill(opts) {
|
|
|
92
92
|
const cfg = await readConfig();
|
|
93
93
|
if (!cfg)
|
|
94
94
|
throw new FloomError("Not signed in.", "Run `floom login` first.");
|
|
95
|
-
const apiUrl = cfg
|
|
95
|
+
const apiUrl = resolveApiUrl(cfg);
|
|
96
96
|
await putJson(`${apiUrl}/api/v1/me/skills/${encodeURIComponent(opts.slug)}/override`, "set skill override", cfg.accessToken, { folder: opts.folder, tags: opts.tags });
|
|
97
97
|
const folderText = opts.folder ?? c.dim("(root)");
|
|
98
98
|
const tagsText = opts.tags.length ? opts.tags.join(", ") : c.dim("(none)");
|
package/dist/list.js
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
import ora from "ora";
|
|
2
|
-
import {
|
|
2
|
+
import { readConfig, resolveApiUrl } from "./config.js";
|
|
3
3
|
import { getJson } from "./lib/api.js";
|
|
4
4
|
import { extractRequires, formatToolList, formatType } from "./lib/skill-labels.js";
|
|
5
5
|
import { c, symbols } from "./ui.js";
|
|
6
6
|
import { FloomError } from "./errors.js";
|
|
7
|
+
import { formatVersionLabel } from "./version.js";
|
|
7
8
|
function formatRow(s) {
|
|
8
9
|
const title = s.title ?? c.dim("(untitled)");
|
|
9
10
|
const visibility = c.dim(`[${s.visibility}]`);
|
|
10
11
|
const type = c.dim(`[${formatType(s.asset_type)}]`);
|
|
11
|
-
const version = s.version ? c.dim(
|
|
12
|
+
const version = s.version ? c.dim(formatVersionLabel(s.version)) : "";
|
|
12
13
|
const updated = c.dim(formatRelative(s.updated_at));
|
|
13
14
|
const requires = extractRequires(s.body_md);
|
|
14
15
|
const needs = requires.length > 0 ? c.dim(`needs ${formatToolList(requires)}`) : "";
|
|
@@ -37,7 +38,7 @@ export async function list(opts) {
|
|
|
37
38
|
if (!cfg) {
|
|
38
39
|
throw new FloomError("Not signed in.", "Run `floom login` first.");
|
|
39
40
|
}
|
|
40
|
-
const apiUrl = cfg
|
|
41
|
+
const apiUrl = resolveApiUrl(cfg);
|
|
41
42
|
const spinner = opts.json ? null : ora({ text: c.dim("Loading skills..."), color: "yellow" }).start();
|
|
42
43
|
let published = [];
|
|
43
44
|
try {
|
package/dist/mcp.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { c } from "./ui.js";
|
|
2
2
|
export function printMcpSetup() {
|
|
3
3
|
const snippet = `## Floom
|
|
4
|
-
- Use Floom skills from
|
|
5
|
-
- To install a shared skill, run \`floom add <slug-or-url
|
|
4
|
+
- Use Floom skills from the local Floom skills folder when they match the task.
|
|
5
|
+
- To install a shared skill, run \`floom add <slug-or-url> --target claude\` or \`floom add <slug-or-url> --target codex\`.
|
|
6
6
|
- To find reusable behavior, run \`floom search <query>\`.
|
|
7
7
|
- MCP sync is optional preview behavior; use it only while the Floom MCP server is configured and running.`;
|
|
8
8
|
process.stdout.write(`\n${c.bold("Floom MCP setup")}\n\n`);
|
|
@@ -11,12 +11,6 @@ export function printMcpSetup() {
|
|
|
11
11
|
process.stdout.write(` ${c.cyan("claude mcp add floom -- npx -y @floomhq/floom-mcp-sync")}\n\n`);
|
|
12
12
|
process.stdout.write(` ${c.bold("Codex CLI")}\n`);
|
|
13
13
|
process.stdout.write(` ${c.cyan("codex mcp add floom -- npx -y @floomhq/floom-mcp-sync")}\n\n`);
|
|
14
|
-
process.stdout.write(` ${c.bold("Gemini CLI")}\n`);
|
|
15
|
-
process.stdout.write(` ${c.cyan("gemini mcp add -s user floom npx -y @floomhq/floom-mcp-sync")}\n\n`);
|
|
16
|
-
process.stdout.write(` ${c.bold("Kimi CLI")}\n`);
|
|
17
|
-
process.stdout.write(` ${c.cyan("kimi mcp add --transport stdio floom -- npx -y @floomhq/floom-mcp-sync")}\n\n`);
|
|
18
|
-
process.stdout.write(` ${c.bold("OpenCode")}\n`);
|
|
19
|
-
process.stdout.write(` ${c.dim("Edit ~/.config/opencode/opencode.json - see guide.")}\n\n`);
|
|
20
14
|
process.stdout.write(`${c.dim("Full guide:")} ${c.cyan("https://floom.dev/docs/mcp")}\n\n`);
|
|
21
15
|
process.stdout.write(`${c.dim("Recommended agent instruction snippet:")}\n\n`);
|
|
22
16
|
process.stdout.write(`${snippet}\n\n`);
|
package/dist/publish.js
CHANGED
|
@@ -2,9 +2,12 @@ import { readFile } from "node:fs/promises";
|
|
|
2
2
|
import { basename, resolve } from "node:path";
|
|
3
3
|
import ora from "ora";
|
|
4
4
|
import clipboard from "clipboardy";
|
|
5
|
-
import {
|
|
5
|
+
import { getWebUrl, readConfig, resolveApiUrl } from "./config.js";
|
|
6
|
+
import { floomFetch } from "./lib/api.js";
|
|
6
7
|
import { c, symbols } from "./ui.js";
|
|
7
|
-
import { FloomError, friendlyHttp
|
|
8
|
+
import { FloomError, friendlyHttp } from "./errors.js";
|
|
9
|
+
import { formatVersionLabel } from "./version.js";
|
|
10
|
+
import { detectSkillSecurityFindings, formatSecurityFindings } from "./secrets.js";
|
|
8
11
|
const ASSET_TYPES = new Set(["knowledge", "instruction", "workflow", "skill"]);
|
|
9
12
|
const INSTALL_TARGETS = new Set([
|
|
10
13
|
"claude_skill",
|
|
@@ -86,10 +89,6 @@ function parseVersion(value, source) {
|
|
|
86
89
|
throw new FloomError(`Invalid ${source}: ${value}`, "Use 1-64 characters: letters, numbers, dots, underscores, plus, or hyphen.");
|
|
87
90
|
}
|
|
88
91
|
export async function publish(opts) {
|
|
89
|
-
const cfg = await readConfig();
|
|
90
|
-
if (!cfg) {
|
|
91
|
-
throw new FloomError("Not signed in.", "Run `floom login` first.");
|
|
92
|
-
}
|
|
93
92
|
const filePath = resolve(process.cwd(), opts.file);
|
|
94
93
|
let raw;
|
|
95
94
|
try {
|
|
@@ -108,6 +107,11 @@ export async function publish(opts) {
|
|
|
108
107
|
if (!raw.trim()) {
|
|
109
108
|
throw new FloomError(`File is empty: ${opts.file}`);
|
|
110
109
|
}
|
|
110
|
+
const securityFindings = detectSkillSecurityFindings(raw);
|
|
111
|
+
if (securityFindings.length > 0) {
|
|
112
|
+
throw new FloomError("Security scan failed. Publish stopped.", `${formatSecurityFindings(securityFindings)}\nRemove secrets, prompt-injection text, or data-exfiltration instructions before publishing.`);
|
|
113
|
+
}
|
|
114
|
+
process.stdout.write(`\n${symbols.ok} Pre-publish security scan passed (3 checks).\n`);
|
|
111
115
|
const { meta, error: fmError } = parseFrontmatter(raw);
|
|
112
116
|
if (fmError) {
|
|
113
117
|
throw new FloomError(`Couldn't parse frontmatter — check your YAML.`, `Line ${fmError.line}: ${fmError.message}`);
|
|
@@ -115,6 +119,10 @@ export async function publish(opts) {
|
|
|
115
119
|
const assetType = opts.assetType ?? parseAssetType(meta.asset_type ?? meta.type, "frontmatter type") ?? "skill";
|
|
116
120
|
const installsAs = opts.installsAs ?? parseInstallsAs(meta.installs_as, "frontmatter installs_as") ?? null;
|
|
117
121
|
const version = opts.version ?? parseVersion(meta.version, "frontmatter version") ?? null;
|
|
122
|
+
const cfg = await readConfig();
|
|
123
|
+
if (!cfg) {
|
|
124
|
+
throw new FloomError("Not signed in.", "Run `floom login` first.");
|
|
125
|
+
}
|
|
118
126
|
// Later version: detect already-published when --update is missing.
|
|
119
127
|
// The current API does not return a duplicate-skill code, so we leave
|
|
120
128
|
// the actual dedupe to the server and surface the friendly hint on 409.
|
|
@@ -122,16 +130,14 @@ export async function publish(opts) {
|
|
|
122
130
|
text: c.dim(`Publishing ${meta.title ? c.bold(meta.title) : opts.file}...`),
|
|
123
131
|
color: "yellow",
|
|
124
132
|
}).start();
|
|
125
|
-
const apiUrl = cfg
|
|
133
|
+
const apiUrl = resolveApiUrl(cfg);
|
|
126
134
|
let res;
|
|
127
135
|
try {
|
|
128
|
-
res = await
|
|
136
|
+
res = await floomFetch(`${apiUrl}/api/skills`, "publish your skill", {
|
|
129
137
|
method: "POST",
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
},
|
|
134
|
-
body: JSON.stringify({
|
|
138
|
+
token: cfg.accessToken,
|
|
139
|
+
checkOk: false,
|
|
140
|
+
body: {
|
|
135
141
|
title: meta.title ?? null,
|
|
136
142
|
description: meta.description ?? null,
|
|
137
143
|
body_md: raw,
|
|
@@ -143,12 +149,12 @@ export async function publish(opts) {
|
|
|
143
149
|
original_filename: basename(filePath),
|
|
144
150
|
published_via: "cli",
|
|
145
151
|
shared_with_emails: opts.sharedWithEmails,
|
|
146
|
-
}
|
|
152
|
+
},
|
|
147
153
|
});
|
|
148
154
|
}
|
|
149
155
|
catch (err) {
|
|
150
156
|
spinner.stop();
|
|
151
|
-
throw
|
|
157
|
+
throw err;
|
|
152
158
|
}
|
|
153
159
|
if (res.status === 409) {
|
|
154
160
|
spinner.stop();
|
|
@@ -165,7 +171,7 @@ export async function publish(opts) {
|
|
|
165
171
|
? data.url.replace(/\.md$/, "")
|
|
166
172
|
: `${webBase}/s/${data.slug}`;
|
|
167
173
|
spinner.stop();
|
|
168
|
-
const versionTag = version ? c.dim(` (
|
|
174
|
+
const versionTag = version ? c.dim(` (${formatVersionLabel(version)})`) : "";
|
|
169
175
|
const titleLabel = data.title ? `"${data.title}"` : opts.file;
|
|
170
176
|
process.stdout.write(`\n${symbols.ok} Published ${c.bold(titleLabel)}${versionTag}\n\n`);
|
|
171
177
|
process.stdout.write(` ${c.cyan(humanUrl)}\n\n`);
|
|
@@ -186,4 +192,9 @@ export async function publish(opts) {
|
|
|
186
192
|
else {
|
|
187
193
|
process.stdout.write(` ${c.dim("Share it anywhere.")}\n\n`);
|
|
188
194
|
}
|
|
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`);
|
|
189
200
|
}
|
package/dist/scan.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { detectSkillSecurityFindings, formatSecurityFindings } from "./secrets.js";
|
|
4
|
+
import { FloomError } from "./errors.js";
|
|
5
|
+
import { c, symbols } from "./ui.js";
|
|
6
|
+
export async function scanSkill(file) {
|
|
7
|
+
const path = resolve(process.cwd(), file);
|
|
8
|
+
let raw;
|
|
9
|
+
try {
|
|
10
|
+
raw = await readFile(path, "utf8");
|
|
11
|
+
}
|
|
12
|
+
catch (err) {
|
|
13
|
+
const code = err.code;
|
|
14
|
+
if (code === "ENOENT")
|
|
15
|
+
throw new FloomError(`File not found: ${file}`);
|
|
16
|
+
if (code === "EISDIR")
|
|
17
|
+
throw new FloomError(`That's a directory, not a file: ${file}`);
|
|
18
|
+
throw new FloomError(`Couldn't read ${file}: ${err.message}`);
|
|
19
|
+
}
|
|
20
|
+
const findings = detectSkillSecurityFindings(raw);
|
|
21
|
+
if (findings.length > 0) {
|
|
22
|
+
throw new FloomError("Security scan failed.", `${formatSecurityFindings(findings)}\nRemove secrets, prompt-injection text, or data-exfiltration instructions before publishing.`);
|
|
23
|
+
}
|
|
24
|
+
process.stdout.write(`\n${symbols.ok} Security scan passed for ${c.bold(file)}\n`);
|
|
25
|
+
process.stdout.write(` ${c.dim("No high-confidence secrets, prompt-injection text, or exfiltration instructions found.")}\n\n`);
|
|
26
|
+
}
|
package/dist/search.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import ora from "ora";
|
|
2
|
-
import {
|
|
2
|
+
import { readConfig, resolveApiUrl } from "./config.js";
|
|
3
3
|
import { getJson } from "./lib/api.js";
|
|
4
4
|
import { c, symbols } from "./ui.js";
|
|
5
5
|
function formatSkillRow(skill) {
|
|
@@ -16,7 +16,7 @@ function formatLibraryRow(library) {
|
|
|
16
16
|
}
|
|
17
17
|
export async function search(opts) {
|
|
18
18
|
const cfg = await readConfig();
|
|
19
|
-
const apiUrl = cfg
|
|
19
|
+
const apiUrl = resolveApiUrl(cfg);
|
|
20
20
|
const params = new URLSearchParams({ q: opts.query });
|
|
21
21
|
if (opts.library)
|
|
22
22
|
params.set("library", opts.library);
|
package/dist/secrets.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
const SECRET_PATTERNS = [
|
|
2
|
+
{ label: "OpenAI API key", regex: /\bsk-proj-[A-Za-z0-9_-]{20,}\b/g },
|
|
3
|
+
{ label: "OpenAI API key", regex: /\bsk-[A-Za-z0-9]{32,}\b/g },
|
|
4
|
+
{ label: "Anthropic API key", regex: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/g },
|
|
5
|
+
{ label: "Google API key", regex: /\bAIza[0-9A-Za-z_-]{25,}\b/g },
|
|
6
|
+
{ label: "GitHub token", regex: /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{30,}\b/g },
|
|
7
|
+
{ label: "GitHub token", regex: /\bgithub_pat_[A-Za-z0-9_]{40,}\b/g },
|
|
8
|
+
{ label: "Supabase access token", regex: /\bsbp_[A-Za-z0-9]{30,}\b/g },
|
|
9
|
+
{ label: "Stripe secret key", regex: /\bsk_(?:live|test)_[A-Za-z0-9]{20,}\b/g },
|
|
10
|
+
{ label: "Slack token", regex: /\bxox[baprs]-[A-Za-z0-9-]{20,}\b/g },
|
|
11
|
+
{ label: "AWS access key", regex: /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g },
|
|
12
|
+
{ label: "Private key", regex: /-----BEGIN (?:RSA |EC |OPENSSH |)PRIVATE KEY-----/g },
|
|
13
|
+
];
|
|
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;
|
|
16
|
+
const PROMPT_INJECTION_PATTERNS = [
|
|
17
|
+
{ label: "Prompt injection instruction", regex: /\bignore (?:all )?(?:previous|prior|above|earlier) instructions\b/gi },
|
|
18
|
+
{ label: "Prompt injection instruction", regex: /\bdisregard (?:all )?(?:previous|prior|above|earlier) instructions\b/gi },
|
|
19
|
+
{ label: "Prompt injection instruction", regex: /\bforget (?:all )?(?:previous|prior|above|earlier) instructions\b/gi },
|
|
20
|
+
{ label: "Prompt injection instruction", regex: /\boverride (?:the )?(?:system|developer|safety) instructions\b/gi },
|
|
21
|
+
{ label: "System prompt extraction", regex: /\b(?:reveal|print|show|dump|expose|leak) (?:the )?(?:system prompt|developer message|hidden instructions)\b/gi },
|
|
22
|
+
];
|
|
23
|
+
const DATA_EXFILTRATION_PATTERNS = [
|
|
24
|
+
{ label: "Data exfiltration instruction", regex: /\b(?:send|post|upload|exfiltrate|copy) (?:[^.\n]{0,80})\b(?:api keys?|tokens?|secrets?|environment variables|\.env|credentials)\b(?:[^.\n]{0,120})\b(?:to|into) https?:\/\//gi },
|
|
25
|
+
{ label: "Data exfiltration instruction", regex: /\b(?:curl|wget|fetch)\b[^\n]{0,160}\b(?:api keys?|tokens?|secrets?|environment variables|\.env|credentials)\b/gi },
|
|
26
|
+
{ label: "Credential harvesting instruction", regex: /\b(?:collect|harvest|steal|extract) (?:[^.\n]{0,80})\b(?:api keys?|tokens?|secrets?|environment variables|\.env|credentials)\b/gi },
|
|
27
|
+
];
|
|
28
|
+
function redact(value) {
|
|
29
|
+
if (value.length <= 12)
|
|
30
|
+
return "[redacted]";
|
|
31
|
+
return `${value.slice(0, 4)}...${value.slice(-4)}`;
|
|
32
|
+
}
|
|
33
|
+
function lineNumberAt(input, index) {
|
|
34
|
+
let line = 1;
|
|
35
|
+
for (let i = 0; i < index; i++) {
|
|
36
|
+
if (input.charCodeAt(i) === 10)
|
|
37
|
+
line++;
|
|
38
|
+
}
|
|
39
|
+
return line;
|
|
40
|
+
}
|
|
41
|
+
function pushFinding(findings, seen, label, line, value) {
|
|
42
|
+
const key = `${label}:${line}:${value}`;
|
|
43
|
+
if (seen.has(key))
|
|
44
|
+
return;
|
|
45
|
+
seen.add(key);
|
|
46
|
+
findings.push({ label, line, preview: redact(value) });
|
|
47
|
+
}
|
|
48
|
+
export function detectSecrets(input) {
|
|
49
|
+
const findings = [];
|
|
50
|
+
const seen = new Set();
|
|
51
|
+
for (const pattern of SECRET_PATTERNS) {
|
|
52
|
+
pattern.regex.lastIndex = 0;
|
|
53
|
+
for (const match of input.matchAll(pattern.regex)) {
|
|
54
|
+
const value = match[0] ?? "";
|
|
55
|
+
pushFinding(findings, seen, pattern.label, lineNumberAt(input, match.index ?? 0), value);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
GENERIC_ASSIGNMENT_RE.lastIndex = 0;
|
|
59
|
+
for (const match of input.matchAll(GENERIC_ASSIGNMENT_RE)) {
|
|
60
|
+
const value = match[1] ?? "";
|
|
61
|
+
if (!value || PLACEHOLDER_RE.test(value))
|
|
62
|
+
continue;
|
|
63
|
+
pushFinding(findings, seen, "Possible secret assignment", lineNumberAt(input, match.index ?? 0), value);
|
|
64
|
+
}
|
|
65
|
+
return findings.sort((a, b) => a.line - b.line || a.label.localeCompare(b.label));
|
|
66
|
+
}
|
|
67
|
+
function detectPatternFindings(input, patterns, category) {
|
|
68
|
+
const findings = [];
|
|
69
|
+
const seen = new Set();
|
|
70
|
+
for (const pattern of patterns) {
|
|
71
|
+
pattern.regex.lastIndex = 0;
|
|
72
|
+
for (const match of input.matchAll(pattern.regex)) {
|
|
73
|
+
const value = (match[0] ?? "").replace(/\s+/g, " ").trim();
|
|
74
|
+
const key = `${pattern.label}:${match.index ?? 0}:${value}`;
|
|
75
|
+
if (seen.has(key))
|
|
76
|
+
continue;
|
|
77
|
+
seen.add(key);
|
|
78
|
+
findings.push({
|
|
79
|
+
label: pattern.label,
|
|
80
|
+
line: lineNumberAt(input, match.index ?? 0),
|
|
81
|
+
preview: redact(value),
|
|
82
|
+
severity: "high",
|
|
83
|
+
category,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return findings;
|
|
88
|
+
}
|
|
89
|
+
export function detectSkillSecurityFindings(input) {
|
|
90
|
+
const secretFindings = detectSecrets(input).map((finding) => ({
|
|
91
|
+
...finding,
|
|
92
|
+
severity: "high",
|
|
93
|
+
category: "secret",
|
|
94
|
+
}));
|
|
95
|
+
return [
|
|
96
|
+
...secretFindings,
|
|
97
|
+
...detectPatternFindings(input, PROMPT_INJECTION_PATTERNS, "prompt_injection"),
|
|
98
|
+
...detectPatternFindings(input, DATA_EXFILTRATION_PATTERNS, "data_exfiltration"),
|
|
99
|
+
].sort((a, b) => a.line - b.line || a.label.localeCompare(b.label));
|
|
100
|
+
}
|
|
101
|
+
export function formatSecurityFindings(findings, limit = 5) {
|
|
102
|
+
const shown = findings.slice(0, limit).map((finding) => (`line ${finding.line}: ${finding.label} (${finding.preview})`));
|
|
103
|
+
const more = findings.length > shown.length ? `\n...and ${findings.length - shown.length} more.` : "";
|
|
104
|
+
return `${shown.join("\n")}${more}`;
|
|
105
|
+
}
|
package/dist/setup.js
CHANGED
|
@@ -11,15 +11,20 @@ const TARGETS = {
|
|
|
11
11
|
claude: { label: "Claude Code", filename: "CLAUDE.md" },
|
|
12
12
|
codex: { label: "Codex", filename: "AGENTS.md" },
|
|
13
13
|
};
|
|
14
|
-
|
|
14
|
+
function floomAgentInstructions(target) {
|
|
15
|
+
const addCommand = target === "codex"
|
|
16
|
+
? "floom add <slug-or-url> --target codex"
|
|
17
|
+
: "floom add <slug-or-url> --target claude";
|
|
18
|
+
return `${START_MARKER}
|
|
15
19
|
## Floom
|
|
16
20
|
|
|
17
21
|
- Before recreating agent behavior from scratch, check Floom for reusable skills.
|
|
18
22
|
- Search or inspect skills with \`floom search <query>\`, \`floom info <slug-or-url>\`, and \`floom list\`.
|
|
19
|
-
- Add shared skills with
|
|
23
|
+
- Add shared skills with \`${addCommand}\`; public and unlisted links do not require a Floom account.
|
|
20
24
|
- Use installed Markdown skills from the local skills folder when they match the task.
|
|
21
25
|
- \`floom sync\`, \`floom watch\`, and \`@floomhq/floom-mcp-sync\` are preview paths for saved, published, and subscribed library skills; review conflicts before relying on synced output.
|
|
22
26
|
${END_MARKER}`;
|
|
27
|
+
}
|
|
23
28
|
async function fileExists(path) {
|
|
24
29
|
try {
|
|
25
30
|
await access(path);
|
|
@@ -65,10 +70,12 @@ async function findUp(filename) {
|
|
|
65
70
|
async function detectTarget(opts) {
|
|
66
71
|
const agent = opts.target ?? (opts.file ? parseTargetFromFile(opts.file) : undefined);
|
|
67
72
|
if (opts.file) {
|
|
68
|
-
|
|
73
|
+
if (!agent) {
|
|
74
|
+
throw new FloomError("Cannot infer agent target from that file name.", "Pass `--target claude` or `--target codex`, or use CLAUDE.md / AGENTS.md.");
|
|
75
|
+
}
|
|
69
76
|
return {
|
|
70
|
-
agent
|
|
71
|
-
label: TARGETS[
|
|
77
|
+
agent,
|
|
78
|
+
label: TARGETS[agent].label,
|
|
72
79
|
path: resolve(process.cwd(), opts.file),
|
|
73
80
|
};
|
|
74
81
|
}
|
|
@@ -99,7 +106,7 @@ function renderPreview(target, existing) {
|
|
|
99
106
|
` ${c.dim("Target:")} ${target.path}`,
|
|
100
107
|
` ${c.dim("Action:")} ${action}`,
|
|
101
108
|
"",
|
|
102
|
-
|
|
109
|
+
floomAgentInstructions(target.agent),
|
|
103
110
|
"",
|
|
104
111
|
`${c.dim("MCP setup guidance:")} run ${c.cyan("floom mcp")} to print local agent commands.`,
|
|
105
112
|
"",
|
|
@@ -139,9 +146,10 @@ export async function setupAgent(opts) {
|
|
|
139
146
|
}
|
|
140
147
|
}
|
|
141
148
|
await mkdir(dirname(target.path), { recursive: true });
|
|
149
|
+
const instructions = floomAgentInstructions(target.agent);
|
|
142
150
|
const next = existing === null
|
|
143
|
-
? `${
|
|
144
|
-
: `${existing.replace(/\s*$/, "")}\n\n${
|
|
151
|
+
? `${instructions}\n`
|
|
152
|
+
: `${existing.replace(/\s*$/, "")}\n\n${instructions}\n`;
|
|
145
153
|
if (existing === null) {
|
|
146
154
|
await writeFile(target.path, next, { encoding: "utf8", flag: "wx" }).catch((err) => {
|
|
147
155
|
if (err instanceof Error && "code" in err && err.code === "EEXIST") {
|