@floomhq/floom 1.0.25 → 1.0.27

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/package.js CHANGED
@@ -6,40 +6,117 @@ import { promisify } from "node:util";
6
6
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
7
7
  import { FloomError } from "./errors.js";
8
8
  const ALLOWED_PACKAGE_DIRS = new Set([
9
+ ".github",
9
10
  "agents",
10
11
  "assets",
12
+ "bin",
11
13
  "canvas-fonts",
12
14
  "checks",
15
+ "claude",
16
+ "codex",
17
+ "contrib",
13
18
  "core",
19
+ "cursor",
20
+ "design",
21
+ "docs",
14
22
  "evidence",
15
23
  "examples",
24
+ "extension",
16
25
  "helpers",
26
+ "hosts",
27
+ "kimi",
28
+ "lib",
29
+ "migrations",
30
+ "model-overlays",
31
+ "openclaw",
32
+ "opencode",
17
33
  "reference",
18
34
  "references",
19
- "schemas",
35
+ "remotion-starter",
20
36
  "scripts",
21
- "spreadsheets",
37
+ "sdk",
38
+ "schemas",
39
+ "src",
40
+ "specialists",
41
+ "supabase",
22
42
  "templates",
43
+ "test",
23
44
  "tests",
24
45
  "themes",
46
+ "vendor",
25
47
  ]);
26
48
  const ALLOWED_PACKAGE_ROOT_FILES = new Set(["SKILL.md", ".gitignore"]);
27
- const IGNORED_PACKAGE_ROOT_ENTRIES = new Set([".DS_Store", ".git", ".pytest_cache"]);
28
- const ALLOWED_PACKAGE_ROOT_EXTENSIONS = new Set([
49
+ const ALLOWED_PACKAGE_ROOT_SUPPORT_FILES = new Set([
29
50
  ".env.example",
30
- ".js",
31
- ".json",
32
- ".md",
33
- ".py",
34
- ".sh",
35
- ".toml",
36
- ".ts",
37
- ".txt",
38
- ".yaml",
39
- ".yml",
51
+ "ACKNOWLEDGEMENTS.md",
52
+ "CHANGELOG.md",
53
+ "LICENSE",
54
+ "LICENSE.md",
55
+ "LICENSE.txt",
56
+ "README.md",
57
+ "AGENTS.md",
58
+ "ARCHITECTURE.md",
59
+ "BROWSER.md",
60
+ "CLAUDE.md",
61
+ "CONTRIBUTING.md",
62
+ "DESIGN.md",
63
+ "ETHOS.md",
64
+ "PLAN-snapshot-dropdown-interactive.md",
65
+ "REMOTION_PROTOCOL.md",
66
+ "SKILL.md.tmpl",
67
+ "TODOS.md",
68
+ "TODOS-format.md",
69
+ "USING_GBRAIN_WITH_GSTACK.md",
70
+ "VERSION",
71
+ ".gitlab-ci.yml",
72
+ "actionlint.yaml",
73
+ "bun.lock",
74
+ "checklist.md",
75
+ "conductor.json",
76
+ "design-checklist.md",
77
+ "dx-hall-of-fame.md",
78
+ "editing.md",
79
+ "forms.md",
80
+ "instructions.md",
81
+ "nanobanana.py",
82
+ "package.json",
83
+ "plan.md",
84
+ "pptxgenjs.md",
85
+ "reference.md",
86
+ "requirements.txt",
87
+ "greptile-triage.md",
88
+ "setup",
89
+ "skill.md",
90
+ "slop-scan.config.json",
91
+ "style_guidelines.md",
92
+ "theme-showcase.pdf",
93
+ ]);
94
+ const GENERATED_PACKAGE_DIRS = new Set([
95
+ ".agents",
96
+ ".cache",
97
+ ".cursor",
98
+ ".factory",
99
+ ".gbrain",
100
+ ".git",
101
+ ".hermes",
102
+ ".kiro",
103
+ ".next",
104
+ ".openclaw",
105
+ ".opencode",
106
+ ".pytest_cache",
107
+ ".slate",
108
+ "__pycache__",
109
+ "build",
110
+ "coverage",
111
+ "dist",
112
+ "fixtures",
113
+ "node_modules",
114
+ "out",
40
115
  ]);
41
- const PACKAGE_FILE_LIMIT = 100;
42
- const PACKAGE_TOTAL_BYTES_LIMIT = 1_000_000;
116
+ const GENERATED_PACKAGE_FILES = new Set([".DS_Store"]);
117
+ const GENERATED_PACKAGE_FILE_SUFFIXES = [".icns", ".log", ".mov", ".mp3", ".mp4", ".pyc", ".pyo", ".wav", ".webm"];
118
+ const PACKAGE_FILE_LIMIT = 1000;
119
+ const PACKAGE_TOTAL_BYTES_LIMIT = 8_000_000;
43
120
  const PACKAGE_FILE_BYTES_LIMIT = 500_000;
44
121
  const PACKAGE_SEGMENT_RE = /^[A-Za-z0-9._-]{1,128}$/;
45
122
  const BASE64_RE = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
@@ -126,40 +203,41 @@ async function collectPackageFiles(root) {
126
203
  const isIgnored = gitIgnoreChecker(root);
127
204
  const rootEntries = await readdir(root, { withFileTypes: true });
128
205
  for (const entry of rootEntries) {
129
- if (await isIgnored(entry.name))
206
+ if (ALLOWED_PACKAGE_ROOT_FILES.has(entry.name) || ALLOWED_PACKAGE_DIRS.has(entry.name))
130
207
  continue;
131
- if (IGNORED_PACKAGE_ROOT_ENTRIES.has(entry.name))
208
+ if (ALLOWED_PACKAGE_ROOT_SUPPORT_FILES.has(entry.name)) {
209
+ if (entry.isSymbolicLink())
210
+ continue;
211
+ if (!entry.isFile())
212
+ throw new FloomError(`Package path is not a regular file: ${entry.name}`);
132
213
  continue;
133
- if (ALLOWED_PACKAGE_ROOT_FILES.has(entry.name))
214
+ }
215
+ if (isGeneratedPackageEntry(entry.name, entry.isDirectory()))
134
216
  continue;
135
- if (entry.isDirectory() && ALLOWED_PACKAGE_DIRS.has(entry.name))
217
+ if (await isIgnored(entry.name))
136
218
  continue;
137
- if (entry.isFile() && isAllowedRootPackageFile(entry.name)) {
138
- const rel = entry.name;
139
- validatePackageRelativePath(rel);
140
- const fullPath = join(root, entry.name);
141
- const stat = await lstat(fullPath);
142
- if (files.length >= PACKAGE_FILE_LIMIT) {
143
- throw new FloomError("Skill package has too many files.", "A skill package is capped at 100 supporting files.");
144
- }
145
- if (stat.size > PACKAGE_FILE_BYTES_LIMIT) {
146
- throw new FloomError(`File too large: ${rel}`, "Each package file is capped at 500KB.");
147
- }
148
- totalBytes += stat.size;
149
- ensurePackageSize(totalBytes);
150
- const bytes = await readFile(fullPath);
151
- files.push({
152
- path: rel,
153
- content_base64: bytes.toString("base64"),
154
- encoding: "base64",
155
- size_bytes: bytes.length,
156
- sha256: sha256Bytes(bytes),
157
- });
219
+ if (entry.isDirectory() && await isNestedSkillDir(join(root, entry.name)))
158
220
  continue;
159
- }
160
- const hint = `Move supporting files under one of: ${Array.from(ALLOWED_PACKAGE_DIRS).sort().join(", ")}.`;
221
+ if (entry.isSymbolicLink())
222
+ continue;
223
+ const hint = "Move supporting files under references/, examples/, scripts/, or assets/.";
161
224
  throw new FloomError(`Unsupported root package entry: ${entry.name}`, hint);
162
225
  }
226
+ for (const entry of rootEntries) {
227
+ if (!ALLOWED_PACKAGE_ROOT_SUPPORT_FILES.has(entry.name))
228
+ continue;
229
+ if (entry.isSymbolicLink())
230
+ continue;
231
+ const fullPath = join(root, entry.name);
232
+ if (await isIgnored(entry.name))
233
+ continue;
234
+ await collectFile(fullPath, entry.name, files, (size) => {
235
+ totalBytes += size;
236
+ if (totalBytes > PACKAGE_TOTAL_BYTES_LIMIT) {
237
+ throw new FloomError("Skill package is too large.", "A skill package is capped at 8MB total.");
238
+ }
239
+ });
240
+ }
163
241
  for (const dirName of ALLOWED_PACKAGE_DIRS) {
164
242
  const dirPath = join(root, dirName);
165
243
  let stat;
@@ -174,30 +252,23 @@ async function collectPackageFiles(root) {
174
252
  if (await isIgnored(dirName))
175
253
  continue;
176
254
  if (stat.isSymbolicLink())
177
- throw new FloomError(`Package directory is a symbolic link: ${dirName}`);
255
+ continue;
178
256
  if (!stat.isDirectory())
179
257
  throw new FloomError(`Package entry must be a directory: ${dirName}`);
180
258
  await collectDir(root, dirPath, files, isIgnored, (size) => {
181
259
  totalBytes += size;
182
- ensurePackageSize(totalBytes);
260
+ if (totalBytes > PACKAGE_TOTAL_BYTES_LIMIT) {
261
+ throw new FloomError("Skill package is too large.", "A skill package is capped at 8MB total.");
262
+ }
183
263
  });
184
264
  }
185
265
  return files.sort((a, b) => a.path.localeCompare(b.path));
186
266
  }
187
- function isAllowedRootPackageFile(name) {
188
- if (name.startsWith(".") && name !== ".env.example")
189
- return false;
190
- const lower = name.toLowerCase();
191
- return Array.from(ALLOWED_PACKAGE_ROOT_EXTENSIONS).some((ext) => lower.endsWith(ext));
192
- }
193
- function ensurePackageSize(totalBytes) {
194
- if (totalBytes > PACKAGE_TOTAL_BYTES_LIMIT) {
195
- throw new FloomError("Skill package is too large.", "A skill package is capped at 1MB total.");
196
- }
197
- }
198
267
  async function collectDir(root, dir, files, isIgnored, addBytes) {
199
268
  const entries = await readdir(dir, { withFileTypes: true });
200
269
  for (const entry of entries) {
270
+ if (isGeneratedPackageEntry(entry.name, entry.isDirectory()))
271
+ continue;
201
272
  if (!PACKAGE_SEGMENT_RE.test(entry.name)) {
202
273
  throw new FloomError(`Invalid package path segment: ${entry.name}`);
203
274
  }
@@ -206,35 +277,58 @@ async function collectDir(root, dir, files, isIgnored, addBytes) {
206
277
  if (await isIgnored(rel))
207
278
  continue;
208
279
  if (entry.isSymbolicLink())
209
- throw new FloomError(`Package path is a symbolic link: ${rel}`);
280
+ continue;
210
281
  if (entry.isDirectory()) {
211
282
  await collectDir(root, fullPath, files, isIgnored, addBytes);
212
283
  continue;
213
284
  }
214
285
  if (!entry.isFile())
215
286
  throw new FloomError(`Package path is not a regular file: ${rel}`);
216
- if (files.length >= PACKAGE_FILE_LIMIT) {
217
- throw new FloomError("Skill package has too many files.", "A skill package is capped at 100 supporting files.");
218
- }
219
- const stat = await lstat(fullPath);
220
- if (stat.isSymbolicLink())
221
- throw new FloomError(`Package path is a symbolic link: ${rel}`);
222
- if (!stat.isFile())
223
- throw new FloomError(`Package path is not a regular file: ${rel}`);
224
- if (stat.size > PACKAGE_FILE_BYTES_LIMIT) {
225
- throw new FloomError(`File too large: ${rel}`, "Each package file is capped at 500KB.");
226
- }
227
- addBytes(stat.size);
228
- const bytes = await readFile(fullPath);
229
- files.push({
230
- path: rel,
231
- content_base64: bytes.toString("base64"),
232
- encoding: "base64",
233
- size_bytes: bytes.length,
234
- sha256: sha256Bytes(bytes),
235
- });
287
+ await collectFile(fullPath, rel, files, addBytes);
236
288
  }
237
289
  }
290
+ async function isNestedSkillDir(path) {
291
+ try {
292
+ const stat = await lstat(join(path, "SKILL.md"));
293
+ return stat.isFile();
294
+ }
295
+ catch (err) {
296
+ if (err.code === "ENOENT")
297
+ return false;
298
+ throw err;
299
+ }
300
+ }
301
+ async function collectFile(fullPath, rel, files, addBytes) {
302
+ const normalized = rel.split(sep).join("/");
303
+ validatePackageRelativePath(normalized);
304
+ if (files.length >= PACKAGE_FILE_LIMIT) {
305
+ throw new FloomError("Skill package has too many files.", "A skill package is capped at 100 supporting files.");
306
+ }
307
+ const stat = await lstat(fullPath);
308
+ if (stat.isSymbolicLink())
309
+ throw new FloomError(`Package path is a symbolic link: ${normalized}`);
310
+ if (!stat.isFile())
311
+ throw new FloomError(`Package path is not a regular file: ${normalized}`);
312
+ if (stat.size > PACKAGE_FILE_BYTES_LIMIT) {
313
+ throw new FloomError(`File too large: ${normalized}`, "Each package file is capped at 500KB.");
314
+ }
315
+ addBytes(stat.size);
316
+ const bytes = await readFile(fullPath);
317
+ files.push({
318
+ path: normalized,
319
+ content_base64: bytes.toString("base64"),
320
+ encoding: "base64",
321
+ size_bytes: bytes.length,
322
+ sha256: sha256Bytes(bytes),
323
+ });
324
+ }
325
+ function isGeneratedPackageEntry(name, isDirectory) {
326
+ if (isDirectory && GENERATED_PACKAGE_DIRS.has(name))
327
+ return true;
328
+ if (GENERATED_PACKAGE_FILES.has(name))
329
+ return true;
330
+ return GENERATED_PACKAGE_FILE_SUFFIXES.some((suffix) => name.endsWith(suffix));
331
+ }
238
332
  function gitIgnoreChecker(root) {
239
333
  const cache = new Map();
240
334
  let localRules = null;
@@ -304,11 +398,13 @@ export function validatePackageRelativePath(path) {
304
398
  throw new FloomError(`Invalid package file path: ${path}`);
305
399
  }
306
400
  const segments = path.split("/");
307
- if (segments.length === 1 && !isAllowedRootPackageFile(segments[0] ?? "")) {
308
- throw new FloomError(`Invalid package file path: ${path}`, "Root package files must be safe text files such as .md, .txt, .json, .yaml, .toml, .py, .sh, .js, or .ts.");
401
+ if (segments.length === 1) {
402
+ if (ALLOWED_PACKAGE_ROOT_SUPPORT_FILES.has(path) && PACKAGE_SEGMENT_RE.test(path))
403
+ return;
404
+ throw new FloomError(`Invalid package file path: ${path}`, "Package root files must be approved support files.");
309
405
  }
310
- if (segments.length > 1 && !ALLOWED_PACKAGE_DIRS.has(segments[0] ?? "")) {
311
- throw new FloomError(`Invalid package file path: ${path}`, `Package files must be root text files or live under one of: ${Array.from(ALLOWED_PACKAGE_DIRS).sort().join(", ")}.`);
406
+ if (!ALLOWED_PACKAGE_DIRS.has(segments[0] ?? "")) {
407
+ throw new FloomError(`Invalid package file path: ${path}`, "Package files must be under an approved support directory.");
312
408
  }
313
409
  if (segments.some((segment) => segment === "." || segment === ".." || !PACKAGE_SEGMENT_RE.test(segment))) {
314
410
  throw new FloomError(`Invalid package file path: ${path}`);
package/dist/publish.js CHANGED
@@ -107,34 +107,7 @@ function parseVersion(value, source) {
107
107
  return value;
108
108
  throw new FloomError(`Invalid ${source}: ${value}`, "Use 1-64 characters: letters, numbers, dots, underscores, plus, or hyphen.");
109
109
  }
110
- function slugFromInput(input) {
111
- const trimmed = input.trim();
112
- try {
113
- const url = new URL(trimmed);
114
- const last = url.pathname.split("/").filter(Boolean).at(-1) ?? "";
115
- return last.replace(/\.(md|json)$/i, "");
116
- }
117
- catch {
118
- return trimmed.replace(/\.(md|json)$/i, "");
119
- }
120
- }
121
- function inferSlugFromPath(path) {
122
- return path
123
- .replace(/[\\/]+$/, "")
124
- .split(/[\\/]+/)
125
- .filter(Boolean)
126
- .at(-1)
127
- ?.replace(/\.md$/i, "") ?? "";
128
- }
129
- function resolveUpdateSlug(opts, meta) {
130
- const raw = opts.updateSlug ?? meta.slug ?? inferSlugFromPath(opts.file);
131
- const slug = slugFromInput(raw);
132
- if (!SLUG_RE.test(slug)) {
133
- throw new FloomError("Missing or invalid update slug.", "Use `npx -y @floomhq/floom publish <path> --update <slug-or-url>`, or add `slug:` to SKILL.md frontmatter.");
134
- }
135
- return slug;
136
- }
137
- export async function publish(opts) {
110
+ export async function publishSkillPath(opts) {
138
111
  const skillPackage = await readSkillPackage(opts.file);
139
112
  const securityFailures = [
140
113
  { path: "SKILL.md", body: skillPackage.skillBody },
@@ -150,7 +123,9 @@ export async function publish(opts) {
150
123
  throw new FloomError("Security scan failed. Publish stopped.", `${securityFailures.join("\n")}\nRemove secrets, prompt-injection text, or data-exfiltration instructions before publishing.`);
151
124
  }
152
125
  const fileCount = 1 + skillPackage.packageFiles.length;
153
- process.stdout.write(`\n${symbols.ok} Pre-publish security scan passed (${fileCount} file${fileCount === 1 ? "" : "s"}, 3 checks).\n`);
126
+ if (!opts.quiet) {
127
+ process.stdout.write(`\n${symbols.ok} Pre-publish security scan passed (${fileCount} file${fileCount === 1 ? "" : "s"}, 3 checks).\n`);
128
+ }
154
129
  const { meta, error: fmError } = parseFrontmatter(skillPackage.skillBody);
155
130
  if (fmError) {
156
131
  throw new FloomError(`Couldn't parse frontmatter — check your YAML.`, `Line ${fmError.line}: ${fmError.message}`);
@@ -160,20 +135,23 @@ export async function publish(opts) {
160
135
  const assetType = opts.assetType ?? parseAssetType(meta.asset_type ?? meta.type, "frontmatter type") ?? "skill";
161
136
  const installsAs = opts.installsAs ?? parseInstallsAs(meta.installs_as, "frontmatter installs_as") ?? null;
162
137
  const version = opts.version ?? parseVersion(meta.version, "frontmatter version") ?? null;
138
+ const updateSlug = opts.update ? (opts.updateSlug ?? meta.slug) : undefined;
139
+ if (opts.update && !updateSlug) {
140
+ throw new FloomError("Missing update slug.", "Use `--update <slug>` or add `slug:` to the skill frontmatter.");
141
+ }
142
+ if (updateSlug !== undefined && !SLUG_RE.test(updateSlug)) {
143
+ throw new FloomError(`Invalid update slug: ${updateSlug}`, "Use the Floom skill slug from its share URL.");
144
+ }
163
145
  const cfg = await readConfig();
164
146
  if (!cfg) {
165
147
  throw new FloomError("Not signed in.", "Run `npx -y @floomhq/floom login` first.");
166
148
  }
167
- const spinner = ora({
168
- text: c.dim(`${opts.update ? "Updating" : "Publishing"} ${meta.title ? c.bold(meta.title) : opts.file}...`),
169
- color: "yellow",
170
- }).start();
171
149
  const apiUrl = resolveApiUrl(cfg);
172
150
  const body = {
173
151
  title: meta.title ?? null,
174
152
  description: meta.description ?? null,
175
153
  body_md: skillPackage.skillBody,
176
- visibility: opts.visibility,
154
+ ...((opts.visibility ?? (!updateSlug ? "unlisted" : undefined)) ? { visibility: opts.visibility ?? "unlisted" } : {}),
177
155
  asset_type: assetType,
178
156
  source: "markdown",
179
157
  installs_as: installsAs,
@@ -181,12 +159,11 @@ export async function publish(opts) {
181
159
  original_filename: skillPackage.originalFilename,
182
160
  published_via: "cli",
183
161
  ...(opts.sharedWithEmails ? { shared_with_emails: opts.sharedWithEmails } : {}),
184
- ...(opts.update || skillPackage.packageFiles.length > 0 ? { package_files: skillPackage.packageFiles } : {}),
162
+ ...(skillPackage.packageFiles.length > 0 ? { package_files: skillPackage.packageFiles } : {}),
185
163
  };
186
164
  let res;
187
165
  try {
188
- const updateSlug = opts.update ? resolveUpdateSlug(opts, meta) : null;
189
- res = await floomFetch(updateSlug ? `${apiUrl}/api/v1/skills/${encodeURIComponent(updateSlug)}` : `${apiUrl}/api/skills`, updateSlug ? "update your skill" : "publish your skill", {
166
+ res = await floomFetch(updateSlug ? `${apiUrl}/api/v1/skills/${updateSlug}` : `${apiUrl}/api/skills`, updateSlug ? "update your skill" : "publish your skill", {
190
167
  method: updateSlug ? "PATCH" : "POST",
191
168
  token: cfg.accessToken,
192
169
  checkOk: false,
@@ -194,16 +171,13 @@ export async function publish(opts) {
194
171
  });
195
172
  }
196
173
  catch (err) {
197
- spinner.stop();
198
174
  throw err;
199
175
  }
200
176
  if (res.status === 409) {
201
- spinner.stop();
202
- throw new FloomError("Skill already published.", "Use `--update <slug-or-url>` to publish a new version of an existing skill.");
177
+ throw new FloomError("Skill already published.", "Use `--update <slug>` to publish a new version of an existing skill.");
203
178
  }
204
179
  if (!res.ok) {
205
- spinner.stop();
206
- throw friendlyHttp(res.status, opts.update ? "update your skill" : "publish your skill");
180
+ throw friendlyHttp(res.status, updateSlug ? "update your skill" : "publish your skill");
207
181
  }
208
182
  const data = (await res.json());
209
183
  // Build a humans-friendly URL: strip the trailing `.md` if the API returned one.
@@ -211,17 +185,33 @@ export async function publish(opts) {
211
185
  const humanUrl = data.url
212
186
  ? data.url.replace(/\.md$/, "")
213
187
  : `${webBase}/s/${data.slug}`;
214
- spinner.stop();
215
- const versionTag = version ? c.dim(` (${formatVersionLabel(version)})`) : "";
216
188
  const titleLabel = data.title ? `"${data.title}"` : opts.file;
217
- process.stdout.write(`\n${symbols.ok} ${opts.update ? "Updated" : "Published"} ${c.bold(titleLabel)}${versionTag}\n\n`);
218
- process.stdout.write(` ${c.cyan(humanUrl)}\n\n`);
219
- if (data.visibility === "shared" && data.shared_with_emails?.length) {
220
- process.stdout.write(` ${c.dim(`Shared with ${data.shared_with_emails.join(", ")}`)}\n\n`);
189
+ return { data, humanUrl, version, titleLabel };
190
+ }
191
+ export async function publish(opts) {
192
+ const spinner = ora({
193
+ text: c.dim(`${opts.update ? "Updating" : "Publishing"} ${opts.file}...`),
194
+ color: "yellow",
195
+ }).start();
196
+ let result;
197
+ try {
198
+ result = await publishSkillPath(opts);
199
+ }
200
+ catch (err) {
201
+ spinner.stop();
202
+ throw err;
203
+ }
204
+ spinner.stop();
205
+ const versionTag = result.version ? c.dim(` (${formatVersionLabel(result.version)})`) : "";
206
+ const verb = opts.update ? "Updated" : "Published";
207
+ process.stdout.write(`\n${symbols.ok} ${verb} ${c.bold(result.titleLabel)}${versionTag}\n\n`);
208
+ process.stdout.write(` ${c.cyan(result.humanUrl)}\n\n`);
209
+ if (result.data.visibility === "shared" && result.data.shared_with_emails?.length) {
210
+ process.stdout.write(` ${c.dim(`Shared with ${result.data.shared_with_emails.join(", ")}`)}\n\n`);
221
211
  }
222
212
  let copied = false;
223
213
  try {
224
- await clipboard.write(humanUrl);
214
+ await clipboard.write(result.humanUrl);
225
215
  copied = true;
226
216
  }
227
217
  catch {
@@ -234,9 +224,9 @@ export async function publish(opts) {
234
224
  process.stdout.write(` ${c.dim("Share it anywhere.")}\n\n`);
235
225
  }
236
226
  process.stdout.write(` ${c.bold("Next")}\n`);
237
- process.stdout.write(` ${c.dim("1.")} Test locally: ${c.cyan(`npx -y @floomhq/floom add ${humanUrl} --setup`)}\n`);
227
+ process.stdout.write(` ${c.dim("1.")} Test locally: ${c.cyan(`npx -y @floomhq/floom add ${result.humanUrl} --setup`)}\n`);
238
228
  process.stdout.write(` ${c.dim("2.")} Send the link.\n`);
239
- process.stdout.write(` ${c.dim("3.")} Receiver runs ${c.cyan(`npx -y @floomhq/floom add ${humanUrl} --setup`)}\n`);
229
+ process.stdout.write(` ${c.dim("3.")} Receiver runs ${c.cyan(`npx -y @floomhq/floom add ${result.humanUrl} --setup`)}\n`);
240
230
  process.stdout.write(` ${c.dim("4.")} Agent reads the installed Markdown from the local skills folder.\n\n`);
241
231
  }
242
232
  function validateTextMeta(value, label, maxLength) {