@floomhq/floom 1.0.14 → 1.0.17

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
@@ -1,13 +1,14 @@
1
1
  import { constants } from "node:fs";
2
2
  import { lstat, mkdir, open } from "node:fs/promises";
3
- import { createHash } from "node:crypto";
3
+ import { homedir } from "node:os";
4
4
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
5
  import ora from "ora";
6
6
  import { readConfig, resolveApiUrl } from "./config.js";
7
7
  import { getJson } from "./lib/api.js";
8
8
  import { c, symbols } from "./ui.js";
9
9
  import { FloomError } from "./errors.js";
10
- import { resolveSkillsDir, skillsDirHint } from "./targets.js";
10
+ import { normalizeRemotePackageFiles, packageHash, sha256Bytes } from "./package.js";
11
+ import { manifestKey, markSynced, readSyncManifest, withSyncLock, writeSyncManifest } from "./sync-manifest.js";
11
12
  const SLUG_RE = /^[A-Za-z0-9_-]{1,128}$/;
12
13
  const FD_PATH_ROOT = "/proc/self/fd";
13
14
  function slugFromInput(input) {
@@ -21,23 +22,33 @@ function slugFromInput(input) {
21
22
  return trimmed.replace(/\.(md|json)$/i, "");
22
23
  }
23
24
  }
25
+ function skillsDir(target) {
26
+ if (target === "codex") {
27
+ const codexHome = process.env.CODEX_HOME ?? join(homedir(), ".codex");
28
+ return process.env.CODEX_SKILLS_DIR ?? join(codexHome, "skills");
29
+ }
30
+ return process.env.CLAUDE_SKILLS_DIR ?? join(homedir(), ".claude", "skills");
31
+ }
24
32
  function skillPath(root, slug) {
33
+ return join(root, slug, "SKILL.md");
34
+ }
35
+ function legacySkillPath(root, slug) {
25
36
  return join(root, `${slug}.md`);
26
37
  }
38
+ function skillsDirHint(target) {
39
+ return target === "codex" ? "CODEX_SKILLS_DIR" : "CLAUDE_SKILLS_DIR";
40
+ }
27
41
  function setupCommand(target) {
28
42
  return `npx -y @floomhq/floom setup --target ${target} --yes`;
29
43
  }
30
- function sha256(input) {
31
- return createHash("sha256").update(input).digest("hex");
32
- }
33
- async function localHash(path) {
44
+ async function readLocalFile(path) {
34
45
  try {
35
46
  const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
36
47
  try {
37
48
  const stat = await handle.stat();
38
49
  if (!stat.isFile())
39
50
  throw new FloomError("Local path is blocked by an existing file or directory.");
40
- return sha256(await handle.readFile("utf8"));
51
+ return await handle.readFile();
41
52
  }
42
53
  finally {
43
54
  await handle.close();
@@ -55,6 +66,47 @@ async function localHash(path) {
55
66
  throw err;
56
67
  }
57
68
  }
69
+ async function localPackageHash(root, slug, target, files) {
70
+ const main = await readLocalFile(target);
71
+ if (main === null) {
72
+ const legacy = await readLocalFile(legacySkillPath(root, slug));
73
+ if (legacy !== null && files.length === 0)
74
+ return packageHash(legacy.toString("utf8"), []);
75
+ return null;
76
+ }
77
+ const localFiles = [];
78
+ for (const file of files) {
79
+ const bytes = await readLocalFile(join(dirname(target), file.path));
80
+ if (bytes === null)
81
+ return null;
82
+ localFiles.push({ path: file.path, bytes, sha256: file.sha256 });
83
+ }
84
+ return packageHash(main.toString("utf8"), localFiles);
85
+ }
86
+ async function markInstallSynced(root, slug, files) {
87
+ const manifest = await readSyncManifest();
88
+ for (const file of files) {
89
+ markSynced(manifest, manifestKey(root, file.target), slug, file.hash);
90
+ }
91
+ await writeSyncManifest(manifest);
92
+ }
93
+ async function preflightInstallPackage(root, files, opts) {
94
+ for (const file of files) {
95
+ await ensureSafeParentDirectory(root, file.target);
96
+ const existing = await readLocalFile(file.target);
97
+ if (existing === null)
98
+ continue;
99
+ if (sha256Buffer(existing) === file.hash)
100
+ continue;
101
+ if (opts.force)
102
+ continue;
103
+ return file.target;
104
+ }
105
+ return null;
106
+ }
107
+ function sha256Buffer(input) {
108
+ return sha256Bytes(input);
109
+ }
58
110
  async function writeInstallFile(root, target, body) {
59
111
  const parent = await openSafeParentDirectory(root, target);
60
112
  let handle = null;
@@ -67,8 +119,9 @@ async function writeInstallFile(root, target, body) {
67
119
  await parent.close();
68
120
  }
69
121
  }
70
- async function overwriteInstallFile(target, body) {
71
- const handle = await open(target, constants.O_WRONLY | constants.O_TRUNC | constants.O_NOFOLLOW);
122
+ async function overwriteInstallFile(root, target, body) {
123
+ const parent = await openSafeParentDirectory(root, target);
124
+ const handle = await open(childCreatePath(parent, dirname(target), basename(target)), constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | constants.O_NOFOLLOW, 0o600);
72
125
  try {
73
126
  const stat = await handle.stat();
74
127
  if (!stat.isFile())
@@ -77,6 +130,7 @@ async function overwriteInstallFile(target, body) {
77
130
  }
78
131
  finally {
79
132
  await handle.close();
133
+ await parent.close();
80
134
  }
81
135
  }
82
136
  async function openSafeParentDirectory(root, target) {
@@ -89,7 +143,7 @@ function childCreatePath(parent, fallbackParent, name) {
89
143
  return join(resolve(fallbackParent), name);
90
144
  }
91
145
  async function writeAll(handle, body) {
92
- const buffer = Buffer.from(body, "utf8");
146
+ const buffer = Buffer.isBuffer(body) ? body : Buffer.from(body, "utf8");
93
147
  let offset = 0;
94
148
  while (offset < buffer.length) {
95
149
  const result = await handle.write(buffer, offset, buffer.length - offset, offset);
@@ -142,14 +196,14 @@ async function assertSafeDirectory(path) {
142
196
  }
143
197
  export async function install(slugInput, opts = {}) {
144
198
  const targetAgent = opts.target ?? "claude";
145
- const root = resolveSkillsDir(targetAgent);
199
+ const root = skillsDir(targetAgent);
146
200
  const slug = slugFromInput(slugInput);
147
201
  if (!SLUG_RE.test(slug)) {
148
202
  throw new FloomError(`Invalid skill slug: ${slugInput}`);
149
203
  }
150
204
  const cfg = await readConfig();
151
205
  const apiUrl = resolveApiUrl(cfg);
152
- const spinner = opts.json ? null : ora({ text: c.dim(`Adding ${slug}...`), color: "yellow" }).start();
206
+ const spinner = ora({ text: c.dim(`Adding ${slug}...`), color: "yellow" }).start();
153
207
  let detail;
154
208
  try {
155
209
  detail = await getJson(`${apiUrl}/api/v1/skills/${encodeURIComponent(slug)}`, "fetch skill", cfg?.accessToken);
@@ -158,75 +212,94 @@ export async function install(slugInput, opts = {}) {
158
212
  }
159
213
  }
160
214
  catch (err) {
161
- spinner?.stop();
162
- throw err;
163
- }
164
- try {
165
- await mkdir(root, { recursive: true, mode: 0o700 });
166
- }
167
- catch (err) {
168
- if (err.code === "EEXIST") {
169
- throw new FloomError(`${skillsDirHint(targetAgent)} points to a file, not a directory.`, `Set ${skillsDirHint(targetAgent)} to a directory, or remove the file blocking it.`);
170
- }
215
+ spinner.stop();
171
216
  throw err;
172
217
  }
173
218
  const target = skillPath(root, slug);
174
- const remoteHash = sha256(detail.body_md);
175
- const existing = await localHash(target);
176
- let action;
177
- if (existing === remoteHash) {
178
- action = "unchanged";
179
- }
180
- else if (existing !== null && opts.force) {
219
+ const remotePackageFiles = normalizeRemotePackageFiles(detail.package_files ?? detail.files);
220
+ const installFiles = [
221
+ { target, bytes: detail.body_md, hash: sha256Bytes(detail.body_md) },
222
+ ...remotePackageFiles.map((file) => ({
223
+ target: join(dirname(target), file.path),
224
+ bytes: file.bytes,
225
+ hash: file.sha256,
226
+ })),
227
+ ];
228
+ const remoteHash = packageHash(detail.body_md, remotePackageFiles);
229
+ let action = "installed";
230
+ let manifestWarning = null;
231
+ await withSyncLock(async () => {
181
232
  try {
182
- await overwriteInstallFile(target, detail.body_md);
233
+ await mkdir(root, { recursive: true, mode: 0o700 });
183
234
  }
184
235
  catch (err) {
185
- const code = err.code;
186
- if (code === "ELOOP")
187
- throw new FloomError("Local path is a symbolic link.", "Move or delete the local path, then run `npx -y @floomhq/floom add` again.");
236
+ if (err.code === "EEXIST") {
237
+ throw new FloomError(`${skillsDirHint(targetAgent)} points to a file, not a directory.`, `Set ${skillsDirHint(targetAgent)} to a directory, or remove the file blocking it.`);
238
+ }
188
239
  throw err;
189
240
  }
190
- action = "updated";
191
- }
192
- else if (existing !== null) {
193
- 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.");
194
- }
195
- else {
196
- try {
197
- await writeInstallFile(root, target, detail.body_md);
241
+ await ensureSafeParentDirectory(root, target);
242
+ const existing = await localPackageHash(root, slug, target, remotePackageFiles);
243
+ const conflictingTarget = await preflightInstallPackage(root, installFiles, opts.force ? { force: true } : {});
244
+ if (conflictingTarget) {
245
+ 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: ${relative(root, conflictingTarget).split(sep).join("/")}`);
198
246
  }
199
- catch (err) {
200
- const code = err.code;
201
- if (code === "EEXIST") {
202
- 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.");
247
+ if (existing === remoteHash) {
248
+ action = "unchanged";
249
+ }
250
+ else if (existing !== null && opts.force) {
251
+ try {
252
+ await overwriteInstallFile(root, target, detail.body_md);
253
+ for (const file of remotePackageFiles) {
254
+ await overwriteInstallFile(root, join(dirname(target), file.path), file.bytes);
255
+ }
256
+ }
257
+ catch (err) {
258
+ const code = err.code;
259
+ if (code === "ELOOP")
260
+ throw new FloomError("Local path is a symbolic link.", "Move or delete the local path, then run `npx -y @floomhq/floom add` again.");
261
+ throw err;
203
262
  }
204
- if (code === "ELOOP") {
205
- throw new FloomError("Local path is a symbolic link.", "Move or delete the local path, then run `npx -y @floomhq/floom add` again.");
263
+ action = "updated";
264
+ }
265
+ else if (existing !== null) {
266
+ 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.");
267
+ }
268
+ else {
269
+ try {
270
+ await writeInstallFile(root, target, detail.body_md);
271
+ for (const file of remotePackageFiles) {
272
+ await writeInstallFile(root, join(dirname(target), file.path), file.bytes);
273
+ }
206
274
  }
207
- if (code === "ENOENT") {
208
- throw new FloomError("Local path changed during install.", "Run `npx -y @floomhq/floom add` again.");
275
+ catch (err) {
276
+ const code = err.code;
277
+ if (code === "EEXIST") {
278
+ throw new FloomError("Local skill already exists with different content.", "Move or delete the local file, then run `npx -y @floomhq/floom add` again.");
279
+ }
280
+ if (code === "ELOOP") {
281
+ throw new FloomError("Local path is a symbolic link.", "Move or delete the local path, then run `npx -y @floomhq/floom add` again.");
282
+ }
283
+ if (code === "ENOENT") {
284
+ throw new FloomError("Local path changed during install.", "Run `npx -y @floomhq/floom add` again.");
285
+ }
286
+ throw err;
209
287
  }
210
- throw err;
288
+ action = "installed";
211
289
  }
212
- action = "installed";
213
- }
214
- const result = {
215
- slug,
216
- title: detail.title,
217
- action,
218
- target: targetAgent,
219
- path: target,
220
- content_hash: remoteHash,
221
- };
222
- spinner?.stop();
223
- if (opts.json) {
224
- process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
225
- return;
226
- }
290
+ try {
291
+ await markInstallSynced(root, slug, installFiles);
292
+ }
293
+ catch (err) {
294
+ manifestWarning = err instanceof Error ? err.message : String(err);
295
+ }
296
+ });
297
+ spinner.stop();
227
298
  process.stdout.write(`\n${symbols.ok} [floom] ${action} ${c.bold(slug)}\n`);
228
- process.stdout.write(` ${c.dim(target)}\n\n`);
229
- process.stdout.write(` ${c.dim("No Floom account was required. This is a one-time local Markdown install; run add --force or update to replace it. Save or follow skills after login when you want sync/MCP.")}\n\n`);
299
+ process.stdout.write(` ${c.dim(dirname(target))}\n\n`);
300
+ if (manifestWarning) {
301
+ process.stdout.write(` ${c.yellow("!")} ${c.dim(`Installed, but sync tracking was not updated: ${manifestWarning}`)}\n\n`);
302
+ }
230
303
  process.stdout.write(` ${c.bold("Next")}\n`);
231
304
  if (opts.setup) {
232
305
  process.stdout.write(` ${c.dim("1.")} Floom is connecting ${targetAgent === "claude" ? "Claude Code" : "Codex"} now.\n`);
package/dist/library.js CHANGED
@@ -3,7 +3,6 @@ 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";
6
- import { resolveSkillsDir } from "./targets.js";
7
6
  function formatLibraryRow(lib) {
8
7
  const name = lib.name ?? c.dim("(unnamed)");
9
8
  const vis = c.dim(`[${lib.visibility}]`);
@@ -46,8 +45,7 @@ export async function libraryCreate(opts) {
46
45
  });
47
46
  process.stdout.write(`\n${symbols.ok} Library created: ${c.cyan(result.slug)}\n`);
48
47
  process.stdout.write(` ${c.dim("API:")} ${apiUrl}/api/v1/libraries/${result.slug}\n`);
49
- process.stdout.write(` ${c.dim("Follow:")} npx -y @floomhq/floom library subscribe ${result.slug}\n`);
50
- process.stdout.write(` ${c.dim("Sync:")} npx -y @floomhq/floom sync\n\n`);
48
+ process.stdout.write(` ${c.dim("Sync:")} floom library subscribe ${result.slug}\n\n`);
51
49
  }
52
50
  export async function libraryAddSkill(opts) {
53
51
  const cfg = await readConfig();
@@ -79,10 +77,8 @@ export async function librarySubscribe(slug) {
79
77
  throw new FloomError("Not signed in.", "Run `npx -y @floomhq/floom login` first.");
80
78
  const apiUrl = resolveApiUrl(cfg);
81
79
  await postJson(`${apiUrl}/api/v1/me/subscriptions`, "subscribe to library", cfg.accessToken, { library_slug: slug });
82
- process.stdout.write(`\n${symbols.ok} Following ${c.cyan(slug)}\n`);
83
- process.stdout.write(` ${c.dim("Library saved to your Floom account.")}\n`);
84
- process.stdout.write(` ${c.dim(`Run \`npx -y @floomhq/floom sync\` to write skills under ${resolveSkillsDir("claude")}/${slug}/ by default.`)}\n`);
85
- process.stdout.write(` ${c.dim("Run `npx -y @floomhq/floom mcp` to keep followed libraries updated while your agent is connected.")}\n\n`);
80
+ process.stdout.write(`\n${symbols.ok} Subscribed to ${c.cyan(slug)}\n`);
81
+ process.stdout.write(` ${c.dim("Run `floom sync --target claude` or `floom sync --target codex` to pull this library locally.")}\n\n`);
86
82
  }
87
83
  export async function libraryUnsubscribe(slug) {
88
84
  const cfg = await readConfig();
@@ -90,7 +86,7 @@ export async function libraryUnsubscribe(slug) {
90
86
  throw new FloomError("Not signed in.", "Run `npx -y @floomhq/floom login` first.");
91
87
  const apiUrl = resolveApiUrl(cfg);
92
88
  await deleteRequest(`${apiUrl}/api/v1/me/subscriptions/${encodeURIComponent(slug)}`, "unsubscribe from library", cfg.accessToken);
93
- process.stdout.write(`\n${symbols.ok} Unfollowed ${c.cyan(slug)}\n\n`);
89
+ process.stdout.write(`\n${symbols.ok} Unsubscribed from ${c.cyan(slug)}\n\n`);
94
90
  }
95
91
  export async function moveSkill(opts) {
96
92
  const cfg = await readConfig();
package/dist/list.js CHANGED
@@ -40,25 +40,24 @@ export async function list(opts) {
40
40
  }
41
41
  const apiUrl = resolveApiUrl(cfg);
42
42
  const spinner = opts.json ? null : ora({ text: c.dim("Loading skills..."), color: "yellow" }).start();
43
- let skills = [];
43
+ let published = [];
44
44
  try {
45
45
  const mine = await getJson(`${apiUrl}/api/v1/me/skills`, "load your skills", cfg.accessToken);
46
- skills = mine.skills ?? [];
46
+ published = mine.skills ?? [];
47
47
  }
48
48
  finally {
49
49
  spinner?.stop();
50
50
  }
51
51
  if (opts.json) {
52
- process.stdout.write(`${JSON.stringify({ skills }, null, 2)}\n`);
52
+ process.stdout.write(`${JSON.stringify({ published }, null, 2)}\n`);
53
53
  return;
54
54
  }
55
- process.stdout.write(`\n${symbols.dot} ${c.bold("Synced library")} ${c.dim(`(${skills.length})`)}\n`);
56
- process.stdout.write(` ${c.dim("Includes your published, saved, and followed library skills.")}\n\n`);
57
- if (skills.length === 0) {
58
- process.stdout.write(` ${c.dim("Nothing in your library yet. Add a shared link, save a skill on floom.dev, or publish `npx -y @floomhq/floom publish skill.md`.")}\n`);
55
+ process.stdout.write(`\n${symbols.dot} ${c.bold("Published")} ${c.dim(`(${published.length})`)}\n\n`);
56
+ if (published.length === 0) {
57
+ process.stdout.write(` ${c.dim("Nothing published yet. Try `npx -y @floomhq/floom publish my-skill`.")}\n`);
59
58
  }
60
59
  else {
61
- for (const s of skills)
60
+ for (const s of published)
62
61
  process.stdout.write(`${formatRow(s)}\n`);
63
62
  }
64
63
  process.stdout.write("\n");
package/dist/login.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createServer } from "node:http";
2
- import { randomBytes } from "node:crypto";
2
+ import { createServer as createNetServer } from "node:net";
3
3
  import open from "open";
4
4
  import ora from "ora";
5
5
  import { getApiUrl, writeConfig } from "./config.js";
@@ -9,6 +9,7 @@ const DEFAULT_PORT = 7456;
9
9
  const TIMEOUT_MS = 5 * 60 * 1000;
10
10
  export async function login() {
11
11
  const apiUrl = getApiUrl();
12
+ const port = await pickPort();
12
13
  process.stdout.write(header());
13
14
  process.stdout.write(`${symbols.arrow} Opening browser to sign in with Google...\n\n`);
14
15
  const spinner = ora({
@@ -17,12 +18,12 @@ export async function login() {
17
18
  }).start();
18
19
  let tokens;
19
20
  try {
20
- tokens = await waitForCallback();
21
+ tokens = await waitForCallback(port);
21
22
  }
22
23
  catch (err) {
23
24
  spinner.stop();
24
25
  if (err instanceof Error && /timed out/i.test(err.message)) {
25
- throw new FloomError("No worries try `npx -y @floomhq/floom login` again when ready.");
26
+ throw new FloomError("No worries - try `npx -y @floomhq/floom login` again when ready.");
26
27
  }
27
28
  throw err;
28
29
  }
@@ -56,26 +57,48 @@ export async function login() {
56
57
  spinner.stop();
57
58
  process.stdout.write(`${symbols.ok} Signed in as ${c.bold(me.email ?? me.id)}\n`);
58
59
  process.stdout.write(` ${c.dim("Your token is saved at ~/.floom/config.json")}\n\n`);
59
- process.stdout.write(` ${c.bold("Account mode is on")}\n`);
60
- process.stdout.write(` ${c.dim("Publish skills, save skills on floom.dev, follow libraries, and sync your account library locally.")}\n\n`);
61
- process.stdout.write(` ${c.dim("Publish:")} ${c.cyan("npx -y @floomhq/floom publish <file.md>")}\n`);
62
- process.stdout.write(` ${c.dim("Sync:")} ${c.cyan("npx -y @floomhq/floom sync")}\n`);
63
- process.stdout.write(` ${c.dim("MCP:")} ${c.cyan("npx -y @floomhq/floom mcp")}\n\n`);
64
60
  }
65
- function waitForCallback() {
61
+ /** Reserve a free port. Prefers 7456 for existing Supabase CLI auth setups. */
62
+ async function pickPort() {
63
+ if (await canListen(DEFAULT_PORT))
64
+ return DEFAULT_PORT;
65
+ return reserveEphemeralPort();
66
+ }
67
+ function canListen(port) {
68
+ return new Promise((resolve) => {
69
+ const server = createNetServer();
70
+ server.once("error", () => resolve(false));
71
+ server.listen(port, "127.0.0.1", () => {
72
+ server.close(() => resolve(true));
73
+ });
74
+ });
75
+ }
76
+ function reserveEphemeralPort() {
77
+ return new Promise((resolve, reject) => {
78
+ const server = createNetServer();
79
+ server.once("error", reject);
80
+ server.listen(0, "127.0.0.1", () => {
81
+ const address = server.address();
82
+ if (!address || typeof address === "string") {
83
+ server.close();
84
+ reject(new FloomError("Could not reserve a local sign-in port."));
85
+ return;
86
+ }
87
+ const port = address.port;
88
+ server.close(() => resolve(port));
89
+ });
90
+ });
91
+ }
92
+ function waitForCallback(port) {
66
93
  return new Promise((resolve, reject) => {
67
94
  const apiUrl = getApiUrl();
68
- const allowedOrigin = new URL(apiUrl).origin;
69
- const state = randomBytes(24).toString("base64url");
70
95
  let settled = false;
71
- let retriedEphemeralPort = false;
72
96
  const server = createServer((req, res) => {
73
97
  // CORS preflight from the browser bridge page.
74
- const origin = req.headers.origin;
75
- const corsOrigin = origin === allowedOrigin ? origin : "null";
98
+ const origin = req.headers.origin ?? "*";
76
99
  if (req.method === "OPTIONS") {
77
100
  res.writeHead(204, {
78
- "access-control-allow-origin": corsOrigin,
101
+ "access-control-allow-origin": origin,
79
102
  "access-control-allow-methods": "POST, OPTIONS",
80
103
  "access-control-allow-headers": "content-type",
81
104
  "access-control-allow-private-network": "true",
@@ -92,24 +115,15 @@ function waitForCallback() {
92
115
  const data = parseCallbackBody(body, req.headers["content-type"]);
93
116
  if (!data.access_token || !data.refresh_token) {
94
117
  res.writeHead(400, {
95
- "access-control-allow-origin": corsOrigin,
118
+ "access-control-allow-origin": origin,
96
119
  "access-control-allow-private-network": "true",
97
120
  "content-type": "text/html; charset=utf-8",
98
121
  });
99
122
  res.end(localCallbackPage("Missing tokens from OAuth response."));
100
123
  return;
101
124
  }
102
- if (data.state !== state) {
103
- res.writeHead(400, {
104
- "access-control-allow-origin": corsOrigin,
105
- "access-control-allow-private-network": "true",
106
- "content-type": "text/html; charset=utf-8",
107
- });
108
- res.end(localCallbackPage("Invalid OAuth state."));
109
- return;
110
- }
111
125
  res.writeHead(200, {
112
- "access-control-allow-origin": corsOrigin,
126
+ "access-control-allow-origin": origin,
113
127
  "access-control-allow-private-network": "true",
114
128
  "content-type": "text/html; charset=utf-8",
115
129
  });
@@ -119,7 +133,7 @@ function waitForCallback() {
119
133
  resolve(data);
120
134
  }
121
135
  catch {
122
- res.writeHead(400, { "content-type": "text/html; charset=utf-8", "access-control-allow-origin": corsOrigin });
136
+ res.writeHead(400, { "content-type": "text/html; charset=utf-8", "access-control-allow-origin": origin });
123
137
  res.end(localCallbackPage("Invalid OAuth response."));
124
138
  }
125
139
  });
@@ -140,28 +154,14 @@ function waitForCallback() {
140
154
  server.close();
141
155
  }
142
156
  server.on("error", (err) => {
143
- const code = err.code;
144
- if (!settled && !retriedEphemeralPort && code === "EADDRINUSE") {
145
- retriedEphemeralPort = true;
146
- server.listen(0, "127.0.0.1");
147
- return;
148
- }
149
157
  if (settled)
150
158
  return;
151
159
  settled = true;
152
160
  clearTimeout(timer);
153
- reject(new FloomError("Local auth server failed.", err.message));
161
+ reject(new FloomError(`Local auth server failed on port ${port}.`, `Is port ${port} already in use? (${err.message})`));
154
162
  });
155
- server.listen(DEFAULT_PORT, "127.0.0.1", () => {
156
- const address = server.address();
157
- if (!address || typeof address === "string") {
158
- settled = true;
159
- cleanup();
160
- reject(new FloomError("Could not reserve a local sign-in port."));
161
- return;
162
- }
163
- const port = address.port;
164
- const target = `${apiUrl}/auth/cli?port=${port}&state=${encodeURIComponent(state)}`;
163
+ server.listen(port, "127.0.0.1", () => {
164
+ const target = `${apiUrl}/auth/cli?port=${port}`;
165
165
  open(target).catch((e) => {
166
166
  const msg = e instanceof Error ? e.message : String(e);
167
167
  process.stdout.write(c.yellow(`Could not auto-open browser (${msg}).\n`) +
@@ -175,7 +175,7 @@ function parseCallbackBody(body, contentType) {
175
175
  if (type.includes("application/x-www-form-urlencoded")) {
176
176
  const params = new URLSearchParams(body);
177
177
  const parsed = {};
178
- for (const key of ["access_token", "refresh_token", "expires_in", "token_type", "state"]) {
178
+ for (const key of ["access_token", "refresh_token", "expires_in", "token_type"]) {
179
179
  const value = params.get(key);
180
180
  if (value)
181
181
  parsed[key] = value;
@@ -185,5 +185,40 @@ function parseCallbackBody(body, contentType) {
185
185
  return JSON.parse(body);
186
186
  }
187
187
  function localCallbackPage(message) {
188
- return `<!doctype html><html><head><meta charset="utf-8"><title>Floom CLI sign-in</title></head><body style="font-family:system-ui,sans-serif;margin:48px"><h1>${message}</h1><p>Return to your terminal to continue.</p></body></html>`;
188
+ const safeMessage = escapeHtml(message);
189
+ return `<!doctype html>
190
+ <html lang="en">
191
+ <head>
192
+ <meta charset="utf-8">
193
+ <meta name="viewport" content="width=device-width, initial-scale=1">
194
+ <title>Floom CLI sign-in</title>
195
+ <style>
196
+ :root { color-scheme: light; --ink: #111827; --muted: #4b5563; --line: #e5e7eb; --accent: #0f766e; --bg: #f8fafc; }
197
+ * { box-sizing: border-box; }
198
+ body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: var(--bg); color: var(--ink); font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
199
+ main { width: min(560px, calc(100vw - 40px)); padding: 40px; background: #fff; border: 1px solid var(--line); border-radius: 16px; box-shadow: 0 24px 80px rgba(15, 23, 42, 0.08); }
200
+ .mark { display: inline-flex; align-items: center; gap: 10px; margin-bottom: 28px; font-weight: 700; letter-spacing: 0.02em; }
201
+ .dot { width: 10px; height: 10px; border-radius: 999px; background: var(--accent); display: inline-block; }
202
+ h1 { margin: 0; font-size: clamp(32px, 7vw, 56px); line-height: 0.95; letter-spacing: 0; }
203
+ p { margin: 20px 0 0; font-size: 18px; line-height: 1.55; color: var(--muted); }
204
+ code { display: inline-block; margin-top: 24px; padding: 10px 12px; border: 1px solid var(--line); border-radius: 8px; background: #f9fafb; color: var(--ink); font-size: 14px; }
205
+ </style>
206
+ </head>
207
+ <body>
208
+ <main>
209
+ <div class="mark"><span class="dot"></span><span>floom</span></div>
210
+ <h1>${safeMessage}</h1>
211
+ <p>Return to your terminal to continue. Your Floom CLI now has the token it needs.</p>
212
+ <code>floom doctor</code>
213
+ </main>
214
+ </body>
215
+ </html>`;
216
+ }
217
+ function escapeHtml(value) {
218
+ return value
219
+ .replace(/&/g, "&amp;")
220
+ .replace(/</g, "&lt;")
221
+ .replace(/>/g, "&gt;")
222
+ .replace(/"/g, "&quot;")
223
+ .replace(/'/g, "&#39;");
189
224
  }
package/dist/mcp.js CHANGED
@@ -1,19 +1,16 @@
1
1
  import { c } from "./ui.js";
2
2
  export function printMcpSetup() {
3
3
  const snippet = `## Floom
4
- - Before recreating behavior from scratch, search the local Floom skills folder for matching skills.
4
+ - Use Floom skills from the local Floom skills folder when they match the task.
5
5
  - To install a shared skill, run \`npx -y @floomhq/floom add <slug-or-url> --target claude\` or \`npx -y @floomhq/floom add <slug-or-url> --target codex\`.
6
- - If MCP tools are available, use \`floom_search_skills\`, \`floom_install_skill\`, \`floom_list_libraries\`, and \`floom_subscribe_library\`.
7
- - If MCP tools are not available, run \`npx -y @floomhq/floom search <query>\`.
8
- - Shared-link installs work with \`add\` and no account for public or unlisted links.
9
- - MCP sync requires \`npx -y @floomhq/floom login\` and keeps saved, published, and followed library skills updated locally.`;
6
+ - To find reusable behavior, run \`npx -y @floomhq/floom search <query>\`.
7
+ - MCP sync is optional preview behavior; use it only while the Floom MCP server is configured and running.`;
10
8
  process.stdout.write(`\n${c.bold("Floom MCP setup")}\n\n`);
11
9
  process.stdout.write(`${c.dim("Pick your tool:")}\n\n`);
12
- process.stdout.write(`${c.dim("Shared links work with `npx -y @floomhq/floom add` and no account. MCP is for account-backed saved, published, and followed library sync.")}\n\n`);
13
10
  process.stdout.write(` ${c.bold("Claude Code")}\n`);
14
11
  process.stdout.write(` ${c.cyan("claude mcp add floom -- npx -y @floomhq/floom-mcp-sync")}\n\n`);
15
12
  process.stdout.write(` ${c.bold("Codex CLI")}\n`);
16
- process.stdout.write(` ${c.cyan("codex mcp add floom -- env FLOOM_TARGET=codex npx -y @floomhq/floom-mcp-sync")}\n\n`);
13
+ process.stdout.write(` ${c.cyan("codex mcp add floom -- npx -y @floomhq/floom-mcp-sync")}\n\n`);
17
14
  process.stdout.write(`${c.dim("Full guide:")} ${c.cyan("https://floom.dev/docs/mcp")}\n\n`);
18
15
  process.stdout.write(`${c.dim("Recommended agent instruction snippet:")}\n\n`);
19
16
  process.stdout.write(`${snippet}\n\n`);