@floomhq/floom 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,6 +8,7 @@ floom login
8
8
  floom publish my-skill.md
9
9
  floom add awesome-skill
10
10
  floom list
11
+ floom library list
11
12
  ```
12
13
 
13
14
  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.
@@ -20,8 +21,13 @@ Returns a shareable link like `https://floom.dev/s/ffas93ud`. Anyone with the UR
20
21
  - `floom list` — show your published skills. Optional `--json`.
21
22
  - `floom add <url-or-slug>` — fetch a skill into `~/.claude/skills/<slug>.md`.
22
23
  - `floom info <url-or-slug>` — show skill metadata. Optional `--json`.
23
- - `floom sync` — preview: pull your own Floom-published skills into `~/.claude/skills/`.
24
+ - `floom sync` — preview: pull your published, saved, and subscribed library skills into `~/.claude/skills/`.
24
25
  - `floom watch` — preview: run `floom sync` repeatedly. Optional `--interval <seconds>`; minimum `10`.
26
+ - `floom library list` — list public starter libraries. Optional `--json`.
27
+ - `floom library create <slug> --name <name>` — create a personal or starter library. Optional `--public` / `--private` / `--unlisted`.
28
+ - `floom library add <library> <skill> [--folder <path>] [--tags a,b]` — add a skill to a library.
29
+ - `floom library subscribe <slug>` — subscribe to a public or unlisted library so sync can pull it locally.
30
+ - `floom move <slug> --folder <path>` — set your local folder override for a saved or library skill.
25
31
  - `floom delete <url-or-slug>` — delete one of your published skills. Optional `--yes`.
26
32
  - `floom doctor` — diagnose your Floom setup.
27
33
  - `floom whoami` — show the signed-in account.
@@ -46,7 +52,7 @@ version: 0.1.0
46
52
 
47
53
  Override the API host with `FLOOM_API_URL` (defaults to `https://floom.dev`).
48
54
 
49
- `floom sync` and `floom watch` are Version 1 preview commands for your own published skills. They store a machine-local manifest at `~/.floom/sync-manifest.json`.
55
+ `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`.
50
56
  The manifest records hashes for files Floom previously wrote. Version 1 sync writes missing files
51
57
  only. Remote updates, existing untracked files, and locally edited tracked files are skipped as
52
58
  conflicts. Symlinks are never followed. To accept the Floom version, move or delete the local file
package/dist/cli.js CHANGED
@@ -11,9 +11,10 @@ import { info } from "./info.js";
11
11
  import { deleteSkill } from "./delete.js";
12
12
  import { doctor } from "./doctor.js";
13
13
  import { sync } from "./sync.js";
14
+ import { libraryAddSkill, libraryCreate, libraryList, libraryRemoveSkill, librarySubscribe, libraryUnsubscribe, moveSkill, } from "./library.js";
14
15
  import { c, symbols } from "./ui.js";
15
16
  import { printError, FloomError } from "./errors.js";
16
- const VERSION = "1.0.0";
17
+ const VERSION = "1.0.1";
17
18
  const PKG = { name: "@floomhq/floom", version: VERSION };
18
19
  const V1_NOT_AVAILABLE = "Not available in Floom Version 1.";
19
20
  function usage() {
@@ -57,13 +58,15 @@ function usage() {
57
58
  ${c.dim("Account")}
58
59
  ${c.cyan("login")} Sign in with Google
59
60
  ${c.cyan("list")} List your published skills ${c.dim("[--json]")}
61
+ ${c.cyan("library")} Create, browse, and subscribe to skill libraries
62
+ ${c.cyan("move")} ${c.dim("<slug> --folder <path>")} Place a saved skill in a local folder
60
63
  ${c.cyan("delete")} ${c.dim("<url-or-slug>")} Delete one of your skills ${c.dim("[--yes]")}
61
64
  ${c.cyan("whoami")} Show the signed-in account
62
65
  ${c.cyan("logout")} Delete local credentials
63
66
 
64
67
  ${c.dim("System")}
65
- ${c.cyan("sync")} Preview: pull your own published skills
66
- ${c.cyan("watch")} Preview: poll your own published skills ${c.dim("[--interval <seconds>, min 10]")}
68
+ ${c.cyan("sync")} Preview: pull published, saved, and library skills
69
+ ${c.cyan("watch")} Preview: poll published, saved, and library skills ${c.dim("[--interval <seconds>, min 10]")}
67
70
  ${c.cyan("doctor")} Diagnose auth, API, and local setup
68
71
  ${c.cyan("--help")} Show this help
69
72
  ${c.cyan("--version")} Show version
@@ -177,6 +180,133 @@ function parseDeleteFlags(argv) {
177
180
  }
178
181
  return out;
179
182
  }
183
+ function normalizeFolder(value) {
184
+ return value === "root" || value === "/" || value === "." ? null : value;
185
+ }
186
+ function parseFolderTagFlags(argv) {
187
+ const out = { tags: [], rest: [] };
188
+ for (let i = 0; i < argv.length; i++) {
189
+ const a = argv[i] ?? "";
190
+ if (a === "--folder" || a.startsWith("--folder=")) {
191
+ const { value, nextIndex } = readFlagValue(argv, i, "--folder");
192
+ out.folder = normalizeFolder(value);
193
+ i = nextIndex;
194
+ }
195
+ else if (a === "--root") {
196
+ out.folder = null;
197
+ }
198
+ else if (a === "--tag" || a.startsWith("--tag=")) {
199
+ const { value, nextIndex } = readFlagValue(argv, i, "--tag");
200
+ out.tags.push(value);
201
+ i = nextIndex;
202
+ }
203
+ else if (a === "--tags" || a.startsWith("--tags=")) {
204
+ const { value, nextIndex } = readFlagValue(argv, i, "--tags");
205
+ out.tags.push(...value.split(",").map((tag) => tag.trim()).filter(Boolean));
206
+ i = nextIndex;
207
+ }
208
+ else if (a.startsWith("--")) {
209
+ throw new FloomError(`Unknown flag: ${a}`, "Use --folder <path>, --root, --tag <tag>, or --tags a,b.");
210
+ }
211
+ else {
212
+ out.rest.push(a);
213
+ }
214
+ }
215
+ return out;
216
+ }
217
+ function parseLibraryCreateFlags(argv) {
218
+ const out = { visibility: "unlisted" };
219
+ for (let i = 0; i < argv.length; i++) {
220
+ const a = argv[i] ?? "";
221
+ if (a === "--name" || a.startsWith("--name=")) {
222
+ const { value, nextIndex } = readFlagValue(argv, i, "--name");
223
+ out.name = value;
224
+ i = nextIndex;
225
+ }
226
+ else if (a === "--description" || a.startsWith("--description=")) {
227
+ const { value, nextIndex } = readFlagValue(argv, i, "--description");
228
+ out.description = value;
229
+ i = nextIndex;
230
+ }
231
+ else if (a === "--public")
232
+ out.visibility = "public";
233
+ else if (a === "--private")
234
+ out.visibility = "private";
235
+ else if (a === "--unlisted")
236
+ out.visibility = "unlisted";
237
+ else if (a.startsWith("--")) {
238
+ throw new FloomError(`Unknown flag: ${a}`, "Try `floom library create team-onboarding --name \"Team onboarding\"`.");
239
+ }
240
+ else if (!out.slug)
241
+ out.slug = a;
242
+ else
243
+ throw new FloomError(`Unexpected argument: ${a}`, "Try `floom library create <slug> --name <name>`.");
244
+ }
245
+ return out;
246
+ }
247
+ async function runLibrary(argv) {
248
+ const [subcommand, ...rest] = argv;
249
+ switch (subcommand ?? "list") {
250
+ case "list": {
251
+ const flags = parseListFlags(rest);
252
+ await libraryList(flags);
253
+ return;
254
+ }
255
+ case "create": {
256
+ const flags = parseLibraryCreateFlags(rest);
257
+ if (!flags.slug)
258
+ throw new FloomError("Missing library slug.", "Try `floom library create team-onboarding --name \"Team onboarding\"`.");
259
+ if (!flags.name)
260
+ throw new FloomError("Missing --name.", "Try `floom library create team-onboarding --name \"Team onboarding\"`.");
261
+ await libraryCreate({
262
+ slug: flags.slug,
263
+ name: flags.name,
264
+ ...(flags.description !== undefined ? { description: flags.description } : {}),
265
+ visibility: flags.visibility,
266
+ });
267
+ return;
268
+ }
269
+ case "add": {
270
+ const flags = parseFolderTagFlags(rest);
271
+ const [librarySlug, skillSlug] = flags.rest;
272
+ if (!librarySlug || !skillSlug) {
273
+ throw new FloomError("Missing library or skill slug.", "Try `floom library add team-onboarding support-tone --folder support`.");
274
+ }
275
+ await libraryAddSkill({
276
+ librarySlug,
277
+ skillSlug,
278
+ ...(flags.folder !== undefined ? { folder: flags.folder } : {}),
279
+ tags: flags.tags,
280
+ });
281
+ return;
282
+ }
283
+ case "remove":
284
+ case "rm": {
285
+ const [librarySlug, skillSlug] = rest;
286
+ if (!librarySlug || !skillSlug) {
287
+ throw new FloomError("Missing library or skill slug.", "Try `floom library remove team-onboarding support-tone`.");
288
+ }
289
+ await libraryRemoveSkill(librarySlug, skillSlug);
290
+ return;
291
+ }
292
+ case "subscribe": {
293
+ const slug = rest[0];
294
+ if (!slug)
295
+ throw new FloomError("Missing library slug.", "Try `floom library subscribe superpowers`.");
296
+ await librarySubscribe(slug);
297
+ return;
298
+ }
299
+ case "unsubscribe": {
300
+ const slug = rest[0];
301
+ if (!slug)
302
+ throw new FloomError("Missing library slug.", "Try `floom library unsubscribe superpowers`.");
303
+ await libraryUnsubscribe(slug);
304
+ return;
305
+ }
306
+ default:
307
+ throw new FloomError(`Unknown library command: ${subcommand}`, "Use: list, create, add, remove, subscribe, unsubscribe.");
308
+ }
309
+ }
180
310
  function parseWatchFlags(argv) {
181
311
  const out = { intervalSeconds: 60 };
182
312
  for (let i = 0; i < argv.length; i++) {
@@ -335,9 +465,20 @@ async function main() {
335
465
  }
336
466
  case "library":
337
467
  case "lib":
338
- notAvailable("`floom library`");
339
- case "move":
340
- notAvailable("`floom move`");
468
+ await runLibrary(rest);
469
+ return;
470
+ case "move": {
471
+ const flags = parseFolderTagFlags(rest);
472
+ const slug = flags.rest[0];
473
+ if (!slug) {
474
+ throw new FloomError("Missing skill slug.", "Try `floom move support-tone --folder support/tone`.");
475
+ }
476
+ if (flags.folder === undefined) {
477
+ throw new FloomError("Missing --folder.", "Use --folder <path> or --root. Add --tag or --tags when useful.");
478
+ }
479
+ await moveSkill({ slug, folder: flags.folder, tags: flags.tags });
480
+ return;
481
+ }
341
482
  case "mcp":
342
483
  notAvailable("`floom mcp setup`");
343
484
  case "doctor":
package/dist/doctor.js CHANGED
@@ -3,7 +3,7 @@ import { join } from "node:path";
3
3
  import { stat, readFile, access, readdir, constants } from "node:fs/promises";
4
4
  import { getApiUrl, readConfig, CONFIG_PATH } from "./config.js";
5
5
  import { c, symbols } from "./ui.js";
6
- const CLI_VERSION = "1.0.0";
6
+ const CLI_VERSION = "1.0.1";
7
7
  function statusBadge(s) {
8
8
  if (s === "ok")
9
9
  return c.green(symbols.ok);
package/dist/info.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import ora from "ora";
2
2
  import { getApiUrl, readConfig } from "./config.js";
3
3
  import { getJson } from "./lib/api.js";
4
+ import { extractRequires, formatToolList, formatType } from "./lib/skill-labels.js";
4
5
  import { c, symbols } from "./ui.js";
5
6
  import { FloomError } from "./errors.js";
6
7
  function slugFromInput(input) {
@@ -29,9 +30,12 @@ export async function info(opts) {
29
30
  spinner?.stop();
30
31
  }
31
32
  if (opts.json) {
32
- // Strip body_md from JSON output to keep it summary-shaped.
33
+ // Strip body_md from JSON output to keep it summary-shaped, but expose
34
+ // the parsed `requires` list so scripted consumers don't need to parse
35
+ // YAML themselves.
33
36
  const { body_md: _body, ...summary } = detail;
34
- process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`);
37
+ const requires = extractRequires(detail.body_md);
38
+ process.stdout.write(`${JSON.stringify({ ...summary, requires }, null, 2)}\n`);
35
39
  return;
36
40
  }
37
41
  const title = detail.title ?? c.dim("(untitled)");
@@ -41,7 +45,11 @@ export async function info(opts) {
41
45
  process.stdout.write(` ${c.dim("About: ")}${detail.description}\n`);
42
46
  }
43
47
  process.stdout.write(` ${c.dim("Visibility: ")}${detail.visibility}\n`);
44
- process.stdout.write(` ${c.dim("Type: ")}${detail.asset_type ?? "skill"}\n`);
48
+ process.stdout.write(` ${c.dim("Type: ")}${formatType(detail.asset_type)}\n`);
49
+ const requires = extractRequires(detail.body_md);
50
+ if (requires.length > 0) {
51
+ process.stdout.write(` ${c.dim("Needs: ")}${formatToolList(requires)}\n`);
52
+ }
45
53
  if (detail.version) {
46
54
  process.stdout.write(` ${c.dim("Version: ")}${detail.version}\n`);
47
55
  }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * CLI-side mirror of the web's skill-labels: type label + tool label + a tiny
3
+ * frontmatter parser for `requires:`. No React, no `gray-matter` — keep the
4
+ * CLI dep tree thin.
5
+ *
6
+ * The frontmatter parser intentionally only supports two `requires` shapes:
7
+ * requires: [gmail, linear]
8
+ * requires:
9
+ * - gmail
10
+ * - linear
11
+ *
12
+ * That covers >99% of real skills. If a skill uses a more exotic YAML shape
13
+ * we silently get an empty list — not a crash.
14
+ */
15
+ const SKILL_TYPE_LABELS = {
16
+ knowledge: "Knowledge",
17
+ instruction: "Instruction",
18
+ workflow: "Workflow",
19
+ skill: "Skill",
20
+ };
21
+ const TOOL_LABELS = {
22
+ gmail: "Gmail",
23
+ "google-mail": "Gmail",
24
+ calendar: "Google Calendar",
25
+ "google-calendar": "Google Calendar",
26
+ drive: "Google Drive",
27
+ "google-drive": "Google Drive",
28
+ docs: "Google Docs",
29
+ "google-docs": "Google Docs",
30
+ sheets: "Google Sheets",
31
+ "google-sheets": "Google Sheets",
32
+ github: "GitHub",
33
+ gitlab: "GitLab",
34
+ linear: "Linear",
35
+ notion: "Notion",
36
+ slack: "Slack",
37
+ intercom: "Intercom",
38
+ zendesk: "Zendesk",
39
+ hubspot: "HubSpot",
40
+ salesforce: "Salesforce",
41
+ stripe: "Stripe",
42
+ airtable: "Airtable",
43
+ jira: "Jira",
44
+ asana: "Asana",
45
+ trello: "Trello",
46
+ zapier: "Zapier",
47
+ composio: "Composio",
48
+ whatsapp: "WhatsApp",
49
+ telegram: "Telegram",
50
+ discord: "Discord",
51
+ twitter: "Twitter",
52
+ x: "X",
53
+ linkedin: "LinkedIn",
54
+ openai: "OpenAI",
55
+ anthropic: "Anthropic",
56
+ gemini: "Gemini",
57
+ claude: "Claude",
58
+ perplexity: "Perplexity",
59
+ supabase: "Supabase",
60
+ vercel: "Vercel",
61
+ resend: "Resend",
62
+ twilio: "Twilio",
63
+ webhook: "Webhook",
64
+ http: "HTTP",
65
+ };
66
+ export function formatType(value) {
67
+ if (!value)
68
+ return "Skill";
69
+ const normalized = value.toLowerCase();
70
+ return SKILL_TYPE_LABELS[normalized] ?? "Skill";
71
+ }
72
+ export function formatTool(slug) {
73
+ const normalized = slug.trim().toLowerCase();
74
+ if (!normalized)
75
+ return slug;
76
+ if (TOOL_LABELS[normalized])
77
+ return TOOL_LABELS[normalized];
78
+ return normalized
79
+ .split(/[-_\s]+/)
80
+ .filter(Boolean)
81
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
82
+ .join(" ");
83
+ }
84
+ export function formatToolList(tools) {
85
+ return tools.map(formatTool).join(", ");
86
+ }
87
+ /**
88
+ * Pull `requires:` out of YAML frontmatter. Returns a deduped lowercase list
89
+ * or [] if absent / malformed.
90
+ */
91
+ export function extractRequires(body) {
92
+ if (!body)
93
+ return [];
94
+ const trimmed = body.replace(/^/, "");
95
+ if (!trimmed.startsWith("---"))
96
+ return [];
97
+ const end = trimmed.indexOf("\n---", 3);
98
+ if (end === -1)
99
+ return [];
100
+ const headerBlock = trimmed.slice(3, end);
101
+ const lines = headerBlock.split(/\r?\n/);
102
+ const seen = new Set();
103
+ const out = [];
104
+ for (let i = 0; i < lines.length; i++) {
105
+ const raw = lines[i] ?? "";
106
+ const line = raw.trim();
107
+ const inlineMatch = /^requires\s*:\s*\[(.*?)\]\s*$/i.exec(line);
108
+ if (inlineMatch) {
109
+ const inner = inlineMatch[1] ?? "";
110
+ for (const part of inner.split(",")) {
111
+ const value = part.trim().replace(/^['"]|['"]$/g, "").toLowerCase();
112
+ if (value && !seen.has(value)) {
113
+ seen.add(value);
114
+ out.push(value);
115
+ }
116
+ }
117
+ return out;
118
+ }
119
+ if (/^requires\s*:\s*$/i.test(line)) {
120
+ // Block form. Scan subsequent indented `- foo` lines until indent breaks.
121
+ for (let j = i + 1; j < lines.length; j++) {
122
+ const next = lines[j] ?? "";
123
+ const itemMatch = /^\s+-\s+(.+?)\s*$/.exec(next);
124
+ if (!itemMatch) {
125
+ // First non-list line ends the block.
126
+ if (next.trim() === "")
127
+ continue;
128
+ break;
129
+ }
130
+ const value = (itemMatch[1] ?? "").replace(/^['"]|['"]$/g, "").trim().toLowerCase();
131
+ if (value && !seen.has(value)) {
132
+ seen.add(value);
133
+ out.push(value);
134
+ }
135
+ }
136
+ return out;
137
+ }
138
+ }
139
+ return out;
140
+ }
package/dist/library.js CHANGED
@@ -44,7 +44,32 @@ export async function libraryCreate(opts) {
44
44
  ...(opts.visibility ? { visibility: opts.visibility } : {}),
45
45
  });
46
46
  process.stdout.write(`\n${symbols.ok} Library created: ${c.cyan(result.slug)}\n`);
47
- process.stdout.write(` ${c.dim("View:")} ${apiUrl}/library/${result.slug}\n\n`);
47
+ process.stdout.write(` ${c.dim("API:")} ${apiUrl}/api/v1/libraries/${result.slug}\n`);
48
+ process.stdout.write(` ${c.dim("Sync:")} floom library subscribe ${result.slug}\n\n`);
49
+ }
50
+ export async function libraryAddSkill(opts) {
51
+ const cfg = await readConfig();
52
+ if (!cfg)
53
+ throw new FloomError("Not signed in.", "Run `floom login` first.");
54
+ const apiUrl = cfg.apiUrl ?? getApiUrl();
55
+ await postJson(`${apiUrl}/api/v1/libraries/${encodeURIComponent(opts.librarySlug)}/skills`, "add skill to library", cfg.accessToken, {
56
+ skill_slug: opts.skillSlug,
57
+ ...(opts.folder !== undefined ? { folder: opts.folder } : {}),
58
+ ...(opts.tags ? { tags: opts.tags } : {}),
59
+ });
60
+ const folderText = opts.folder ?? c.dim("(root)");
61
+ const tagsText = opts.tags?.length ? opts.tags.join(", ") : c.dim("(none)");
62
+ process.stdout.write(`\n${symbols.ok} Added ${c.cyan(opts.skillSlug)} to ${c.cyan(opts.librarySlug)}\n`);
63
+ process.stdout.write(` ${c.dim("folder:")} ${folderText}\n`);
64
+ process.stdout.write(` ${c.dim("tags:")} ${tagsText}\n\n`);
65
+ }
66
+ export async function libraryRemoveSkill(librarySlug, skillSlug) {
67
+ const cfg = await readConfig();
68
+ if (!cfg)
69
+ throw new FloomError("Not signed in.", "Run `floom login` first.");
70
+ const apiUrl = cfg.apiUrl ?? getApiUrl();
71
+ await deleteRequest(`${apiUrl}/api/v1/libraries/${encodeURIComponent(librarySlug)}/skills/${encodeURIComponent(skillSlug)}`, "remove skill from library", cfg.accessToken);
72
+ process.stdout.write(`\n${symbols.ok} Removed ${c.cyan(skillSlug)} from ${c.cyan(librarySlug)}\n\n`);
48
73
  }
49
74
  export async function librarySubscribe(slug) {
50
75
  const cfg = await readConfig();
package/dist/list.js CHANGED
@@ -1,15 +1,18 @@
1
1
  import ora from "ora";
2
2
  import { getApiUrl, readConfig } from "./config.js";
3
3
  import { getJson } from "./lib/api.js";
4
+ import { extractRequires, formatToolList, formatType } from "./lib/skill-labels.js";
4
5
  import { c, symbols } from "./ui.js";
5
6
  import { FloomError } from "./errors.js";
6
7
  function formatRow(s) {
7
8
  const title = s.title ?? c.dim("(untitled)");
8
9
  const visibility = c.dim(`[${s.visibility}]`);
9
- const type = c.dim(`[${s.asset_type ?? "skill"}]`);
10
+ const type = c.dim(`[${formatType(s.asset_type)}]`);
10
11
  const version = s.version ? c.dim(`v${s.version}`) : "";
11
12
  const updated = c.dim(formatRelative(s.updated_at));
12
- return ` ${c.cyan(s.slug.padEnd(14))} ${title} ${type} ${visibility} ${version} ${updated}`;
13
+ const requires = extractRequires(s.body_md);
14
+ const needs = requires.length > 0 ? c.dim(`needs ${formatToolList(requires)}`) : "";
15
+ return ` ${c.cyan(s.slug.padEnd(14))} ${title} ${type} ${visibility} ${version} ${updated}${needs ? ` ${needs}` : ""}`;
13
16
  }
14
17
  function formatRelative(iso) {
15
18
  const diff = Date.now() - new Date(iso).getTime();
package/dist/sync.js CHANGED
@@ -223,7 +223,7 @@ export async function sync(opts = {}) {
223
223
  }
224
224
  for (const skill of payload.skills)
225
225
  validateSyncSkillShape(skill);
226
- // Version 1 preview syncs owned published skills only.
226
+ // Version 1 preview syncs published, saved, and subscribed library skills.
227
227
  const all = payload.skills;
228
228
  const seen = new Set();
229
229
  let unchanged = 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floomhq/floom",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "Publish AI skills from your terminal. Share with a link.",
5
5
  "license": "MIT",
6
6
  "type": "module",