@floomhq/skills 0.2.9 → 0.2.10

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
@@ -2014,6 +2014,19 @@ var scrypt = promisify(scryptCb);
2014
2014
  // ../shared/src/install-targets.ts
2015
2015
  import { homedir } from "node:os";
2016
2016
  import { join } from "node:path";
2017
+ var INSTALL_TARGETS = [
2018
+ "generic",
2019
+ "all",
2020
+ "claude",
2021
+ "codex",
2022
+ "cursor",
2023
+ "gemini",
2024
+ "opencode",
2025
+ "kimi"
2026
+ ];
2027
+ function isInstallTarget(value) {
2028
+ return !!value && INSTALL_TARGETS.includes(value);
2029
+ }
2017
2030
  var COMPATIBLE_AGENTS = {
2018
2031
  generic: ["Claude Code", "Codex CLI", "Cursor", "Gemini CLI", "OpenCode", "Kimi CLI"],
2019
2032
  all: ["Claude Code", "Codex CLI", "Cursor", "Gemini CLI", "OpenCode", "Kimi CLI"],
@@ -2061,24 +2074,24 @@ function presetDir(target, opts) {
2061
2074
  }
2062
2075
  }
2063
2076
  function resolveInstallDir(args) {
2077
+ const target = args.target ?? "generic";
2064
2078
  if (args.to) {
2065
2079
  return {
2066
- target: args.target ?? "generic",
2080
+ target,
2067
2081
  dir: args.to,
2068
2082
  origin: "explicit",
2069
- compatibleAgents: COMPATIBLE_AGENTS[args.target ?? "generic"]
2083
+ compatibleAgents: COMPATIBLE_AGENTS[target]
2070
2084
  };
2071
2085
  }
2072
- const targetEnvDir = envDirForTarget(args.target ?? "generic");
2086
+ const targetEnvDir = envDirForTarget(target);
2073
2087
  if (targetEnvDir) {
2074
2088
  return {
2075
- target: args.target ?? "generic",
2089
+ target,
2076
2090
  dir: targetEnvDir,
2077
2091
  origin: "env",
2078
- compatibleAgents: COMPATIBLE_AGENTS[args.target ?? "generic"]
2092
+ compatibleAgents: COMPATIBLE_AGENTS[target]
2079
2093
  };
2080
2094
  }
2081
- const target = args.target ?? "generic";
2082
2095
  return {
2083
2096
  target,
2084
2097
  dir: presetDir(target, { global: args.global, cwd: args.cwd }),
@@ -2381,7 +2394,8 @@ var CONFIG_DIR = join3(homedir2(), ".floom");
2381
2394
  var AUTH_FILE = join3(CONFIG_DIR, "auth.json");
2382
2395
  var DEFAULT_APP_URL = "https://skills.floom.dev";
2383
2396
  var DEFAULT_API_URL = "https://skills.floom.dev/api/v1";
2384
- var LEGACY_API_HOSTS = /* @__PURE__ */ new Set(["floom-v0.vercel.app"]);
2397
+ var LEGACY_API_HOSTS = /* @__PURE__ */ new Set(["floom-v0.vercel.app", "skills.wasm.floom.dev"]);
2398
+ var TRUSTED_API_HOSTS = /* @__PURE__ */ new Set(["skills.floom.dev", "skills.wasm.floom.dev", "localhost", "127.0.0.1", "::1"]);
2385
2399
  async function ensureDir() {
2386
2400
  await mkdir2(CONFIG_DIR, { recursive: true, mode: 448 });
2387
2401
  }
@@ -2395,12 +2409,15 @@ async function readRawAuth() {
2395
2409
  return JSON.parse(raw);
2396
2410
  } catch (e) {
2397
2411
  if (e.code === "ENOENT") return null;
2412
+ if (e instanceof SyntaxError) {
2413
+ throw new FloomError("AUTH_REQUIRED", "Invalid ~/.floom/auth.json. Run: floom login to refresh local auth.");
2414
+ }
2398
2415
  throw e;
2399
2416
  }
2400
2417
  }
2401
2418
  async function writeAuth(state) {
2402
2419
  await ensureDir();
2403
- await writeFile(AUTH_FILE, JSON.stringify({ ...state, apiUrl: normalizeApiUrl(state.apiUrl) }, null, 2), { mode: 384 });
2420
+ await writeFile(AUTH_FILE, JSON.stringify({ ...state, apiUrl: trustedApiUrlOrDefault(state.apiUrl) }, null, 2), { mode: 384 });
2404
2421
  try {
2405
2422
  await chmod(AUTH_FILE, 384);
2406
2423
  } catch {
@@ -2423,14 +2440,16 @@ function getAppUrl() {
2423
2440
  }
2424
2441
  function getApiBaseUrls(preferred) {
2425
2442
  const explicitApiUrl = process.env.FLOOM_API_URL?.trim();
2426
- if (explicitApiUrl) return [normalizeApiUrl(explicitApiUrl)];
2427
- const primary = normalizeApiUrl(preferred ?? getApiUrl());
2443
+ if (explicitApiUrl) return [trustedApiUrlOrDefault(explicitApiUrl)];
2444
+ const primary = trustedApiUrlOrDefault(preferred ?? getApiUrl());
2428
2445
  const bases = [primary];
2429
2446
  if (preferred && !process.env.FLOOM_APP_URL) bases.push(DEFAULT_API_URL);
2430
2447
  return Array.from(new Set(bases));
2431
2448
  }
2432
2449
  function normalizeApiUrl(apiUrl) {
2433
- const trimmed = apiUrl.replace(/\/$/, "");
2450
+ if (typeof apiUrl !== "string") return DEFAULT_API_URL;
2451
+ const trimmed = apiUrl.trim().replace(/\/$/, "");
2452
+ if (!trimmed) return DEFAULT_API_URL;
2434
2453
  try {
2435
2454
  const url = new URL(trimmed);
2436
2455
  if (LEGACY_API_HOSTS.has(url.hostname)) return DEFAULT_API_URL;
@@ -2439,6 +2458,22 @@ function normalizeApiUrl(apiUrl) {
2439
2458
  }
2440
2459
  return trimmed;
2441
2460
  }
2461
+ function allowsCustomApiUrl() {
2462
+ const raw = process.env.FLOOM_ALLOW_CUSTOM_API_URL?.trim().toLowerCase();
2463
+ return raw === "1" || raw === "true" || raw === "yes";
2464
+ }
2465
+ function isTrustedApiUrl(apiUrl) {
2466
+ try {
2467
+ const url = new URL(apiUrl);
2468
+ return TRUSTED_API_HOSTS.has(url.hostname) || allowsCustomApiUrl();
2469
+ } catch {
2470
+ return false;
2471
+ }
2472
+ }
2473
+ function trustedApiUrlOrDefault(apiUrl) {
2474
+ const normalized = normalizeApiUrl(apiUrl);
2475
+ return isTrustedApiUrl(normalized) ? normalized : DEFAULT_API_URL;
2476
+ }
2442
2477
  function isLegacyApiUrl(apiUrl) {
2443
2478
  if (!apiUrl) return false;
2444
2479
  try {
@@ -2449,7 +2484,7 @@ function isLegacyApiUrl(apiUrl) {
2449
2484
  }
2450
2485
 
2451
2486
  // src/version.ts
2452
- var VERSION = "0.2.9";
2487
+ var VERSION = "0.2.10";
2453
2488
 
2454
2489
  // src/api-client.ts
2455
2490
  var DEFAULT_TIMEOUT_MS = 2e4;
@@ -2491,7 +2526,8 @@ async function api(path, opts = {}) {
2491
2526
  "User-Agent": `floom-cli/${VERSION}`,
2492
2527
  "x-floom-cli-version": VERSION
2493
2528
  };
2494
- if (token) headers.Authorization = `Bearer ${token}`;
2529
+ const requestToken = opts.tokenOverride ?? token;
2530
+ if (requestToken) headers.Authorization = `Bearer ${requestToken}`;
2495
2531
  let res;
2496
2532
  try {
2497
2533
  res = await fetchWithTimeout(url.toString(), {
@@ -2588,8 +2624,9 @@ async function loginCommand() {
2588
2624
  const interval = Math.max(2, session.poll_interval_seconds) * 1e3;
2589
2625
  while (Date.now() < deadline) {
2590
2626
  await new Promise((r) => setTimeout(r, interval));
2591
- const pollPath = `/cli/sessions/${session.session_id}?device_code=${encodeURIComponent(session.device_code)}`;
2592
- const poll = await api(pollPath);
2627
+ const poll = await api(`/cli/sessions/${session.session_id}`, {
2628
+ tokenOverride: session.device_code
2629
+ });
2593
2630
  if (poll.status === "approved" && poll.token && poll.handle && poll.email) {
2594
2631
  await writeAuth({
2595
2632
  token: poll.token,
@@ -2958,7 +2995,8 @@ async function publishCommand(opts = {}) {
2958
2995
  log.ok(`Published ${complete.ref}`);
2959
2996
  log.blank();
2960
2997
  log.info("View:");
2961
- log.kv("", `${(auth?.apiUrl ?? process.env.FLOOM_API_URL ?? "https://skills.floom.dev/api/v1").replace("/api/v1", "")}/@${handle}/${manifest.name}`);
2998
+ const displayApiUrl = trustedApiUrlOrDefault(process.env.FLOOM_API_URL ?? auth?.apiUrl ?? DEFAULT_API_URL);
2999
+ log.kv("", `${displayApiUrl.replace("/api/v1", "")}/@${handle}/${manifest.name}`);
2962
3000
  log.info("Install:");
2963
3001
  log.kv("", complete.install_command);
2964
3002
  }
@@ -3069,6 +3107,12 @@ async function installCommand(refStr, opts = {}) {
3069
3107
  log.info("Expected: @owner/slug, workspace-slug/slug, or with @version suffix");
3070
3108
  process.exit(1);
3071
3109
  }
3110
+ if (opts.for && !isInstallTarget(opts.for)) {
3111
+ log.err(`Invalid install target: ${opts.for}`);
3112
+ log.info("Expected: claude | codex | cursor | gemini | opencode | kimi | all");
3113
+ process.exit(1);
3114
+ }
3115
+ const installTarget = isInstallTarget(opts.for) ? opts.for : "generic";
3072
3116
  let info;
3073
3117
  try {
3074
3118
  info = await api(`/skills/${ref.owner}/${ref.slug}`);
@@ -3093,7 +3137,7 @@ async function installCommand(refStr, opts = {}) {
3093
3137
  process.exit(1);
3094
3138
  }
3095
3139
  const target = resolveInstallDir({
3096
- target: opts.for ?? "generic",
3140
+ target: installTarget,
3097
3141
  to: opts.to,
3098
3142
  global: opts.global
3099
3143
  });
@@ -3154,7 +3198,7 @@ async function installCommand(refStr, opts = {}) {
3154
3198
  bundle_sha256: dl.bundle_sha256,
3155
3199
  installed_at: (/* @__PURE__ */ new Date()).toISOString(),
3156
3200
  path: destFolder.replace(projectDir + "/", ""),
3157
- preset: opts.for
3201
+ preset: installTarget
3158
3202
  });
3159
3203
  await writeLock(projectDir, next);
3160
3204
  log.blank();
@@ -3935,6 +3979,15 @@ import { z as z3 } from "zod";
3935
3979
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3936
3980
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3937
3981
  var API_TIMEOUT_MS = 2e4;
3982
+ function semverGte2(a, b) {
3983
+ const pa = a.split(".").map(Number);
3984
+ const pb = b.split(".").map(Number);
3985
+ for (let i = 0; i < 3; i++) {
3986
+ if ((pa[i] ?? 0) > (pb[i] ?? 0)) return true;
3987
+ if ((pa[i] ?? 0) < (pb[i] ?? 0)) return false;
3988
+ }
3989
+ return true;
3990
+ }
3938
3991
  async function fetchWithTimeout2(url, init = {}) {
3939
3992
  const controller = new AbortController();
3940
3993
  const timer = setTimeout(() => controller.abort(), API_TIMEOUT_MS);
@@ -3992,10 +4045,14 @@ async function apiRequest(token, path, query) {
3992
4045
  }
3993
4046
  throw lastError ?? new Error("API request failed");
3994
4047
  }
3995
- async function installViaApi(token, refText, target) {
4048
+ async function installViaApi(token, refText, target, options = {}) {
3996
4049
  const parsed = parseSkillRef(refText);
3997
4050
  if (!parsed) throw new Error(`Invalid ref: ${refText}`);
4051
+ if (!isInstallTarget(target)) throw new Error(`Invalid install target: ${target}`);
3998
4052
  const info = await apiRequest(token, `/skills/${parsed.owner}/${parsed.slug}`);
4053
+ if (info.min_floom_version && !semverGte2(VERSION, info.min_floom_version)) {
4054
+ throw new Error(`This skill requires Floom CLI >= ${info.min_floom_version} (you have ${VERSION}).`);
4055
+ }
3999
4056
  const dl = await apiRequest(token, `/skills/${parsed.owner}/${parsed.slug}/download`, parsed.version ? { version: parsed.version } : void 0);
4000
4057
  const bundle = await rawGet(dl.download.url);
4001
4058
  if (!verifyBundleHash(bundle, dl.bundle_sha256)) throw new Error("Bundle hash mismatch");
@@ -4003,6 +4060,9 @@ async function installViaApi(token, refText, target) {
4003
4060
  await mkdir9(install.dir, { recursive: true });
4004
4061
  const dest = join13(install.dir, parsed.slug);
4005
4062
  const exists = await readdir4(dest).then(() => true).catch(() => false);
4063
+ if (exists && !options.force) {
4064
+ throw new Error(`Folder already exists at ${dest}. Call install_skill with force=true to overwrite after reviewing local changes.`);
4065
+ }
4006
4066
  if (exists) await rm3(dest, { recursive: true, force: true });
4007
4067
  const temp = await mkdtemp(join13(tmpdir3(), `floom-mcp-${parsed.slug}-`));
4008
4068
  try {
@@ -4024,7 +4084,7 @@ async function installViaApi(token, refText, target) {
4024
4084
  preset: target
4025
4085
  });
4026
4086
  await writeLock(process.cwd(), next);
4027
- return { path: dest, version: dl.version, ref: info.ref ?? ref };
4087
+ return { path: dest, version: dl.version, ref: info.ref ?? ref, has_scripts: !!dl.has_scripts };
4028
4088
  }
4029
4089
  async function parseSkillBundle(bundle) {
4030
4090
  const tmp = await mkdtemp(join13(tmpdir3(), "floom-mcp-read-"));
@@ -4123,9 +4183,13 @@ async function mcpCommand() {
4123
4183
  const result = await apiRequest(token, `/libraries/${workspaceSlug}/pins`, { target });
4124
4184
  return { content: [{ type: "text", text: JSON.stringify(result) }] };
4125
4185
  });
4126
- server.tool("install_skill", { ref: z3.string().min(3), target: z3.enum(["claude", "codex", "cursor", "kimi", "opencode", "gemini"]) }, async ({ ref, target }) => {
4186
+ server.tool("install_skill", {
4187
+ ref: z3.string().min(3),
4188
+ target: z3.enum(["claude", "codex", "cursor", "kimi", "opencode", "gemini"]),
4189
+ force: z3.boolean().optional().default(false)
4190
+ }, async ({ ref, target, force }) => {
4127
4191
  const token = await resolveOptionalToken();
4128
- const installed = await installViaApi(token, ref, target);
4192
+ const installed = await installViaApi(token, ref, target, { force });
4129
4193
  return { content: [{ type: "text", text: JSON.stringify(installed) }] };
4130
4194
  });
4131
4195
  const transport = new StdioServerTransport();
@@ -4166,6 +4230,15 @@ function warn(name, detail) {
4166
4230
  function fail(name, detail) {
4167
4231
  return { name, ok: false, detail };
4168
4232
  }
4233
+ function apiUrlCheck(rawApiUrl, label = "api_url") {
4234
+ const normalized = normalizeApiUrl(rawApiUrl);
4235
+ const trusted = trustedApiUrlOrDefault(rawApiUrl);
4236
+ if (isLegacyApiUrl(rawApiUrl)) return warn(label, `legacy URL ${rawApiUrl}; using ${DEFAULT_API_URL}`);
4237
+ if (normalized !== trusted) {
4238
+ return warn(label, `ignored untrusted API URL ${normalized}; set FLOOM_ALLOW_CUSTOM_API_URL=1 only for a trusted self-hosted Floom API`);
4239
+ }
4240
+ return pass(label, trusted);
4241
+ }
4169
4242
  async function validateCurrentToken(token) {
4170
4243
  if (!token) return warn("fresh_agent_auth", "missing token; authenticated MCP calls skipped");
4171
4244
  try {
@@ -4195,7 +4268,7 @@ async function doctorCommand(opts = {}) {
4195
4268
  const checks2 = [
4196
4269
  pass("cli_version", VERSION),
4197
4270
  authCheck2,
4198
- process.env.FLOOM_API_URL ? isLegacyApiUrl(process.env.FLOOM_API_URL) ? warn("api_url", `legacy FLOOM_API_URL ${process.env.FLOOM_API_URL}; using ${DEFAULT_API_URL}`) : pass("api_url", process.env.FLOOM_API_URL) : isLegacyApiUrl(rawAuth?.apiUrl) ? warn("auth_api_url", `legacy URL in ~/.floom/auth.json; using ${DEFAULT_API_URL}`) : pass("api_url", auth2?.apiUrl ?? DEFAULT_API_URL)
4271
+ 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)
4199
4272
  ];
4200
4273
  emitDoctor(checks2, opts.json);
4201
4274
  if (checks2.some((check) => !check.ok)) process.exit(1);
@@ -4225,7 +4298,7 @@ async function doctorCommand(opts = {}) {
4225
4298
  HOME: tmpHome,
4226
4299
  FLOOM_SKILLS_DIR: tmpSkills,
4227
4300
  ...hasValidToken && token ? { FLOOM_API_TOKEN: token } : {},
4228
- ...process.env.FLOOM_API_URL ? { FLOOM_API_URL: normalizeApiUrl(process.env.FLOOM_API_URL) } : auth?.apiUrl ? { FLOOM_API_URL: auth.apiUrl } : {}
4301
+ ...process.env.FLOOM_API_URL ? { FLOOM_API_URL: trustedApiUrlOrDefault(process.env.FLOOM_API_URL) } : auth?.apiUrl ? { FLOOM_API_URL: trustedApiUrlOrDefault(auth.apiUrl) } : {}
4229
4302
  },
4230
4303
  stderr: "pipe"
4231
4304
  });
@@ -4251,12 +4324,15 @@ async function doctorCommand(opts = {}) {
4251
4324
  } else {
4252
4325
  checks.push(warn("mcp_authenticated_tools", "skipped list_workspaces/search_skills because no valid token is available"));
4253
4326
  }
4254
- const ref = opts.ref ?? "floom-demo/brand-voice";
4255
- const skill = await client.callTool({ name: "get_skill", arguments: { ref } });
4256
- checks.push(/SKILL\.md/.test(textOf(skill)) ? pass("mcp_get_skill", ref) : fail("mcp_get_skill", "bundle did not include SKILL.md"));
4257
- const installed = await client.callTool({ name: "install_skill", arguments: { ref, target: opts.target ?? "codex" } });
4258
- const entries = await readdir5(tmpSkills);
4259
- checks.push(entries.length > 0 ? pass("mcp_install_skill", textOf(installed)) : fail("mcp_install_skill", "target directory is empty"));
4327
+ if (opts.ref) {
4328
+ const skill = await client.callTool({ name: "get_skill", arguments: { ref: opts.ref } });
4329
+ checks.push(/SKILL\.md/.test(textOf(skill)) ? pass("mcp_get_skill", opts.ref) : fail("mcp_get_skill", "bundle did not include SKILL.md"));
4330
+ const installed = await client.callTool({ name: "install_skill", arguments: { ref: opts.ref, target: opts.target ?? "codex" } });
4331
+ const entries = await readdir5(tmpSkills);
4332
+ checks.push(entries.length > 0 ? pass("mcp_install_skill", textOf(installed)) : fail("mcp_install_skill", "target directory is empty"));
4333
+ } else {
4334
+ checks.push(warn("mcp_public_skill", "skipped; pass --ref <public-skill-ref> to verify get_skill/install_skill against a known public skill"));
4335
+ }
4260
4336
  } catch (e) {
4261
4337
  checks.push(fail("fresh_agent_mcp", e.message));
4262
4338
  } finally {