@floomhq/skills 0.2.13 → 0.2.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/index.js CHANGED
@@ -111,13 +111,13 @@ var require_bcrypt = __commonJS({
111
111
  throw Error("Illegal callback: " + typeof callback);
112
112
  _async(callback);
113
113
  } else
114
- return new Promise(function(resolve3, reject) {
114
+ return new Promise(function(resolve5, reject) {
115
115
  _async(function(err, res) {
116
116
  if (err) {
117
117
  reject(err);
118
118
  return;
119
119
  }
120
- resolve3(res);
120
+ resolve5(res);
121
121
  });
122
122
  });
123
123
  };
@@ -146,13 +146,13 @@ var require_bcrypt = __commonJS({
146
146
  throw Error("Illegal callback: " + typeof callback);
147
147
  _async(callback);
148
148
  } else
149
- return new Promise(function(resolve3, reject) {
149
+ return new Promise(function(resolve5, reject) {
150
150
  _async(function(err, res) {
151
151
  if (err) {
152
152
  reject(err);
153
153
  return;
154
154
  }
155
- resolve3(res);
155
+ resolve5(res);
156
156
  });
157
157
  });
158
158
  };
@@ -197,13 +197,13 @@ var require_bcrypt = __commonJS({
197
197
  throw Error("Illegal callback: " + typeof callback);
198
198
  _async(callback);
199
199
  } else
200
- return new Promise(function(resolve3, reject) {
200
+ return new Promise(function(resolve5, reject) {
201
201
  _async(function(err, res) {
202
202
  if (err) {
203
203
  reject(err);
204
204
  return;
205
205
  }
206
- resolve3(res);
206
+ resolve5(res);
207
207
  });
208
208
  });
209
209
  };
@@ -1908,9 +1908,9 @@ function parseManifest(raw) {
1908
1908
  }
1909
1909
  async function readManifest(skillDir) {
1910
1910
  const { readFile: readFile11 } = await import("node:fs/promises");
1911
- const { join: join15 } = await import("node:path");
1911
+ const { join: join16 } = await import("node:path");
1912
1912
  try {
1913
- const raw = await readFile11(join15(skillDir, "skill.json"), "utf8");
1913
+ const raw = await readFile11(join16(skillDir, "skill.json"), "utf8");
1914
1914
  let parsed;
1915
1915
  try {
1916
1916
  parsed = JSON.parse(raw);
@@ -2012,8 +2012,9 @@ import { promisify } from "node:util";
2012
2012
  var scrypt = promisify(scryptCb);
2013
2013
 
2014
2014
  // ../shared/src/install-targets.ts
2015
- import { homedir } from "node:os";
2016
- import { join } from "node:path";
2015
+ import { existsSync, realpathSync } from "node:fs";
2016
+ import { homedir, tmpdir } from "node:os";
2017
+ import { basename, dirname, isAbsolute, join, normalize, relative, resolve } from "node:path";
2017
2018
  var INSTALL_TARGETS = [
2018
2019
  "generic",
2019
2020
  "all",
@@ -2047,10 +2048,36 @@ var TARGET_ENV_DIRS = {
2047
2048
  opencode: ["FLOOM_SKILLS_DIR", "OPENCODE_SKILLS_DIR", "AGENTS_SKILLS_DIR"],
2048
2049
  kimi: ["FLOOM_SKILLS_DIR", "KIMI_SKILLS_DIR", "AGENTS_SKILLS_DIR"]
2049
2050
  };
2051
+ var SYSTEM_ROOTS = /* @__PURE__ */ new Set(["/", "/bin", "/etc", "/proc", "/sbin", "/sys", "/usr", "/var"]);
2052
+ function resolveThroughExistingParent(path) {
2053
+ let current = path;
2054
+ while (!existsSync(current)) {
2055
+ const parent = dirname(current);
2056
+ if (parent === current) return path;
2057
+ current = parent;
2058
+ }
2059
+ const realParent = realpathSync.native(current);
2060
+ return resolve(realParent, relative(current, path));
2061
+ }
2062
+ function validateEnvInstallDir(envVar, rawValue) {
2063
+ const trimmed = rawValue.trim();
2064
+ if (!trimmed || trimmed.includes("\0")) {
2065
+ throw new Error(`${envVar} must be a non-empty filesystem path`);
2066
+ }
2067
+ const resolved = resolve(trimmed);
2068
+ const resolvedHome = resolve(homedir());
2069
+ const realTarget = resolveThroughExistingParent(resolved);
2070
+ if (SYSTEM_ROOTS.has(realTarget) || !isWithin(realTarget, resolvedHome)) {
2071
+ throw new Error(
2072
+ `${envVar} points outside your home directory (${realTarget}). Set it to a path under ${resolvedHome} or use --target without an env override.`
2073
+ );
2074
+ }
2075
+ return realTarget;
2076
+ }
2050
2077
  function envDirForTarget(target) {
2051
2078
  for (const key of TARGET_ENV_DIRS[target]) {
2052
2079
  const value = process.env[key]?.trim();
2053
- if (value) return value;
2080
+ if (value) return validateEnvInstallDir(key, value);
2054
2081
  }
2055
2082
  return null;
2056
2083
  }
@@ -2073,12 +2100,35 @@ function presetDir(target, opts) {
2073
2100
  return join(root, ".agents", "skills");
2074
2101
  }
2075
2102
  }
2103
+ function isWithin(child, parent) {
2104
+ const rel = relative(parent, child);
2105
+ return rel === "" || !!rel && !rel.startsWith("..") && !isAbsolute(rel);
2106
+ }
2107
+ function validateExplicitInstallDir(dir, cwd = process.cwd()) {
2108
+ const trimmed = dir.trim();
2109
+ if (!trimmed || trimmed.includes("\0")) {
2110
+ throw new Error("--to must be a non-empty filesystem path");
2111
+ }
2112
+ const absolute = resolve(cwd, trimmed);
2113
+ if (!isAbsolute(trimmed)) {
2114
+ const cwdRoot = resolve(cwd);
2115
+ if (!isWithin(absolute, cwdRoot)) {
2116
+ throw new Error("--to must stay inside the current project when using a relative path");
2117
+ }
2118
+ return normalize(trimmed);
2119
+ }
2120
+ const allowedAbsoluteRoots = [homedir(), tmpdir()].map((root) => resolve(root));
2121
+ if (!allowedAbsoluteRoots.some((root) => isWithin(absolute, root))) {
2122
+ throw new Error("--to absolute paths must be inside your home directory or temporary directory");
2123
+ }
2124
+ return absolute;
2125
+ }
2076
2126
  function resolveInstallDir(args) {
2077
2127
  const target = args.target ?? "generic";
2078
2128
  if (args.to) {
2079
2129
  return {
2080
2130
  target,
2081
- dir: args.to,
2131
+ dir: validateExplicitInstallDir(args.to, args.cwd),
2082
2132
  origin: "explicit",
2083
2133
  compatibleAgents: COMPATIBLE_AGENTS[target]
2084
2134
  };
@@ -2099,6 +2149,13 @@ function resolveInstallDir(args) {
2099
2149
  compatibleAgents: COMPATIBLE_AGENTS[target]
2100
2150
  };
2101
2151
  }
2152
+ function normalizeInstallParentDir(parentDir, skillSlug) {
2153
+ const normalized = normalize(parentDir);
2154
+ if (basename(normalized) === skillSlug) {
2155
+ return join(normalized, "..");
2156
+ }
2157
+ return normalized;
2158
+ }
2102
2159
 
2103
2160
  // ../shared/src/security-scan.ts
2104
2161
  import { readFile } from "node:fs/promises";
@@ -2222,7 +2279,7 @@ async function scanSkillBundleFiles(files) {
2222
2279
 
2223
2280
  // ../shared/src/skill-package.ts
2224
2281
  import { readFile as readFile2, readdir, stat, lstat, mkdir } from "node:fs/promises";
2225
- import { join as join2, relative, sep, posix } from "node:path";
2282
+ import { join as join2, relative as relative2, sep, posix } from "node:path";
2226
2283
  import { createHash as createHash2 } from "node:crypto";
2227
2284
  var LIMITS = {
2228
2285
  maxBundleBytes: 10 * 1024 * 1024,
@@ -2278,7 +2335,7 @@ async function walk(rootDir, ignore, acc, current) {
2278
2335
  const entries = await readdir(current, { withFileTypes: true });
2279
2336
  for (const entry of entries) {
2280
2337
  const abs = join2(current, entry.name);
2281
- const rel = relative(rootDir, abs).split(sep).join(posix.sep);
2338
+ const rel = relative2(rootDir, abs).split(sep).join(posix.sep);
2282
2339
  if (entry.isSymbolicLink()) {
2283
2340
  throw new Error(`Symlinks are not allowed: ${rel}`);
2284
2341
  }
@@ -2347,7 +2404,7 @@ async function extractBundle(buf, destDir) {
2347
2404
  const tar = await import("tar");
2348
2405
  const { Readable } = await import("node:stream");
2349
2406
  await mkdir(destDir, { recursive: true });
2350
- await new Promise((resolve3, reject) => {
2407
+ await new Promise((resolve5, reject) => {
2351
2408
  const extractStream = tar.extract({
2352
2409
  cwd: destDir,
2353
2410
  strict: true,
@@ -2361,7 +2418,7 @@ async function extractBundle(buf, destDir) {
2361
2418
  if (msg.toLowerCase().includes("symlink")) reject(new Error("Bundle contains symlinks"));
2362
2419
  }
2363
2420
  });
2364
- Readable.from(buf).pipe(extractStream).on("finish", () => resolve3()).on("error", reject);
2421
+ Readable.from(buf).pipe(extractStream).on("finish", () => resolve5()).on("error", reject);
2365
2422
  });
2366
2423
  }
2367
2424
  function verifyBundleHash(buf, expected) {
@@ -2407,7 +2464,11 @@ async function readRawAuth() {
2407
2464
  const raw = await readFile3(AUTH_FILE, "utf8");
2408
2465
  return JSON.parse(raw);
2409
2466
  } catch (e) {
2410
- if (e.code === "ENOENT") return null;
2467
+ const code = e.code;
2468
+ if (code === "ENOENT") return null;
2469
+ if (code === "EACCES" || code === "EPERM") {
2470
+ throw new FloomError("AUTH_REQUIRED", "Cannot read ~/.floom/auth.json due to file permissions. Fix permissions or run: floom login");
2471
+ }
2411
2472
  if (e instanceof SyntaxError) {
2412
2473
  throw new FloomError("AUTH_REQUIRED", "Invalid ~/.floom/auth.json. Run: floom login to refresh local auth.");
2413
2474
  }
@@ -2461,14 +2522,17 @@ function allowsCustomApiUrl() {
2461
2522
  const raw = process.env.FLOOM_ALLOW_CUSTOM_API_URL?.trim().toLowerCase();
2462
2523
  return raw === "1" || raw === "true" || raw === "yes";
2463
2524
  }
2464
- function isTrustedApiUrl(apiUrl) {
2525
+ function isCanonicalApiUrl(apiUrl) {
2465
2526
  try {
2466
2527
  const url = new URL(apiUrl);
2467
- return TRUSTED_API_HOSTS.has(url.hostname) || allowsCustomApiUrl();
2528
+ return TRUSTED_API_HOSTS.has(url.hostname);
2468
2529
  } catch {
2469
2530
  return false;
2470
2531
  }
2471
2532
  }
2533
+ function isTrustedApiUrl(apiUrl) {
2534
+ return isCanonicalApiUrl(apiUrl) || allowsCustomApiUrl();
2535
+ }
2472
2536
  function trustedApiUrlOrDefault(apiUrl) {
2473
2537
  const normalized = normalizeApiUrl(apiUrl);
2474
2538
  return isTrustedApiUrl(normalized) ? normalized : DEFAULT_API_URL;
@@ -2483,11 +2547,12 @@ function isLegacyApiUrl(apiUrl) {
2483
2547
  }
2484
2548
 
2485
2549
  // src/version.ts
2486
- var VERSION = "0.2.13";
2550
+ var VERSION = "0.2.17";
2487
2551
 
2488
2552
  // src/api-client.ts
2489
2553
  var DEFAULT_TIMEOUT_MS = 2e4;
2490
2554
  var DEFAULT_RETRY_ATTEMPTS = 2;
2555
+ var MAX_ERROR_BODY_BYTES = 64 * 1024;
2491
2556
  function timeoutMs() {
2492
2557
  const raw = Number(process.env.FLOOM_API_TIMEOUT_MS);
2493
2558
  return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_TIMEOUT_MS;
@@ -2503,7 +2568,7 @@ function retryDelayMs(attempt) {
2503
2568
  }
2504
2569
  async function sleep(ms) {
2505
2570
  if (ms <= 0) return;
2506
- await new Promise((resolve3) => setTimeout(resolve3, ms));
2571
+ await new Promise((resolve5) => setTimeout(resolve5, ms));
2507
2572
  }
2508
2573
  async function fetchWithTimeout(url, init = {}) {
2509
2574
  const controller = new AbortController();
@@ -2519,12 +2584,32 @@ async function fetchWithTimeout(url, init = {}) {
2519
2584
  clearTimeout(timer);
2520
2585
  }
2521
2586
  }
2522
- function redirectHost(res, requestUrl) {
2523
- const location = res.headers.get("location");
2524
- return location ? new URL(location, requestUrl).host : void 0;
2587
+ async function readLimitedText(res, limitBytes = MAX_ERROR_BODY_BYTES) {
2588
+ if (!res.body) return res.text();
2589
+ const reader = res.body.getReader();
2590
+ const chunks = [];
2591
+ let total = 0;
2592
+ let truncated = false;
2593
+ while (true) {
2594
+ const { done, value } = await reader.read();
2595
+ if (done) break;
2596
+ const chunk = value ?? new Uint8Array();
2597
+ if (total + chunk.byteLength > limitBytes) {
2598
+ const remaining = Math.max(limitBytes - total, 0);
2599
+ if (remaining > 0) chunks.push(chunk.slice(0, remaining));
2600
+ truncated = true;
2601
+ await reader.cancel();
2602
+ break;
2603
+ }
2604
+ chunks.push(chunk);
2605
+ total += chunk.byteLength;
2606
+ }
2607
+ const text = new TextDecoder().decode(Buffer.concat(chunks));
2608
+ return truncated ? `${text}
2609
+ [truncated]` : text;
2525
2610
  }
2526
2611
  function isRedirect(res) {
2527
- return res.status >= 300 && res.status < 400;
2612
+ return res.type === "opaqueredirect" || res.status >= 300 && res.status < 400;
2528
2613
  }
2529
2614
  function isRetryableStatus(status) {
2530
2615
  return status === 408 || status === 429 || status >= 500;
@@ -2534,8 +2619,9 @@ function isRetryableMethod(method) {
2534
2619
  }
2535
2620
  async function api(path, opts = {}) {
2536
2621
  const auth = await readAuth();
2537
- const token = process.env.FLOOM_API_TOKEN?.trim() || auth?.token;
2538
- if (opts.authRequired && !token) {
2622
+ const envToken = process.env.FLOOM_API_TOKEN?.trim();
2623
+ const storedToken = auth?.token;
2624
+ if (opts.authRequired && !opts.tokenOverride && !envToken && !storedToken) {
2539
2625
  throw new FloomError("AUTH_REQUIRED", "Not logged in. Run: floom login");
2540
2626
  }
2541
2627
  let lastError = null;
@@ -2555,7 +2641,15 @@ async function api(path, opts = {}) {
2555
2641
  "User-Agent": `floom-cli/${VERSION}`,
2556
2642
  "x-floom-cli-version": VERSION
2557
2643
  };
2558
- const requestToken = opts.tokenOverride ?? token;
2644
+ const requestToken = opts.tokenOverride ?? envToken ?? (isCanonicalApiUrl(base) ? storedToken : void 0);
2645
+ if (opts.authRequired && !requestToken) {
2646
+ lastError = new FloomError(
2647
+ "AUTH_REQUIRED",
2648
+ "Refusing to send stored auth token to a custom API URL. Set FLOOM_API_TOKEN explicitly only if you trust that API.",
2649
+ { apiUrl: base }
2650
+ );
2651
+ break;
2652
+ }
2559
2653
  if (requestToken) headers.Authorization = `Bearer ${requestToken}`;
2560
2654
  let res;
2561
2655
  try {
@@ -2580,17 +2674,25 @@ async function api(path, opts = {}) {
2580
2674
  throw new FloomError(
2581
2675
  "INTERNAL_ERROR",
2582
2676
  "Floom API returned an unexpected redirect. Refusing to forward credentials.",
2583
- { status: res.status, apiUrl: base, redirectHost: redirectHost(res, url) }
2677
+ { status: res.status, apiUrl: base }
2584
2678
  );
2585
2679
  }
2586
- const text = await res.text();
2680
+ const text = res.ok ? await res.text() : await readLimitedText(res);
2587
2681
  let json = null;
2588
2682
  try {
2589
2683
  json = text ? JSON.parse(text) : null;
2590
2684
  } catch {
2591
2685
  }
2686
+ const envelopeError = json?.error;
2687
+ if (res.ok && envelopeError && typeof envelopeError === "object" && envelopeError.code) {
2688
+ throw new FloomError(
2689
+ envelopeError.code,
2690
+ typeof envelopeError.message === "string" ? envelopeError.message : String(envelopeError.code),
2691
+ { status: res.status, requestId: envelopeError.request_id, apiUrl: base }
2692
+ );
2693
+ }
2592
2694
  if (res.ok) return json;
2593
- const err = json?.error ?? {};
2695
+ const err = envelopeError ?? {};
2594
2696
  lastError = new FloomError(
2595
2697
  err.code ?? "INTERNAL_ERROR",
2596
2698
  err.message ?? `HTTP ${res.status} ${res.statusText}`,
@@ -2621,7 +2723,7 @@ async function rawPut(url, body, contentType = "application/octet-stream") {
2621
2723
  if (isRedirect(res)) {
2622
2724
  throw new FloomError(
2623
2725
  "UPLOAD_FAILED",
2624
- `Upload failed: unexpected redirect to ${redirectHost(res, new URL(url)) ?? "unknown"}`
2726
+ "Upload failed: unexpected redirect."
2625
2727
  );
2626
2728
  }
2627
2729
  if (!res.ok) {
@@ -2641,7 +2743,7 @@ async function rawGet(url) {
2641
2743
  if (isRedirect(res)) {
2642
2744
  throw new FloomError(
2643
2745
  "DOWNLOAD_FAILED",
2644
- `Download failed: unexpected redirect to ${redirectHost(res, new URL(url)) ?? "unknown"}`
2746
+ "Download failed: unexpected redirect."
2645
2747
  );
2646
2748
  }
2647
2749
  if (!res.ok) {
@@ -2685,9 +2787,18 @@ async function loginCommand() {
2685
2787
  const interval = Math.max(2, session.poll_interval_seconds) * 1e3;
2686
2788
  while (Date.now() < deadline) {
2687
2789
  await new Promise((r) => setTimeout(r, interval));
2688
- const poll = await api(`/cli/sessions/${session.session_id}`, {
2689
- tokenOverride: session.device_code
2690
- });
2790
+ let poll;
2791
+ try {
2792
+ poll = await api(`/cli/sessions/${session.session_id}`, {
2793
+ tokenOverride: session.device_code
2794
+ });
2795
+ } catch (e) {
2796
+ if (e instanceof FloomError && String(e.code) === "DEVICE_PENDING") {
2797
+ process.stdout.write(".");
2798
+ continue;
2799
+ }
2800
+ throw e;
2801
+ }
2691
2802
  if (poll.status === "approved" && poll.token && poll.handle && poll.email) {
2692
2803
  await writeAuth({
2693
2804
  token: poll.token,
@@ -2759,13 +2870,16 @@ async function whoamiCommand() {
2759
2870
  log.heading("Logged in as:");
2760
2871
  log.kv("handle", `@${me.user.handle}`);
2761
2872
  log.kv("email", me.user.email);
2873
+ if (me.workspace) {
2874
+ log.kv("workspace", `${me.workspace.slug} (${me.workspace.role})`);
2875
+ }
2762
2876
  log.kv("api url", process.env.FLOOM_API_URL ?? auth?.apiUrl ?? "default");
2763
2877
  if (envToken) log.kv("auth", "FLOOM_API_TOKEN");
2764
2878
  }
2765
2879
 
2766
2880
  // src/commands/init.ts
2767
2881
  import { writeFile as writeFile2, stat as stat2 } from "node:fs/promises";
2768
- import { join as join4, basename } from "node:path";
2882
+ import { join as join4, basename as basename2 } from "node:path";
2769
2883
  import prompts from "prompts";
2770
2884
  async function pathExists(p) {
2771
2885
  try {
@@ -2777,7 +2891,7 @@ async function pathExists(p) {
2777
2891
  }
2778
2892
  async function initCommand() {
2779
2893
  const cwd = process.cwd();
2780
- const folderName = basename(cwd);
2894
+ const folderName = basename2(cwd);
2781
2895
  if (await pathExists(join4(cwd, "skill.json"))) {
2782
2896
  log.err("skill.json already exists in this folder. Edit it directly or run from a fresh directory.");
2783
2897
  process.exit(1);
@@ -2979,15 +3093,52 @@ async function validateCommand(opts = {}) {
2979
3093
  // src/commands/publish.ts
2980
3094
  import { readFile as readFile6 } from "node:fs/promises";
2981
3095
  import { join as join6 } from "node:path";
3096
+
3097
+ // src/lib/publish-urls.ts
3098
+ function trimAppUrl(appUrl) {
3099
+ return appUrl.replace(/\/api\/v1\/?$/, "").replace(/\/$/, "");
3100
+ }
2982
3101
  function buildAuthenticatedSkillUrl(appUrl, librarySlug, skillSlug) {
2983
- const base = appUrl.replace(/\/api\/v1\/?$/, "").replace(/\/$/, "");
3102
+ const base = trimAppUrl(appUrl);
2984
3103
  return `${base}/library/${encodeURIComponent(skillSlug)}?lib=${encodeURIComponent(librarySlug)}`;
2985
3104
  }
2986
3105
  function buildPublicSkillUrl(appUrl, handle, librarySlug, skillSlug) {
2987
- const base = appUrl.replace(/\/api\/v1\/?$/, "").replace(/\/$/, "");
3106
+ const base = trimAppUrl(appUrl);
2988
3107
  if (librarySlug !== handle) return null;
2989
3108
  return `${base}/@${handle}/${skillSlug}`;
2990
3109
  }
3110
+ function buildPublishViewLines(args) {
3111
+ const manageUrl = buildAuthenticatedSkillUrl(args.appUrl, args.refRoot, args.slug);
3112
+ const publicUrl = buildPublicSkillUrl(args.appUrl, args.handle, args.refRoot, args.slug);
3113
+ if (args.visibility === "public") {
3114
+ return {
3115
+ heading: publicUrl ? "View (public):" : "Manage (workspace skill):",
3116
+ primaryUrl: publicUrl ?? manageUrl,
3117
+ ...publicUrl ? {} : { note: "Workspace skills stay under their authenticated library URL unless copied to your personal library." },
3118
+ shareUrl: args.shareUrl ?? void 0
3119
+ };
3120
+ }
3121
+ if (args.visibility === "unlisted") {
3122
+ return {
3123
+ heading: publicUrl ? "View (unlisted - use a share link for unauthenticated access):" : "Manage (unlisted workspace skill):",
3124
+ primaryUrl: publicUrl ?? manageUrl,
3125
+ ...publicUrl ? {} : { note: "Use the share link for unauthenticated access; workspace detail URLs remain authenticated." },
3126
+ shareUrl: args.shareUrl ?? void 0
3127
+ };
3128
+ }
3129
+ return {
3130
+ heading: "Manage (private - sign in to your workspace):",
3131
+ primaryUrl: manageUrl,
3132
+ ...publicUrl ? {
3133
+ secondaryHeading: "Public URL after you make it public or unlisted:",
3134
+ secondaryUrl: publicUrl
3135
+ } : {
3136
+ note: "Workspace skills stay under their authenticated library URL unless copied to your personal library."
3137
+ }
3138
+ };
3139
+ }
3140
+
3141
+ // src/commands/publish.ts
2991
3142
  async function publishCommand(opts = {}) {
2992
3143
  const auth = await readAuth();
2993
3144
  const envToken = process.env.FLOOM_API_TOKEN?.trim();
@@ -2995,7 +3146,7 @@ async function publishCommand(opts = {}) {
2995
3146
  log.err("Not logged in. Run: floom login");
2996
3147
  process.exit(1);
2997
3148
  }
2998
- const dir = process.cwd();
3149
+ const dir = opts.dir ?? process.cwd();
2999
3150
  log.heading("Validating skill...");
3000
3151
  const report = await validateSkill(dir);
3001
3152
  if (!report.ok) {
@@ -3070,34 +3221,126 @@ async function publishCommand(opts = {}) {
3070
3221
  log.blank();
3071
3222
  log.ok(`Published ${complete.ref}`);
3072
3223
  log.blank();
3073
- log.info("Manage:");
3074
3224
  const displayApiUrl = trustedApiUrlOrDefault(process.env.FLOOM_API_URL ?? auth?.apiUrl ?? DEFAULT_API_URL);
3075
- log.kv("", buildAuthenticatedSkillUrl(displayApiUrl, refRoot, manifest.name));
3076
- const publicUrl = buildPublicSkillUrl(displayApiUrl, handle, refRoot, manifest.name);
3077
- if (publicUrl) {
3078
- log.info("Public URL after you make it public:");
3079
- log.kv("", publicUrl);
3080
- } else {
3081
- log.info("Public profile URL:");
3082
- log.kv("", "Workspace skills stay under their authenticated library URL unless copied to your personal library.");
3225
+ const appUrl = displayApiUrl.replace(/\/api\/v1\/?$/, "");
3226
+ const visibility = complete.visibility ?? "private";
3227
+ const views = buildPublishViewLines({
3228
+ visibility,
3229
+ appUrl,
3230
+ handle,
3231
+ slug: manifest.name,
3232
+ refRoot,
3233
+ shareUrl: complete.share_url
3234
+ });
3235
+ log.info(views.heading);
3236
+ log.kv("", views.primaryUrl);
3237
+ if (views.secondaryHeading && views.secondaryUrl) {
3238
+ log.info(views.secondaryHeading);
3239
+ log.kv("", views.secondaryUrl);
3240
+ }
3241
+ if (views.note) {
3242
+ log.info(views.note);
3243
+ }
3244
+ if (views.shareUrl) {
3245
+ log.info("Share link:");
3246
+ log.kv("", views.shareUrl);
3247
+ } else if (visibility === "unlisted") {
3248
+ log.info(`Create a share link: floom link create ${refRoot}/${manifest.name}`);
3083
3249
  }
3084
3250
  log.info("Install:");
3085
3251
  log.kv("", complete.install_command);
3086
3252
  }
3087
3253
 
3254
+ // src/commands/push.ts
3255
+ import { readdir as readdir2, stat as stat3 } from "node:fs/promises";
3256
+ import { basename as basename3, join as join7, resolve as resolve2 } from "node:path";
3257
+ function parsePushConcurrency(value) {
3258
+ const raw = value ?? 6;
3259
+ const parsed = typeof raw === "number" ? raw : Number.parseInt(raw, 10);
3260
+ if (!Number.isInteger(parsed) || parsed < 1 || parsed > 16) {
3261
+ throw new Error("--concurrency must be an integer from 1 to 16.");
3262
+ }
3263
+ return parsed;
3264
+ }
3265
+ async function hasSkillFiles(dir) {
3266
+ try {
3267
+ const [skillMd, manifest] = await Promise.all([
3268
+ stat3(join7(dir, "SKILL.md")),
3269
+ stat3(join7(dir, "skill.json"))
3270
+ ]);
3271
+ return skillMd.isFile() && manifest.isFile();
3272
+ } catch {
3273
+ return false;
3274
+ }
3275
+ }
3276
+ async function findImmediateSkillDirs(root) {
3277
+ const entries = await readdir2(root, { withFileTypes: true });
3278
+ const dirs = entries.filter((entry) => entry.isDirectory()).map((entry) => join7(root, entry.name)).sort();
3279
+ const checks = await Promise.all(dirs.map(async (dir) => await hasSkillFiles(dir) ? dir : null));
3280
+ return checks.filter((dir) => Boolean(dir));
3281
+ }
3282
+ async function runBounded(items, concurrency, worker) {
3283
+ let next = 0;
3284
+ const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
3285
+ while (next < items.length) {
3286
+ const item = items[next];
3287
+ next += 1;
3288
+ await worker(item);
3289
+ }
3290
+ });
3291
+ await Promise.all(workers);
3292
+ }
3293
+ async function pushCommand(dir = ".", options = {}, deps = {}) {
3294
+ const root = resolve2(dir);
3295
+ const rootStat = await stat3(root).catch(() => null);
3296
+ if (!rootStat?.isDirectory()) {
3297
+ throw new Error(`Directory not found: ${dir}`);
3298
+ }
3299
+ const publish = deps.publish ?? publishCommand;
3300
+ if (await hasSkillFiles(root)) {
3301
+ await publish({ ...options, dir: root });
3302
+ return;
3303
+ }
3304
+ const skillDirs = await findImmediateSkillDirs(root);
3305
+ if (skillDirs.length === 0) {
3306
+ throw new Error("SKILL.md and skill.json are required, either in this directory or immediate child directories.");
3307
+ }
3308
+ const concurrency = parsePushConcurrency(options.concurrency);
3309
+ const startedAt = Date.now();
3310
+ const errors = [];
3311
+ let pushed = 0;
3312
+ await runBounded(skillDirs, concurrency, async (skillDir) => {
3313
+ const slug = basename3(skillDir);
3314
+ try {
3315
+ await publish({ ...options, dir: skillDir });
3316
+ pushed += 1;
3317
+ log.info(`Pushed ${slug}`);
3318
+ } catch (error) {
3319
+ errors.push({ slug, message: error.message });
3320
+ }
3321
+ });
3322
+ const elapsed = ((Date.now() - startedAt) / 1e3).toFixed(1).replace(/\.0$/, "");
3323
+ log.info(`Pushed ${pushed}/${skillDirs.length} skills in ${elapsed}s.`);
3324
+ if (errors.length > 0) {
3325
+ log.err("Push errors:");
3326
+ for (const error of errors) log.err(`- ${error.slug}: ${error.message}`);
3327
+ process.exitCode = 1;
3328
+ }
3329
+ }
3330
+
3088
3331
  // src/commands/install.ts
3089
- import { mkdir as mkdir4, readdir as readdir3, rm, rename } from "node:fs/promises";
3090
- import { join as join8 } from "node:path";
3091
- import { tmpdir } from "node:os";
3332
+ import { mkdir as mkdir4, readdir as readdir4, rm, rename } from "node:fs/promises";
3333
+ import { join as join9 } from "node:path";
3334
+ import { tmpdir as tmpdir2 } from "node:os";
3092
3335
 
3093
3336
  // src/lib/floom-lock.ts
3094
- import { readFile as readFile7, writeFile as writeFile3, mkdir as mkdir3, stat as stat3, readdir as readdir2 } from "node:fs/promises";
3095
- import { join as join7, relative as relative2, sep as sep2, posix as posix2 } from "node:path";
3337
+ import { readFile as readFile7, writeFile as writeFile3, mkdir as mkdir3, stat as stat4, readdir as readdir3 } from "node:fs/promises";
3338
+ import { join as join8, relative as relative3, sep as sep2, posix as posix2 } from "node:path";
3096
3339
  import { createHash as createHash3 } from "node:crypto";
3097
3340
  var EMPTY = { schema_version: "0.1", skills: {} };
3098
3341
  async function readLock(projectDir) {
3099
3342
  try {
3100
- const raw = await readFile7(join7(projectDir, "floom.lock"), "utf8");
3343
+ const raw = await readFile7(join8(projectDir, "floom.lock"), "utf8");
3101
3344
  const parsed = JSON.parse(raw);
3102
3345
  if (parsed.schema_version === "0.1") return parsed;
3103
3346
  if (parsed.schema_version === "0.2") return parsed;
@@ -3112,7 +3355,7 @@ async function readLock(projectDir) {
3112
3355
  }
3113
3356
  async function writeLock(projectDir, lock) {
3114
3357
  await mkdir3(projectDir, { recursive: true });
3115
- await writeFile3(join7(projectDir, "floom.lock"), JSON.stringify(lock, null, 2) + "\n", "utf8");
3358
+ await writeFile3(join8(projectDir, "floom.lock"), JSON.stringify(lock, null, 2) + "\n", "utf8");
3116
3359
  }
3117
3360
  function setLockEntry(lock, ref, entry) {
3118
3361
  return { ...lock, skills: { ...lock.skills, [ref]: entry } };
@@ -3142,12 +3385,12 @@ async function hashInstalledFolder(folderAbs) {
3142
3385
  async function walk2(dir) {
3143
3386
  let entries;
3144
3387
  try {
3145
- entries = await readdir2(dir, { withFileTypes: true });
3388
+ entries = await readdir3(dir, { withFileTypes: true });
3146
3389
  } catch {
3147
3390
  return;
3148
3391
  }
3149
3392
  for (const entry of entries) {
3150
- const abs = join7(dir, entry.name);
3393
+ const abs = join8(dir, entry.name);
3151
3394
  if (entry.isSymbolicLink()) continue;
3152
3395
  if (entry.isDirectory()) {
3153
3396
  await walk2(abs);
@@ -3155,7 +3398,7 @@ async function hashInstalledFolder(folderAbs) {
3155
3398
  }
3156
3399
  if (!entry.isFile()) continue;
3157
3400
  const buf = await readFile7(abs);
3158
- const rel = relative2(folderAbs, abs).split(sep2).join(posix2.sep);
3401
+ const rel = relative3(folderAbs, abs).split(sep2).join(posix2.sep);
3159
3402
  out.push({
3160
3403
  relPath: rel,
3161
3404
  sha256: createHash3("sha256").update(buf).digest("hex"),
@@ -3164,7 +3407,7 @@ async function hashInstalledFolder(folderAbs) {
3164
3407
  }
3165
3408
  }
3166
3409
  try {
3167
- await stat3(folderAbs);
3410
+ await stat4(folderAbs);
3168
3411
  } catch {
3169
3412
  return out;
3170
3413
  }
@@ -3173,6 +3416,20 @@ async function hashInstalledFolder(folderAbs) {
3173
3416
  return out;
3174
3417
  }
3175
3418
 
3419
+ // src/lib/filesystem-safety.ts
3420
+ import { lstat as lstat2 } from "node:fs/promises";
3421
+ async function assertPathIsNotSymlink(path, label = "path") {
3422
+ try {
3423
+ const stats = await lstat2(path);
3424
+ if (stats.isSymbolicLink()) {
3425
+ throw new Error(`${label} must not be a symbolic link: ${path}`);
3426
+ }
3427
+ } catch (e) {
3428
+ if (e.code === "ENOENT") return;
3429
+ throw e;
3430
+ }
3431
+ }
3432
+
3176
3433
  // src/commands/install.ts
3177
3434
  var CLI_VERSION = VERSION;
3178
3435
  function semverGte(a, b) {
@@ -3226,13 +3483,16 @@ async function installCommand(refStr, opts = {}) {
3226
3483
  global: opts.global
3227
3484
  });
3228
3485
  const projectDir = opts.global ? process.cwd() : process.cwd();
3229
- const destFolder = join8(target.dir, ref.slug);
3486
+ const parentDir = opts.to ? normalizeInstallParentDir(target.dir, ref.slug) : target.dir;
3487
+ const destFolder = join9(parentDir, ref.slug);
3488
+ await assertPathIsNotSymlink(parentDir, "install parent directory");
3489
+ await assertPathIsNotSymlink(destFolder, "install destination");
3230
3490
  const lock = await readLock(projectDir);
3231
3491
  const refKey = formatSkillRef({ owner: ref.owner, slug: ref.slug });
3232
3492
  const existing = lock.skills[refKey];
3233
3493
  let folderExists = false;
3234
3494
  try {
3235
- const entries = await readdir3(destFolder);
3495
+ const entries = await readdir4(destFolder);
3236
3496
  folderExists = entries.length > 0;
3237
3497
  } catch {
3238
3498
  }
@@ -3262,7 +3522,7 @@ async function installCommand(refStr, opts = {}) {
3262
3522
  }
3263
3523
  log.ok(`Bundle verified (${(buf.length / 1024).toFixed(1)} KB).`);
3264
3524
  const tmp = await import("node:fs/promises").then(
3265
- (m) => m.mkdtemp(join8(tmpdir(), `floom-install-${ref.slug}-`))
3525
+ (m) => m.mkdtemp(join9(tmpdir2(), `floom-install-${ref.slug}-`))
3266
3526
  );
3267
3527
  try {
3268
3528
  await extractBundle(buf, tmp);
@@ -3365,8 +3625,8 @@ async function outdatedCommand() {
3365
3625
 
3366
3626
  // src/commands/update.ts
3367
3627
  import { rm as rm2, rename as rename2, mkdir as mkdir5 } from "node:fs/promises";
3368
- import { tmpdir as tmpdir2 } from "node:os";
3369
- import { join as join9, resolve as resolve2 } from "node:path";
3628
+ import { tmpdir as tmpdir3 } from "node:os";
3629
+ import { join as join10, resolve as resolve4 } from "node:path";
3370
3630
  function cmpSemver2(a, b) {
3371
3631
  const pa = a.split(".").map((p) => parseInt(p, 10));
3372
3632
  const pb = b.split(".").map((p) => parseInt(p, 10));
@@ -3405,7 +3665,8 @@ async function updateCommand(refStr, opts = {}) {
3405
3665
  log.step(`${ref} is already at latest (${entry.version}).`);
3406
3666
  continue;
3407
3667
  }
3408
- const installDir = resolve2(projectDir, entry.path);
3668
+ const installDir = resolve4(projectDir, entry.path);
3669
+ await assertPathIsNotSymlink(installDir, "installed skill directory");
3409
3670
  if (!opts.force) {
3410
3671
  const current = await hashInstalledFolder(installDir);
3411
3672
  log.step(`Updating ${ref}: ${entry.version} -> ${info.latest_version}`);
@@ -3418,7 +3679,7 @@ async function updateCommand(refStr, opts = {}) {
3418
3679
  continue;
3419
3680
  }
3420
3681
  const tmp = await import("node:fs/promises").then(
3421
- (m) => m.mkdtemp(join9(tmpdir2(), `floom-update-${slug}-`))
3682
+ (m) => m.mkdtemp(join10(tmpdir3(), `floom-update-${slug}-`))
3422
3683
  );
3423
3684
  try {
3424
3685
  await extractBundle(buf, tmp);
@@ -3545,6 +3806,26 @@ async function unshareCommand(refStr, email) {
3545
3806
  log.ok(`Removed ${email}'s access to ${refStr}.`);
3546
3807
  }
3547
3808
 
3809
+ // src/commands/link.ts
3810
+ async function linkCreateCommand(refStr, opts = {}) {
3811
+ const ref = parseSkillRef(refStr);
3812
+ if (!ref) {
3813
+ log.err(`Invalid skill ref: ${refStr}`);
3814
+ process.exit(1);
3815
+ }
3816
+ const role = opts.role ?? "viewer";
3817
+ const body = { role };
3818
+ if (opts.name) body.name = opts.name;
3819
+ const r = await api(`/skills/${ref.owner}/${ref.slug}/links`, {
3820
+ method: "POST",
3821
+ authRequired: true,
3822
+ body
3823
+ });
3824
+ log.ok(`Share link created for ${refStr}.`);
3825
+ log.kv("url", r.link.url);
3826
+ log.kv("role", r.link.role);
3827
+ }
3828
+
3548
3829
  // src/commands/library.ts
3549
3830
  function isValidEmail2(email) {
3550
3831
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
@@ -3614,7 +3895,7 @@ async function workspaceAcceptCommand(tokenOrUrl) {
3614
3895
  // src/lib/config-file.ts
3615
3896
  import { mkdir as mkdir6, readFile as readFile8, writeFile as writeFile4 } from "node:fs/promises";
3616
3897
  import { homedir as homedir3 } from "node:os";
3617
- import { dirname, join as join10 } from "node:path";
3898
+ import { dirname as dirname2, join as join11 } from "node:path";
3618
3899
  import { z as z2 } from "zod";
3619
3900
  var FLOOM_CONFIG_VERSION = "0.1";
3620
3901
  var TARGETS = ["claude", "codex", "cursor", "kimi", "opencode"];
@@ -3628,8 +3909,8 @@ var configSchema = z2.object({
3628
3909
  targets: z2.record(z2.enum(TARGETS), targetConfigSchema).default({})
3629
3910
  });
3630
3911
  function configPath(scope, cwd = process.cwd()) {
3631
- if (scope === "global") return join10(homedir3(), ".floom", "config.json");
3632
- return join10(cwd, ".floom", "config.json");
3912
+ if (scope === "global") return join11(homedir3(), ".floom", "config.json");
3913
+ return join11(cwd, ".floom", "config.json");
3633
3914
  }
3634
3915
  async function readFloomConfig(scope, cwd = process.cwd()) {
3635
3916
  try {
@@ -3643,7 +3924,7 @@ async function readFloomConfig(scope, cwd = process.cwd()) {
3643
3924
  async function writeFloomConfig(scope, config, cwd = process.cwd()) {
3644
3925
  const path = configPath(scope, cwd);
3645
3926
  const parsed = configSchema.parse(config);
3646
- await mkdir6(dirname(path), { recursive: true, mode: 448 });
3927
+ await mkdir6(dirname2(path), { recursive: true, mode: 448 });
3647
3928
  await writeFile4(path, JSON.stringify(parsed, null, 2) + "\n", { mode: 384 });
3648
3929
  }
3649
3930
  function normalizeScope(value) {
@@ -3681,7 +3962,7 @@ async function setWorkspaceActive(input) {
3681
3962
 
3682
3963
  // src/commands/instruction.ts
3683
3964
  import { mkdir as mkdir7, readFile as readFile9, writeFile as writeFile5 } from "node:fs/promises";
3684
- import { basename as basename2, dirname as dirname2, join as join11 } from "node:path";
3965
+ import { basename as basename4, dirname as dirname3, join as join12 } from "node:path";
3685
3966
  import { createHash as createHash4 } from "node:crypto";
3686
3967
  var START = "<!-- FLOOM START -->";
3687
3968
  var END = "<!-- FLOOM END -->";
@@ -3766,7 +4047,7 @@ async function writeManagedInstructionFile(input) {
3766
4047
  else throw e;
3767
4048
  }
3768
4049
  const next = replaceManagedBlock(existing, input.blockBody);
3769
- if (existed && !next.hadBlock && !input.apply && !input.force) {
4050
+ if (existed && !next.hadBlock && !input.apply) {
3770
4051
  log.warn(`Refusing to modify ${input.path} without an existing Floom managed block.`);
3771
4052
  log.info("Re-run with --apply to append the managed block, or --path <file> for a dedicated file.");
3772
4053
  process.exit(1);
@@ -3775,11 +4056,14 @@ async function writeManagedInstructionFile(input) {
3775
4056
  let backupPath;
3776
4057
  if (existed && existing) {
3777
4058
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
3778
- backupPath = join11(".floom", "backups", `${basename2(input.path)}.${stamp}.bak`);
3779
- await mkdir7(dirname2(backupPath), { recursive: true, mode: 448 });
4059
+ backupPath = join12(".floom", "backups", `${basename4(input.path)}.${stamp}.bak`);
4060
+ await assertPathIsNotSymlink(dirname3(backupPath), "instruction backup directory");
4061
+ await assertPathIsNotSymlink(backupPath, "instruction backup file");
4062
+ await mkdir7(dirname3(backupPath), { recursive: true, mode: 448 });
3780
4063
  await writeFile5(backupPath, existing, { mode: 384 });
3781
4064
  }
3782
- await mkdir7(dirname2(input.path), { recursive: true });
4065
+ await mkdir7(dirname3(input.path), { recursive: true });
4066
+ await assertPathIsNotSymlink(input.path, "instruction file");
3783
4067
  await writeFile5(input.path, next.content, "utf8");
3784
4068
  return { changed: true, backupPath };
3785
4069
  }
@@ -3932,7 +4216,7 @@ async function workspaceActiveCommand(opts = {}) {
3932
4216
 
3933
4217
  // src/commands/sync.ts
3934
4218
  import { mkdir as mkdir8, writeFile as writeFile6 } from "node:fs/promises";
3935
- import { dirname as dirname3, join as join12 } from "node:path";
4219
+ import { dirname as dirname4, join as join13 } from "node:path";
3936
4220
  var ROUTER_SKILL = [
3937
4221
  "# Floom Find Skills",
3938
4222
  "",
@@ -3951,8 +4235,8 @@ var ROUTER_SKILL = [
3951
4235
  async function installRouter(target) {
3952
4236
  const install = resolveInstallDir({ target });
3953
4237
  const routerDir = target === "kimi" ? "floom" : "floom-find-skills";
3954
- const path = join12(install.dir, routerDir, "SKILL.md");
3955
- await mkdir8(dirname3(path), { recursive: true });
4238
+ const path = join13(install.dir, routerDir, "SKILL.md");
4239
+ await mkdir8(dirname4(path), { recursive: true });
3956
4240
  await writeFile6(path, ROUTER_SKILL, "utf8");
3957
4241
  return path;
3958
4242
  }
@@ -4220,13 +4504,14 @@ async function folderRevokeCommand(workspace, folderId, memberId) {
4220
4504
  }
4221
4505
 
4222
4506
  // src/commands/mcp.ts
4223
- import { mkdtemp, mkdir as mkdir9, readdir as readdir4, readFile as readFile10, rename as rename3, rm as rm3, writeFile as writeFile7 } from "node:fs/promises";
4224
- import { join as join13 } from "node:path";
4225
- import { tmpdir as tmpdir3 } from "node:os";
4507
+ import { mkdtemp, mkdir as mkdir9, readdir as readdir5, readFile as readFile10, rename as rename3, rm as rm3, writeFile as writeFile7 } from "node:fs/promises";
4508
+ import { join as join14 } from "node:path";
4509
+ import { tmpdir as tmpdir4 } from "node:os";
4226
4510
  import { z as z3 } from "zod";
4227
4511
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4228
4512
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4229
4513
  var API_TIMEOUT_MS = 2e4;
4514
+ var MAX_ERROR_BODY_BYTES2 = 64 * 1024;
4230
4515
  function semverGte2(a, b) {
4231
4516
  const pa = a.split(".").map(Number);
4232
4517
  const pb = b.split(".").map(Number);
@@ -4248,6 +4533,30 @@ async function fetchWithTimeout2(url, init = {}) {
4248
4533
  clearTimeout(timer);
4249
4534
  }
4250
4535
  }
4536
+ async function readLimitedText2(res, limitBytes = MAX_ERROR_BODY_BYTES2) {
4537
+ if (!res.body) return res.text();
4538
+ const reader = res.body.getReader();
4539
+ const chunks = [];
4540
+ let total = 0;
4541
+ let truncated = false;
4542
+ while (true) {
4543
+ const { done, value } = await reader.read();
4544
+ if (done) break;
4545
+ const chunk = value ?? new Uint8Array();
4546
+ if (total + chunk.byteLength > limitBytes) {
4547
+ const remaining = Math.max(limitBytes - total, 0);
4548
+ if (remaining > 0) chunks.push(chunk.slice(0, remaining));
4549
+ truncated = true;
4550
+ await reader.cancel();
4551
+ break;
4552
+ }
4553
+ chunks.push(chunk);
4554
+ total += chunk.byteLength;
4555
+ }
4556
+ const text = new TextDecoder().decode(Buffer.concat(chunks));
4557
+ return truncated ? `${text}
4558
+ [truncated]` : text;
4559
+ }
4251
4560
  async function resolveOptionalToken() {
4252
4561
  const fromEnv = process.env.FLOOM_API_TOKEN?.trim();
4253
4562
  if (fromEnv) return fromEnv;
@@ -4280,19 +4589,21 @@ async function apiRequest(token, path, query) {
4280
4589
  lastError = new Error(`Unable to reach Floom API at ${base}: ${e.message}`);
4281
4590
  continue;
4282
4591
  }
4283
- if (res.status >= 300 && res.status < 400) {
4284
- const location = res.headers.get("location");
4285
- const redirectHost2 = location ? new URL(location, url).host : "unknown";
4286
- lastError = new Error(`Floom API returned an unexpected redirect to ${redirectHost2}. Refusing to forward credentials.`);
4592
+ if (res.type === "opaqueredirect" || res.status >= 300 && res.status < 400) {
4593
+ lastError = new Error("Floom API returned an unexpected redirect. Refusing to forward credentials.");
4287
4594
  break;
4288
4595
  }
4289
- const body = await res.text();
4596
+ const body = res.ok ? await res.text() : await readLimitedText2(res);
4290
4597
  let json = null;
4291
4598
  try {
4292
4599
  json = body ? JSON.parse(body) : null;
4293
4600
  } catch {
4294
4601
  json = { raw: body };
4295
4602
  }
4603
+ if (json?.error?.code) {
4604
+ lastError = new Error(json.error.message ?? String(json.error.code));
4605
+ if (res.ok || res.status !== 404) break;
4606
+ }
4296
4607
  if (res.ok) return json;
4297
4608
  lastError = new Error(json?.error?.message ?? `HTTP ${res.status}`);
4298
4609
  if (res.status !== 404 || json?.error) break;
@@ -4311,14 +4622,16 @@ async function installViaApi(token, refText, target, options = {}) {
4311
4622
  const bundle = await rawGet(dl.download.url);
4312
4623
  if (!verifyBundleHash(bundle, dl.bundle_sha256)) throw new Error("Bundle hash mismatch");
4313
4624
  const install = resolveInstallDir({ target });
4625
+ await assertPathIsNotSymlink(install.dir, "install directory");
4314
4626
  await mkdir9(install.dir, { recursive: true });
4315
- const dest = join13(install.dir, parsed.slug);
4316
- const exists = await readdir4(dest).then(() => true).catch(() => false);
4627
+ const dest = join14(install.dir, parsed.slug);
4628
+ await assertPathIsNotSymlink(dest, "install destination");
4629
+ const exists = await readdir5(dest).then(() => true).catch(() => false);
4317
4630
  if (exists && !options.force) {
4318
4631
  throw new Error(`Folder already exists at ${dest}. Call install_skill with force=true to overwrite after reviewing local changes.`);
4319
4632
  }
4320
4633
  if (exists) await rm3(dest, { recursive: true, force: true });
4321
- const temp = await mkdtemp(join13(tmpdir3(), `floom-mcp-${parsed.slug}-`));
4634
+ const temp = await mkdtemp(join14(tmpdir4(), `floom-mcp-${parsed.slug}-`));
4322
4635
  try {
4323
4636
  await extractBundle(bundle, temp);
4324
4637
  await rename3(temp, dest);
@@ -4341,24 +4654,24 @@ async function installViaApi(token, refText, target, options = {}) {
4341
4654
  return { path: dest, version: dl.version, ref: info.ref ?? ref, has_scripts: !!dl.has_scripts };
4342
4655
  }
4343
4656
  async function parseSkillBundle(bundle) {
4344
- const tmp = await mkdtemp(join13(tmpdir3(), "floom-mcp-read-"));
4657
+ const tmp = await mkdtemp(join14(tmpdir4(), "floom-mcp-read-"));
4345
4658
  try {
4346
4659
  await extractBundle(bundle, tmp);
4347
4660
  const files = [];
4348
4661
  const walk2 = async (dir, rel = "") => {
4349
- const entries = await readdir4(dir, { withFileTypes: true });
4662
+ const entries = await readdir5(dir, { withFileTypes: true });
4350
4663
  for (const entry of entries) {
4351
4664
  const nextRel = rel ? `${rel}/${entry.name}` : entry.name;
4352
- const full = join13(dir, entry.name);
4665
+ const full = join14(dir, entry.name);
4353
4666
  if (entry.isDirectory()) await walk2(full, nextRel);
4354
4667
  else files.push(nextRel);
4355
4668
  }
4356
4669
  };
4357
4670
  await walk2(tmp);
4358
4671
  const skillMdPath = files.find((f) => f.toUpperCase() === "SKILL.MD");
4359
- const skillMd = skillMdPath ? await readFile10(join13(tmp, skillMdPath), "utf8") : "";
4672
+ const skillMd = skillMdPath ? await readFile10(join14(tmp, skillMdPath), "utf8") : "";
4360
4673
  const skillJsonPath = files.find((f) => f.toLowerCase() === "skill.json");
4361
- const skillJson = skillJsonPath ? JSON.parse(await readFile10(join13(tmp, skillJsonPath), "utf8")) : null;
4674
+ const skillJson = skillJsonPath ? JSON.parse(await readFile10(join14(tmp, skillJsonPath), "utf8")) : null;
4362
4675
  return { files, skill_md: skillMd, skill_json: skillJson };
4363
4676
  } finally {
4364
4677
  await rm3(tmp, { recursive: true, force: true });
@@ -4451,11 +4764,29 @@ async function mcpCommand() {
4451
4764
  }
4452
4765
 
4453
4766
  // src/commands/doctor.ts
4454
- import { mkdtemp as mkdtemp2, readdir as readdir5, rm as rm4 } from "node:fs/promises";
4455
- import { tmpdir as tmpdir4 } from "node:os";
4456
- import { join as join14 } from "node:path";
4767
+ import { mkdtemp as mkdtemp2, readdir as readdir6, rm as rm4 } from "node:fs/promises";
4768
+ import { tmpdir as tmpdir5 } from "node:os";
4769
+ import { join as join15 } from "node:path";
4770
+ import { fileURLToPath } from "node:url";
4457
4771
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
4458
4772
  import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
4773
+
4774
+ // src/lib/api-health.ts
4775
+ async function probeApiHealth(rawApiUrl) {
4776
+ const apiV1 = trustedApiUrlOrDefault(rawApiUrl ?? DEFAULT_API_URL).replace(/\/$/, "");
4777
+ const healthUrl = `${apiV1}/health`;
4778
+ try {
4779
+ const res = await fetch(healthUrl, { method: "GET", signal: AbortSignal.timeout(8e3) });
4780
+ if (!res.ok) return { name: "api_health", ok: false, detail: `GET ${healthUrl} \u2192 HTTP ${res.status}` };
4781
+ const body = await res.json();
4782
+ if (body.ok !== true) return { name: "api_health", ok: false, detail: `unexpected JSON from ${healthUrl}` };
4783
+ return { name: "api_health", ok: true, detail: healthUrl };
4784
+ } catch (e) {
4785
+ return { name: "api_health", ok: false, detail: `${healthUrl}: ${e.message}` };
4786
+ }
4787
+ }
4788
+
4789
+ // src/commands/doctor.ts
4459
4790
  function textOf(result) {
4460
4791
  return String(result?.content?.[0]?.text ?? "");
4461
4792
  }
@@ -4519,11 +4850,13 @@ async function doctorCommand(opts = {}) {
4519
4850
  authCheck2 = fail("auth", `saved login rejected by API: ${e.message}`);
4520
4851
  }
4521
4852
  }
4853
+ const resolvedApiUrl2 = process.env.FLOOM_API_URL ?? auth2?.apiUrl ?? DEFAULT_API_URL;
4522
4854
  const checks2 = [
4523
4855
  pass("cli_version", VERSION),
4524
4856
  authCheck2,
4525
4857
  process.env.FLOOM_API_URL ? apiUrlCheck(process.env.FLOOM_API_URL) : isLegacyApiUrl(rawAuth?.apiUrl) ? warn("auth_api_url", `legacy URL in ~/.floom/auth.json; using ${DEFAULT_API_URL}`) : apiUrlCheck(auth2?.apiUrl ?? DEFAULT_API_URL)
4526
4858
  ];
4859
+ checks2.push(await probeApiHealth(resolvedApiUrl2));
4527
4860
  emitDoctor(checks2, opts.json);
4528
4861
  if (checks2.some((check) => !check.ok)) process.exit(1);
4529
4862
  return;
@@ -4531,18 +4864,20 @@ async function doctorCommand(opts = {}) {
4531
4864
  const checks = [];
4532
4865
  const auth = await readAuth();
4533
4866
  const token = process.env.FLOOM_API_TOKEN?.trim() || auth?.token;
4867
+ const resolvedApiUrl = process.env.FLOOM_API_URL ?? auth?.apiUrl ?? DEFAULT_API_URL;
4868
+ checks.push(await probeApiHealth(resolvedApiUrl));
4534
4869
  const authCheck = await validateCurrentToken(token);
4535
4870
  const hasValidToken = authCheck.ok && authCheck.status !== "warn" && Boolean(token);
4536
4871
  checks.push(authCheck);
4537
- const cliPath = process.argv[1];
4872
+ const cliPath = fileURLToPath(import.meta.url);
4538
4873
  if (!cliPath) {
4539
- checks.push(fail("fresh_agent_cli_path", "process.argv[1] is empty"));
4874
+ checks.push(fail("fresh_agent_cli_path", "current CLI module path is empty"));
4540
4875
  emitDoctor(checks, opts.json);
4541
4876
  process.exit(1);
4542
4877
  }
4543
- const tmpHome = await mkdtemp2(join14(tmpdir4(), "floom-doctor-home-"));
4544
- const tmpSkills = await mkdtemp2(join14(tmpdir4(), "floom-doctor-skills-"));
4545
- const tmpProject = await mkdtemp2(join14(tmpdir4(), "floom-doctor-project-"));
4878
+ const tmpHome = await mkdtemp2(join15(tmpdir5(), "floom-doctor-home-"));
4879
+ const tmpSkills = await mkdtemp2(join15(tmpdir5(), "floom-doctor-skills-"));
4880
+ const tmpProject = await mkdtemp2(join15(tmpdir5(), "floom-doctor-project-"));
4546
4881
  const transport = new StdioClientTransport({
4547
4882
  command: process.execPath,
4548
4883
  args: [cliPath, "mcp"],
@@ -4582,7 +4917,7 @@ async function doctorCommand(opts = {}) {
4582
4917
  const skill = await client.callTool({ name: "get_skill", arguments: { ref: opts.ref } });
4583
4918
  checks.push(/SKILL\.md/.test(textOf(skill)) ? pass("mcp_get_skill", opts.ref) : fail("mcp_get_skill", "bundle did not include SKILL.md"));
4584
4919
  const installed = await client.callTool({ name: "install_skill", arguments: { ref: opts.ref, target: opts.target ?? "codex" } });
4585
- const entries = await readdir5(tmpSkills);
4920
+ const entries = await readdir6(tmpSkills);
4586
4921
  checks.push(entries.length > 0 ? pass("mcp_install_skill", textOf(installed)) : fail("mcp_install_skill", "target directory is empty"));
4587
4922
  } else {
4588
4923
  checks.push(warn("mcp_public_skill", "skipped; pass --ref <public-skill-ref> to verify get_skill/install_skill against a known public skill"));
@@ -4622,8 +4957,8 @@ program.command("whoami").description("Show the logged-in user.").action(whoamiC
4622
4957
  program.command("init").description("Scaffold a new skill in the current directory.").action(initCommand);
4623
4958
  program.command("validate").description("Validate the skill in the current directory.").option("--json", "Emit machine-readable JSON").action((opts) => validateCommand(opts));
4624
4959
  program.command("publish").description("Publish the skill in the current directory.").option("--dry-run", "Validate and pack locally without uploading.").option("--workspace <slug>", "Publish into a shared workspace slug (default: personal)").option("--library <slug>", "Legacy alias for --workspace").addHelpText("after", "\nExamples:\n $ floom publish\n $ floom publish --workspace team-workspace").action((opts) => publishCommand(opts));
4625
- program.command("push").description("Alias for `publish`.").option("--dry-run", "Validate and pack locally without uploading.").option("--workspace <slug>", "Publish into a shared workspace slug").option("--library <slug>", "Legacy alias for --workspace").addHelpText("after", "\nExample:\n $ floom push --workspace team-workspace").action((opts) => publishCommand(opts));
4626
- program.command("install <ref>").description("Install a skill (default: .agents/skills/<slug>/).").option("--for <target>", "Tool preset: claude | codex | cursor | gemini | opencode | kimi | all").option("--to <path>", "Explicit install directory").option("--global", "Install to user-level folder instead of project-local").option("--force", "Overwrite existing folder").addHelpText("after", "\nExamples:\n $ floom install @alice/research-brief\n $ floom install @alice/research-brief --for codex\n $ floom install @alice/research-brief --to .agents/skills").action((ref, opts) => installCommand(ref, opts));
4960
+ program.command("push [dir]").description("Publish one skill folder or every immediate child skill folder.").option("--dry-run", "Validate and pack locally without uploading.").option("--workspace <slug>", "Publish into a shared workspace slug").option("--library <slug>", "Legacy alias for --workspace").option("--concurrency <n>", "Bulk push concurrency, 1-16", "6").addHelpText("after", "\nExamples:\n $ floom push\n $ floom push ./skills --workspace team-workspace --concurrency 4").action((dir, opts) => pushCommand(dir ?? ".", opts));
4961
+ program.command("install <ref>").description("Install a skill (default: .agents/skills/<slug>/).").option("--for <target>", "Tool preset: claude | codex | cursor | gemini | opencode | kimi | all").option("--to <path>", "Parent directory; installs to <path>/<skill-slug>/ (not the skill folder itself)").option("--global", "Install to user-level folder instead of project-local").option("--force", "Overwrite existing folder").addHelpText("after", "\nExamples:\n $ floom install @alice/research-brief\n $ floom install @alice/research-brief --for codex\n $ floom install @alice/research-brief --to .agents/skills\n\nNote: --to is the parent folder. The skill lands in .agents/skills/research-brief/, not directly in .agents/skills/.").action((ref, opts) => installCommand(ref, opts));
4627
4962
  program.command("installed").description("List installed skills in this project.").option("--json").action(installedCommand);
4628
4963
  program.command("outdated").description("Show installed skills with newer versions available.").action(outdatedCommand);
4629
4964
  program.command("update [ref]").description("Update installed skills to latest.").option("--force", "Overwrite local edits").action((ref, opts) => updateCommand(ref, opts));
@@ -4636,6 +4971,8 @@ program.command("pinned").alias("pins").description("List workspace skills pinne
4636
4971
  program.command("unpin <ref>").description("Unpin a workspace skill for local pull").option("--workspace <slug>", "Workspace slug").option("--target <target>", "claude | codex | cursor | kimi | opencode", "codex").action((ref, opts) => unpinCommand(ref, opts));
4637
4972
  program.command("share <ref> <email>").description("Invite someone to a skill by email.").option("--role <role>", "viewer (default) or editor").action((ref, email, opts) => shareCommand(ref, email, opts));
4638
4973
  program.command("unshare <ref> <email>").description("Revoke someone's access.").action((ref, email) => unshareCommand(ref, email));
4974
+ var linkCmd = program.command("link").description("Create opaque share links for unlisted/public skills");
4975
+ linkCmd.command("create <ref>").description("Create a share link URL for a skill.").option("--name <name>", "Optional link label").option("--role <role>", "viewer (default) or editor").action((ref, opts) => linkCreateCommand(ref, opts));
4639
4976
  var configCmd = program.command("config").description("Manage local Floom configuration");
4640
4977
  configCmd.command("default-workspace [slug]").description("Show or set the default workspace").option("--scope <scope>", "global | local", "local").action((slug, opts) => defaultWorkspaceCommand(slug, opts));
4641
4978
  function addWorkspaceCommands(cmd) {
@@ -4694,4 +5031,3 @@ bcryptjs/dist/bcrypt.js:
4694
5031
  * see: https://github.com/dcodeIO/bcrypt.js for details
4695
5032
  *)
4696
5033
  */
4697
- //# sourceMappingURL=index.js.map