@floomhq/floom 1.0.3 → 1.0.4

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/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 { getApiUrl, readConfig } from "./config.js";
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(skillsDir(), `${slug}.md`);
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(skillsDir(), target);
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
- await mkdir(resolvedRoot, { recursive: true, mode: 0o700 });
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?.apiUrl ?? getApiUrl();
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
- await mkdir(skillsDir(), { recursive: true, mode: 0o700 });
143
- const target = skillPath(slug);
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 ?? DEFAULT_TIMEOUT_MS);
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 { getApiUrl, readConfig } from "./config.js";
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?.apiUrl ?? getApiUrl();
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.apiUrl ?? getApiUrl();
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.apiUrl ?? getApiUrl();
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.apiUrl ?? getApiUrl();
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.apiUrl ?? getApiUrl();
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.apiUrl ?? getApiUrl();
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.apiUrl ?? getApiUrl();
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 { getApiUrl, readConfig } from "./config.js";
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(`v${s.version}`) : "";
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.apiUrl ?? getApiUrl();
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 ~/.claude/skills/ when they match the task.
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 { getApiUrl, getWebUrl, readConfig } from "./config.js";
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, friendlyNetwork } from "./errors.js";
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.apiUrl ?? getApiUrl();
133
+ const apiUrl = resolveApiUrl(cfg);
126
134
  let res;
127
135
  try {
128
- res = await fetch(`${apiUrl}/api/skills`, {
136
+ res = await floomFetch(`${apiUrl}/api/skills`, "publish your skill", {
129
137
  method: "POST",
130
- headers: {
131
- authorization: `Bearer ${cfg.accessToken}`,
132
- "content-type": "application/json",
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 friendlyNetwork(err);
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(` (v${version})`) : "";
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 { getApiUrl, readConfig } from "./config.js";
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?.apiUrl ?? getApiUrl();
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);
@@ -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
- export const FLOOM_AGENT_INSTRUCTIONS = `${START_MARKER}
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 \`floom add <slug-or-url>\`; public and unlisted links do not require a Floom account.
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
- const resolvedAgent = agent ?? "codex";
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: resolvedAgent,
71
- label: TARGETS[resolvedAgent].label,
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
- FLOOM_AGENT_INSTRUCTIONS,
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
- ? `${FLOOM_AGENT_INSTRUCTIONS}\n`
144
- : `${existing.replace(/\s*$/, "")}\n\n${FLOOM_AGENT_INSTRUCTIONS}\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") {
package/dist/share.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import ora from "ora";
2
- import { getApiUrl, readConfig } from "./config.js";
2
+ import { readConfig, resolveApiUrl } from "./config.js";
3
3
  import { friendlyHttp, friendlyNetwork, FloomError } from "./errors.js";
4
4
  import { c, symbols } from "./ui.js";
5
5
  function slugFromInput(input) {
@@ -25,7 +25,7 @@ export async function share(opts) {
25
25
  if (!slug) {
26
26
  throw new FloomError("Missing skill slug.", "Try: `floom share <slug> --list`");
27
27
  }
28
- const apiUrl = cfg.apiUrl ?? getApiUrl();
28
+ const apiUrl = resolveApiUrl(cfg);
29
29
  const endpoint = `${apiUrl}/api/skills/${encodeURIComponent(slug)}/share`;
30
30
  const spinner = ora({ text: c.dim("Updating sharing..."), color: "yellow" }).start();
31
31
  let res;
package/dist/sync.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 { getApiUrl, readConfig } from "./config.js";
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";
@@ -207,7 +207,7 @@ export async function sync(opts = {}) {
207
207
  if (!cfg)
208
208
  throw new FloomError("Not signed in.", "Run `floom login` first.");
209
209
  await ensureSyncManifestDir();
210
- const apiUrl = cfg.apiUrl ?? getApiUrl();
210
+ const apiUrl = resolveApiUrl(cfg);
211
211
  const spinner = opts.spinner === false ? null : ora({ text: c.dim("Syncing skills..."), color: "yellow" }).start();
212
212
  let payload;
213
213
  try {
@@ -0,0 +1,25 @@
1
+ import pkg from "../package.json" with { type: "json" };
2
+ export const CLI_VERSION = pkg.version;
3
+ function numericParts(value) {
4
+ const normalized = value.trim().replace(/^v/i, "");
5
+ const numeric = normalized.match(/^\d+(?:\.\d+)*/)?.[0] ?? "0";
6
+ return numeric.split(".").map((part) => Number.parseInt(part, 10));
7
+ }
8
+ export function compareSemverish(a, b) {
9
+ const left = numericParts(a);
10
+ const right = numericParts(b);
11
+ const max = Math.max(left.length, right.length);
12
+ for (let i = 0; i < max; i++) {
13
+ const l = left[i] ?? 0;
14
+ const r = right[i] ?? 0;
15
+ if (l > r)
16
+ return 1;
17
+ if (l < r)
18
+ return -1;
19
+ }
20
+ return 0;
21
+ }
22
+ export function formatVersionLabel(value) {
23
+ const trimmed = value.trim();
24
+ return trimmed.match(/^v/i) ? trimmed.replace(/^v/i, "v") : `v${trimmed}`;
25
+ }