@floomhq/floom 1.0.13 → 1.0.16

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,74 +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`);
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
+ }
229
303
  process.stdout.write(` ${c.bold("Next")}\n`);
230
304
  if (opts.setup) {
231
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,7 +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("Sync:")} npx -y @floomhq/floom library subscribe ${result.slug}\n\n`);
48
+ process.stdout.write(` ${c.dim("Sync:")} floom library subscribe ${result.slug}\n\n`);
50
49
  }
51
50
  export async function libraryAddSkill(opts) {
52
51
  const cfg = await readConfig();
@@ -79,7 +78,7 @@ export async function librarySubscribe(slug) {
79
78
  const apiUrl = resolveApiUrl(cfg);
80
79
  await postJson(`${apiUrl}/api/v1/me/subscriptions`, "subscribe to library", cfg.accessToken, { library_slug: slug });
81
80
  process.stdout.write(`\n${symbols.ok} Subscribed to ${c.cyan(slug)}\n`);
82
- process.stdout.write(` ${c.dim(`Skills will sync under ${resolveSkillsDir("claude")}/${slug}/ by default. Use \`sync --target codex\` for Codex.`)}\n\n`);
81
+ process.stdout.write(` ${c.dim("Run `floom sync --target claude` or `floom sync --target codex` to pull this library locally.")}\n\n`);
83
82
  }
84
83
  export async function libraryUnsubscribe(slug) {
85
84
  const cfg = await readConfig();
package/dist/list.js CHANGED
@@ -54,7 +54,7 @@ export async function list(opts) {
54
54
  }
55
55
  process.stdout.write(`\n${symbols.dot} ${c.bold("Published")} ${c.dim(`(${published.length})`)}\n\n`);
56
56
  if (published.length === 0) {
57
- process.stdout.write(` ${c.dim("Nothing published yet. Try `npx -y @floomhq/floom publish skill.md`.")}\n`);
57
+ process.stdout.write(` ${c.dim("Nothing published yet. Try `npx -y @floomhq/floom publish my-skill`.")}\n`);
58
58
  }
59
59
  else {
60
60
  for (const s of published)
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
  }
@@ -57,20 +58,47 @@ export async function login() {
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
60
  }
60
- 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) {
61
93
  return new Promise((resolve, reject) => {
62
94
  const apiUrl = getApiUrl();
63
- const allowedOrigin = new URL(apiUrl).origin;
64
- const state = randomBytes(24).toString("base64url");
65
95
  let settled = false;
66
- let retriedEphemeralPort = false;
67
96
  const server = createServer((req, res) => {
68
97
  // CORS preflight from the browser bridge page.
69
- const origin = req.headers.origin;
70
- const corsOrigin = origin === allowedOrigin ? origin : "null";
98
+ const origin = req.headers.origin ?? "*";
71
99
  if (req.method === "OPTIONS") {
72
100
  res.writeHead(204, {
73
- "access-control-allow-origin": corsOrigin,
101
+ "access-control-allow-origin": origin,
74
102
  "access-control-allow-methods": "POST, OPTIONS",
75
103
  "access-control-allow-headers": "content-type",
76
104
  "access-control-allow-private-network": "true",
@@ -87,24 +115,15 @@ function waitForCallback() {
87
115
  const data = parseCallbackBody(body, req.headers["content-type"]);
88
116
  if (!data.access_token || !data.refresh_token) {
89
117
  res.writeHead(400, {
90
- "access-control-allow-origin": corsOrigin,
118
+ "access-control-allow-origin": origin,
91
119
  "access-control-allow-private-network": "true",
92
120
  "content-type": "text/html; charset=utf-8",
93
121
  });
94
122
  res.end(localCallbackPage("Missing tokens from OAuth response."));
95
123
  return;
96
124
  }
97
- if (data.state !== state) {
98
- res.writeHead(400, {
99
- "access-control-allow-origin": corsOrigin,
100
- "access-control-allow-private-network": "true",
101
- "content-type": "text/html; charset=utf-8",
102
- });
103
- res.end(localCallbackPage("Invalid OAuth state."));
104
- return;
105
- }
106
125
  res.writeHead(200, {
107
- "access-control-allow-origin": corsOrigin,
126
+ "access-control-allow-origin": origin,
108
127
  "access-control-allow-private-network": "true",
109
128
  "content-type": "text/html; charset=utf-8",
110
129
  });
@@ -114,7 +133,7 @@ function waitForCallback() {
114
133
  resolve(data);
115
134
  }
116
135
  catch {
117
- 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 });
118
137
  res.end(localCallbackPage("Invalid OAuth response."));
119
138
  }
120
139
  });
@@ -135,28 +154,14 @@ function waitForCallback() {
135
154
  server.close();
136
155
  }
137
156
  server.on("error", (err) => {
138
- const code = err.code;
139
- if (!settled && !retriedEphemeralPort && code === "EADDRINUSE") {
140
- retriedEphemeralPort = true;
141
- server.listen(0, "127.0.0.1");
142
- return;
143
- }
144
157
  if (settled)
145
158
  return;
146
159
  settled = true;
147
160
  clearTimeout(timer);
148
- 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})`));
149
162
  });
150
- server.listen(DEFAULT_PORT, "127.0.0.1", () => {
151
- const address = server.address();
152
- if (!address || typeof address === "string") {
153
- settled = true;
154
- cleanup();
155
- reject(new FloomError("Could not reserve a local sign-in port."));
156
- return;
157
- }
158
- const port = address.port;
159
- 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}`;
160
165
  open(target).catch((e) => {
161
166
  const msg = e instanceof Error ? e.message : String(e);
162
167
  process.stdout.write(c.yellow(`Could not auto-open browser (${msg}).\n`) +
@@ -170,7 +175,7 @@ function parseCallbackBody(body, contentType) {
170
175
  if (type.includes("application/x-www-form-urlencoded")) {
171
176
  const params = new URLSearchParams(body);
172
177
  const parsed = {};
173
- 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"]) {
174
179
  const value = params.get(key);
175
180
  if (value)
176
181
  parsed[key] = value;
@@ -180,5 +185,40 @@ function parseCallbackBody(body, contentType) {
180
185
  return JSON.parse(body);
181
186
  }
182
187
  function localCallbackPage(message) {
183
- 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;");
184
224
  }
package/dist/mcp.js CHANGED
@@ -1,17 +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>\`.
6
+ - To find reusable behavior, run \`npx -y @floomhq/floom search <query>\`.
8
7
  - MCP sync is optional preview behavior; use it only while the Floom MCP server is configured and running.`;
9
8
  process.stdout.write(`\n${c.bold("Floom MCP setup")}\n\n`);
10
9
  process.stdout.write(`${c.dim("Pick your tool:")}\n\n`);
11
10
  process.stdout.write(` ${c.bold("Claude Code")}\n`);
12
11
  process.stdout.write(` ${c.cyan("claude mcp add floom -- npx -y @floomhq/floom-mcp-sync")}\n\n`);
13
12
  process.stdout.write(` ${c.bold("Codex CLI")}\n`);
14
- 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`);
15
14
  process.stdout.write(`${c.dim("Full guide:")} ${c.cyan("https://floom.dev/docs/mcp")}\n\n`);
16
15
  process.stdout.write(`${c.dim("Recommended agent instruction snippet:")}\n\n`);
17
16
  process.stdout.write(`${snippet}\n\n`);