@floomhq/skills 0.2.12 → 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.12";
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,6 +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
+ }
3101
+ function buildAuthenticatedSkillUrl(appUrl, librarySlug, skillSlug) {
3102
+ const base = trimAppUrl(appUrl);
3103
+ return `${base}/library/${encodeURIComponent(skillSlug)}?lib=${encodeURIComponent(librarySlug)}`;
3104
+ }
3105
+ function buildPublicSkillUrl(appUrl, handle, librarySlug, skillSlug) {
3106
+ const base = trimAppUrl(appUrl);
3107
+ if (librarySlug !== handle) return null;
3108
+ return `${base}/@${handle}/${skillSlug}`;
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
2982
3142
  async function publishCommand(opts = {}) {
2983
3143
  const auth = await readAuth();
2984
3144
  const envToken = process.env.FLOOM_API_TOKEN?.trim();
@@ -2986,7 +3146,7 @@ async function publishCommand(opts = {}) {
2986
3146
  log.err("Not logged in. Run: floom login");
2987
3147
  process.exit(1);
2988
3148
  }
2989
- const dir = process.cwd();
3149
+ const dir = opts.dir ?? process.cwd();
2990
3150
  log.heading("Validating skill...");
2991
3151
  const report = await validateSkill(dir);
2992
3152
  if (!report.ok) {
@@ -3061,26 +3221,126 @@ async function publishCommand(opts = {}) {
3061
3221
  log.blank();
3062
3222
  log.ok(`Published ${complete.ref}`);
3063
3223
  log.blank();
3064
- log.info("View:");
3065
3224
  const displayApiUrl = trustedApiUrlOrDefault(process.env.FLOOM_API_URL ?? auth?.apiUrl ?? DEFAULT_API_URL);
3066
- log.kv("", `${displayApiUrl.replace("/api/v1", "")}/@${handle}/${manifest.name}`);
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}`);
3249
+ }
3067
3250
  log.info("Install:");
3068
3251
  log.kv("", complete.install_command);
3069
3252
  }
3070
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
+
3071
3331
  // src/commands/install.ts
3072
- import { mkdir as mkdir4, readdir as readdir3, rm, rename } from "node:fs/promises";
3073
- import { join as join8 } from "node:path";
3074
- 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";
3075
3335
 
3076
3336
  // src/lib/floom-lock.ts
3077
- import { readFile as readFile7, writeFile as writeFile3, mkdir as mkdir3, stat as stat3, readdir as readdir2 } from "node:fs/promises";
3078
- 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";
3079
3339
  import { createHash as createHash3 } from "node:crypto";
3080
3340
  var EMPTY = { schema_version: "0.1", skills: {} };
3081
3341
  async function readLock(projectDir) {
3082
3342
  try {
3083
- const raw = await readFile7(join7(projectDir, "floom.lock"), "utf8");
3343
+ const raw = await readFile7(join8(projectDir, "floom.lock"), "utf8");
3084
3344
  const parsed = JSON.parse(raw);
3085
3345
  if (parsed.schema_version === "0.1") return parsed;
3086
3346
  if (parsed.schema_version === "0.2") return parsed;
@@ -3095,7 +3355,7 @@ async function readLock(projectDir) {
3095
3355
  }
3096
3356
  async function writeLock(projectDir, lock) {
3097
3357
  await mkdir3(projectDir, { recursive: true });
3098
- 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");
3099
3359
  }
3100
3360
  function setLockEntry(lock, ref, entry) {
3101
3361
  return { ...lock, skills: { ...lock.skills, [ref]: entry } };
@@ -3125,12 +3385,12 @@ async function hashInstalledFolder(folderAbs) {
3125
3385
  async function walk2(dir) {
3126
3386
  let entries;
3127
3387
  try {
3128
- entries = await readdir2(dir, { withFileTypes: true });
3388
+ entries = await readdir3(dir, { withFileTypes: true });
3129
3389
  } catch {
3130
3390
  return;
3131
3391
  }
3132
3392
  for (const entry of entries) {
3133
- const abs = join7(dir, entry.name);
3393
+ const abs = join8(dir, entry.name);
3134
3394
  if (entry.isSymbolicLink()) continue;
3135
3395
  if (entry.isDirectory()) {
3136
3396
  await walk2(abs);
@@ -3138,7 +3398,7 @@ async function hashInstalledFolder(folderAbs) {
3138
3398
  }
3139
3399
  if (!entry.isFile()) continue;
3140
3400
  const buf = await readFile7(abs);
3141
- const rel = relative2(folderAbs, abs).split(sep2).join(posix2.sep);
3401
+ const rel = relative3(folderAbs, abs).split(sep2).join(posix2.sep);
3142
3402
  out.push({
3143
3403
  relPath: rel,
3144
3404
  sha256: createHash3("sha256").update(buf).digest("hex"),
@@ -3147,7 +3407,7 @@ async function hashInstalledFolder(folderAbs) {
3147
3407
  }
3148
3408
  }
3149
3409
  try {
3150
- await stat3(folderAbs);
3410
+ await stat4(folderAbs);
3151
3411
  } catch {
3152
3412
  return out;
3153
3413
  }
@@ -3156,6 +3416,20 @@ async function hashInstalledFolder(folderAbs) {
3156
3416
  return out;
3157
3417
  }
3158
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
+
3159
3433
  // src/commands/install.ts
3160
3434
  var CLI_VERSION = VERSION;
3161
3435
  function semverGte(a, b) {
@@ -3209,13 +3483,16 @@ async function installCommand(refStr, opts = {}) {
3209
3483
  global: opts.global
3210
3484
  });
3211
3485
  const projectDir = opts.global ? process.cwd() : process.cwd();
3212
- 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");
3213
3490
  const lock = await readLock(projectDir);
3214
3491
  const refKey = formatSkillRef({ owner: ref.owner, slug: ref.slug });
3215
3492
  const existing = lock.skills[refKey];
3216
3493
  let folderExists = false;
3217
3494
  try {
3218
- const entries = await readdir3(destFolder);
3495
+ const entries = await readdir4(destFolder);
3219
3496
  folderExists = entries.length > 0;
3220
3497
  } catch {
3221
3498
  }
@@ -3245,7 +3522,7 @@ async function installCommand(refStr, opts = {}) {
3245
3522
  }
3246
3523
  log.ok(`Bundle verified (${(buf.length / 1024).toFixed(1)} KB).`);
3247
3524
  const tmp = await import("node:fs/promises").then(
3248
- (m) => m.mkdtemp(join8(tmpdir(), `floom-install-${ref.slug}-`))
3525
+ (m) => m.mkdtemp(join9(tmpdir2(), `floom-install-${ref.slug}-`))
3249
3526
  );
3250
3527
  try {
3251
3528
  await extractBundle(buf, tmp);
@@ -3348,8 +3625,8 @@ async function outdatedCommand() {
3348
3625
 
3349
3626
  // src/commands/update.ts
3350
3627
  import { rm as rm2, rename as rename2, mkdir as mkdir5 } from "node:fs/promises";
3351
- import { tmpdir as tmpdir2 } from "node:os";
3352
- 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";
3353
3630
  function cmpSemver2(a, b) {
3354
3631
  const pa = a.split(".").map((p) => parseInt(p, 10));
3355
3632
  const pb = b.split(".").map((p) => parseInt(p, 10));
@@ -3388,7 +3665,8 @@ async function updateCommand(refStr, opts = {}) {
3388
3665
  log.step(`${ref} is already at latest (${entry.version}).`);
3389
3666
  continue;
3390
3667
  }
3391
- const installDir = resolve2(projectDir, entry.path);
3668
+ const installDir = resolve4(projectDir, entry.path);
3669
+ await assertPathIsNotSymlink(installDir, "installed skill directory");
3392
3670
  if (!opts.force) {
3393
3671
  const current = await hashInstalledFolder(installDir);
3394
3672
  log.step(`Updating ${ref}: ${entry.version} -> ${info.latest_version}`);
@@ -3401,7 +3679,7 @@ async function updateCommand(refStr, opts = {}) {
3401
3679
  continue;
3402
3680
  }
3403
3681
  const tmp = await import("node:fs/promises").then(
3404
- (m) => m.mkdtemp(join9(tmpdir2(), `floom-update-${slug}-`))
3682
+ (m) => m.mkdtemp(join10(tmpdir3(), `floom-update-${slug}-`))
3405
3683
  );
3406
3684
  try {
3407
3685
  await extractBundle(buf, tmp);
@@ -3528,6 +3806,26 @@ async function unshareCommand(refStr, email) {
3528
3806
  log.ok(`Removed ${email}'s access to ${refStr}.`);
3529
3807
  }
3530
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
+
3531
3829
  // src/commands/library.ts
3532
3830
  function isValidEmail2(email) {
3533
3831
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
@@ -3597,7 +3895,7 @@ async function workspaceAcceptCommand(tokenOrUrl) {
3597
3895
  // src/lib/config-file.ts
3598
3896
  import { mkdir as mkdir6, readFile as readFile8, writeFile as writeFile4 } from "node:fs/promises";
3599
3897
  import { homedir as homedir3 } from "node:os";
3600
- import { dirname, join as join10 } from "node:path";
3898
+ import { dirname as dirname2, join as join11 } from "node:path";
3601
3899
  import { z as z2 } from "zod";
3602
3900
  var FLOOM_CONFIG_VERSION = "0.1";
3603
3901
  var TARGETS = ["claude", "codex", "cursor", "kimi", "opencode"];
@@ -3611,8 +3909,8 @@ var configSchema = z2.object({
3611
3909
  targets: z2.record(z2.enum(TARGETS), targetConfigSchema).default({})
3612
3910
  });
3613
3911
  function configPath(scope, cwd = process.cwd()) {
3614
- if (scope === "global") return join10(homedir3(), ".floom", "config.json");
3615
- return join10(cwd, ".floom", "config.json");
3912
+ if (scope === "global") return join11(homedir3(), ".floom", "config.json");
3913
+ return join11(cwd, ".floom", "config.json");
3616
3914
  }
3617
3915
  async function readFloomConfig(scope, cwd = process.cwd()) {
3618
3916
  try {
@@ -3626,7 +3924,7 @@ async function readFloomConfig(scope, cwd = process.cwd()) {
3626
3924
  async function writeFloomConfig(scope, config, cwd = process.cwd()) {
3627
3925
  const path = configPath(scope, cwd);
3628
3926
  const parsed = configSchema.parse(config);
3629
- await mkdir6(dirname(path), { recursive: true, mode: 448 });
3927
+ await mkdir6(dirname2(path), { recursive: true, mode: 448 });
3630
3928
  await writeFile4(path, JSON.stringify(parsed, null, 2) + "\n", { mode: 384 });
3631
3929
  }
3632
3930
  function normalizeScope(value) {
@@ -3664,7 +3962,7 @@ async function setWorkspaceActive(input) {
3664
3962
 
3665
3963
  // src/commands/instruction.ts
3666
3964
  import { mkdir as mkdir7, readFile as readFile9, writeFile as writeFile5 } from "node:fs/promises";
3667
- 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";
3668
3966
  import { createHash as createHash4 } from "node:crypto";
3669
3967
  var START = "<!-- FLOOM START -->";
3670
3968
  var END = "<!-- FLOOM END -->";
@@ -3749,7 +4047,7 @@ async function writeManagedInstructionFile(input) {
3749
4047
  else throw e;
3750
4048
  }
3751
4049
  const next = replaceManagedBlock(existing, input.blockBody);
3752
- if (existed && !next.hadBlock && !input.apply && !input.force) {
4050
+ if (existed && !next.hadBlock && !input.apply) {
3753
4051
  log.warn(`Refusing to modify ${input.path} without an existing Floom managed block.`);
3754
4052
  log.info("Re-run with --apply to append the managed block, or --path <file> for a dedicated file.");
3755
4053
  process.exit(1);
@@ -3758,11 +4056,14 @@ async function writeManagedInstructionFile(input) {
3758
4056
  let backupPath;
3759
4057
  if (existed && existing) {
3760
4058
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
3761
- backupPath = join11(".floom", "backups", `${basename2(input.path)}.${stamp}.bak`);
3762
- 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 });
3763
4063
  await writeFile5(backupPath, existing, { mode: 384 });
3764
4064
  }
3765
- await mkdir7(dirname2(input.path), { recursive: true });
4065
+ await mkdir7(dirname3(input.path), { recursive: true });
4066
+ await assertPathIsNotSymlink(input.path, "instruction file");
3766
4067
  await writeFile5(input.path, next.content, "utf8");
3767
4068
  return { changed: true, backupPath };
3768
4069
  }
@@ -3915,7 +4216,7 @@ async function workspaceActiveCommand(opts = {}) {
3915
4216
 
3916
4217
  // src/commands/sync.ts
3917
4218
  import { mkdir as mkdir8, writeFile as writeFile6 } from "node:fs/promises";
3918
- import { dirname as dirname3, join as join12 } from "node:path";
4219
+ import { dirname as dirname4, join as join13 } from "node:path";
3919
4220
  var ROUTER_SKILL = [
3920
4221
  "# Floom Find Skills",
3921
4222
  "",
@@ -3934,8 +4235,8 @@ var ROUTER_SKILL = [
3934
4235
  async function installRouter(target) {
3935
4236
  const install = resolveInstallDir({ target });
3936
4237
  const routerDir = target === "kimi" ? "floom" : "floom-find-skills";
3937
- const path = join12(install.dir, routerDir, "SKILL.md");
3938
- await mkdir8(dirname3(path), { recursive: true });
4238
+ const path = join13(install.dir, routerDir, "SKILL.md");
4239
+ await mkdir8(dirname4(path), { recursive: true });
3939
4240
  await writeFile6(path, ROUTER_SKILL, "utf8");
3940
4241
  return path;
3941
4242
  }
@@ -4203,13 +4504,14 @@ async function folderRevokeCommand(workspace, folderId, memberId) {
4203
4504
  }
4204
4505
 
4205
4506
  // src/commands/mcp.ts
4206
- import { mkdtemp, mkdir as mkdir9, readdir as readdir4, readFile as readFile10, rename as rename3, rm as rm3, writeFile as writeFile7 } from "node:fs/promises";
4207
- import { join as join13 } from "node:path";
4208
- 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";
4209
4510
  import { z as z3 } from "zod";
4210
4511
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4211
4512
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4212
4513
  var API_TIMEOUT_MS = 2e4;
4514
+ var MAX_ERROR_BODY_BYTES2 = 64 * 1024;
4213
4515
  function semverGte2(a, b) {
4214
4516
  const pa = a.split(".").map(Number);
4215
4517
  const pb = b.split(".").map(Number);
@@ -4231,6 +4533,30 @@ async function fetchWithTimeout2(url, init = {}) {
4231
4533
  clearTimeout(timer);
4232
4534
  }
4233
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
+ }
4234
4560
  async function resolveOptionalToken() {
4235
4561
  const fromEnv = process.env.FLOOM_API_TOKEN?.trim();
4236
4562
  if (fromEnv) return fromEnv;
@@ -4263,19 +4589,21 @@ async function apiRequest(token, path, query) {
4263
4589
  lastError = new Error(`Unable to reach Floom API at ${base}: ${e.message}`);
4264
4590
  continue;
4265
4591
  }
4266
- if (res.status >= 300 && res.status < 400) {
4267
- const location = res.headers.get("location");
4268
- const redirectHost2 = location ? new URL(location, url).host : "unknown";
4269
- 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.");
4270
4594
  break;
4271
4595
  }
4272
- const body = await res.text();
4596
+ const body = res.ok ? await res.text() : await readLimitedText2(res);
4273
4597
  let json = null;
4274
4598
  try {
4275
4599
  json = body ? JSON.parse(body) : null;
4276
4600
  } catch {
4277
4601
  json = { raw: body };
4278
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
+ }
4279
4607
  if (res.ok) return json;
4280
4608
  lastError = new Error(json?.error?.message ?? `HTTP ${res.status}`);
4281
4609
  if (res.status !== 404 || json?.error) break;
@@ -4294,14 +4622,16 @@ async function installViaApi(token, refText, target, options = {}) {
4294
4622
  const bundle = await rawGet(dl.download.url);
4295
4623
  if (!verifyBundleHash(bundle, dl.bundle_sha256)) throw new Error("Bundle hash mismatch");
4296
4624
  const install = resolveInstallDir({ target });
4625
+ await assertPathIsNotSymlink(install.dir, "install directory");
4297
4626
  await mkdir9(install.dir, { recursive: true });
4298
- const dest = join13(install.dir, parsed.slug);
4299
- 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);
4300
4630
  if (exists && !options.force) {
4301
4631
  throw new Error(`Folder already exists at ${dest}. Call install_skill with force=true to overwrite after reviewing local changes.`);
4302
4632
  }
4303
4633
  if (exists) await rm3(dest, { recursive: true, force: true });
4304
- const temp = await mkdtemp(join13(tmpdir3(), `floom-mcp-${parsed.slug}-`));
4634
+ const temp = await mkdtemp(join14(tmpdir4(), `floom-mcp-${parsed.slug}-`));
4305
4635
  try {
4306
4636
  await extractBundle(bundle, temp);
4307
4637
  await rename3(temp, dest);
@@ -4324,24 +4654,24 @@ async function installViaApi(token, refText, target, options = {}) {
4324
4654
  return { path: dest, version: dl.version, ref: info.ref ?? ref, has_scripts: !!dl.has_scripts };
4325
4655
  }
4326
4656
  async function parseSkillBundle(bundle) {
4327
- const tmp = await mkdtemp(join13(tmpdir3(), "floom-mcp-read-"));
4657
+ const tmp = await mkdtemp(join14(tmpdir4(), "floom-mcp-read-"));
4328
4658
  try {
4329
4659
  await extractBundle(bundle, tmp);
4330
4660
  const files = [];
4331
4661
  const walk2 = async (dir, rel = "") => {
4332
- const entries = await readdir4(dir, { withFileTypes: true });
4662
+ const entries = await readdir5(dir, { withFileTypes: true });
4333
4663
  for (const entry of entries) {
4334
4664
  const nextRel = rel ? `${rel}/${entry.name}` : entry.name;
4335
- const full = join13(dir, entry.name);
4665
+ const full = join14(dir, entry.name);
4336
4666
  if (entry.isDirectory()) await walk2(full, nextRel);
4337
4667
  else files.push(nextRel);
4338
4668
  }
4339
4669
  };
4340
4670
  await walk2(tmp);
4341
4671
  const skillMdPath = files.find((f) => f.toUpperCase() === "SKILL.MD");
4342
- const skillMd = skillMdPath ? await readFile10(join13(tmp, skillMdPath), "utf8") : "";
4672
+ const skillMd = skillMdPath ? await readFile10(join14(tmp, skillMdPath), "utf8") : "";
4343
4673
  const skillJsonPath = files.find((f) => f.toLowerCase() === "skill.json");
4344
- 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;
4345
4675
  return { files, skill_md: skillMd, skill_json: skillJson };
4346
4676
  } finally {
4347
4677
  await rm3(tmp, { recursive: true, force: true });
@@ -4434,11 +4764,29 @@ async function mcpCommand() {
4434
4764
  }
4435
4765
 
4436
4766
  // src/commands/doctor.ts
4437
- import { mkdtemp as mkdtemp2, readdir as readdir5, rm as rm4 } from "node:fs/promises";
4438
- import { tmpdir as tmpdir4 } from "node:os";
4439
- 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";
4440
4771
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
4441
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
4442
4790
  function textOf(result) {
4443
4791
  return String(result?.content?.[0]?.text ?? "");
4444
4792
  }
@@ -4502,11 +4850,13 @@ async function doctorCommand(opts = {}) {
4502
4850
  authCheck2 = fail("auth", `saved login rejected by API: ${e.message}`);
4503
4851
  }
4504
4852
  }
4853
+ const resolvedApiUrl2 = process.env.FLOOM_API_URL ?? auth2?.apiUrl ?? DEFAULT_API_URL;
4505
4854
  const checks2 = [
4506
4855
  pass("cli_version", VERSION),
4507
4856
  authCheck2,
4508
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)
4509
4858
  ];
4859
+ checks2.push(await probeApiHealth(resolvedApiUrl2));
4510
4860
  emitDoctor(checks2, opts.json);
4511
4861
  if (checks2.some((check) => !check.ok)) process.exit(1);
4512
4862
  return;
@@ -4514,18 +4864,20 @@ async function doctorCommand(opts = {}) {
4514
4864
  const checks = [];
4515
4865
  const auth = await readAuth();
4516
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));
4517
4869
  const authCheck = await validateCurrentToken(token);
4518
4870
  const hasValidToken = authCheck.ok && authCheck.status !== "warn" && Boolean(token);
4519
4871
  checks.push(authCheck);
4520
- const cliPath = process.argv[1];
4872
+ const cliPath = fileURLToPath(import.meta.url);
4521
4873
  if (!cliPath) {
4522
- 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"));
4523
4875
  emitDoctor(checks, opts.json);
4524
4876
  process.exit(1);
4525
4877
  }
4526
- const tmpHome = await mkdtemp2(join14(tmpdir4(), "floom-doctor-home-"));
4527
- const tmpSkills = await mkdtemp2(join14(tmpdir4(), "floom-doctor-skills-"));
4528
- 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-"));
4529
4881
  const transport = new StdioClientTransport({
4530
4882
  command: process.execPath,
4531
4883
  args: [cliPath, "mcp"],
@@ -4565,7 +4917,7 @@ async function doctorCommand(opts = {}) {
4565
4917
  const skill = await client.callTool({ name: "get_skill", arguments: { ref: opts.ref } });
4566
4918
  checks.push(/SKILL\.md/.test(textOf(skill)) ? pass("mcp_get_skill", opts.ref) : fail("mcp_get_skill", "bundle did not include SKILL.md"));
4567
4919
  const installed = await client.callTool({ name: "install_skill", arguments: { ref: opts.ref, target: opts.target ?? "codex" } });
4568
- const entries = await readdir5(tmpSkills);
4920
+ const entries = await readdir6(tmpSkills);
4569
4921
  checks.push(entries.length > 0 ? pass("mcp_install_skill", textOf(installed)) : fail("mcp_install_skill", "target directory is empty"));
4570
4922
  } else {
4571
4923
  checks.push(warn("mcp_public_skill", "skipped; pass --ref <public-skill-ref> to verify get_skill/install_skill against a known public skill"));
@@ -4605,8 +4957,8 @@ program.command("whoami").description("Show the logged-in user.").action(whoamiC
4605
4957
  program.command("init").description("Scaffold a new skill in the current directory.").action(initCommand);
4606
4958
  program.command("validate").description("Validate the skill in the current directory.").option("--json", "Emit machine-readable JSON").action((opts) => validateCommand(opts));
4607
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));
4608
- 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));
4609
- 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));
4610
4962
  program.command("installed").description("List installed skills in this project.").option("--json").action(installedCommand);
4611
4963
  program.command("outdated").description("Show installed skills with newer versions available.").action(outdatedCommand);
4612
4964
  program.command("update [ref]").description("Update installed skills to latest.").option("--force", "Overwrite local edits").action((ref, opts) => updateCommand(ref, opts));
@@ -4619,6 +4971,8 @@ program.command("pinned").alias("pins").description("List workspace skills pinne
4619
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));
4620
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));
4621
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));
4622
4976
  var configCmd = program.command("config").description("Manage local Floom configuration");
4623
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));
4624
4978
  function addWorkspaceCommands(cmd) {
@@ -4677,4 +5031,3 @@ bcryptjs/dist/bcrypt.js:
4677
5031
  * see: https://github.com/dcodeIO/bcrypt.js for details
4678
5032
  *)
4679
5033
  */
4680
- //# sourceMappingURL=index.js.map