@floomhq/skills 0.2.8 → 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
@@ -1907,10 +1907,10 @@ function parseManifest(raw) {
1907
1907
  };
1908
1908
  }
1909
1909
  async function readManifest(skillDir) {
1910
- const { readFile: readFile9 } = await import("node:fs/promises");
1911
- const { join: join12 } = await import("node:path");
1910
+ const { readFile: readFile11 } = await import("node:fs/promises");
1911
+ const { join: join15 } = await import("node:path");
1912
1912
  try {
1913
- const raw = await readFile9(join12(skillDir, "skill.json"), "utf8");
1913
+ const raw = await readFile11(join15(skillDir, "skill.json"), "utf8");
1914
1914
  let parsed;
1915
1915
  try {
1916
1916
  parsed = JSON.parse(raw);
@@ -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"],
@@ -2049,35 +2062,36 @@ function presetDir(target, opts) {
2049
2062
  return join(root, ".claude", "skills");
2050
2063
  case "gemini":
2051
2064
  return join(root, ".gemini", "skills");
2065
+ case "kimi":
2066
+ return join(root, ".kimi", "skills");
2052
2067
  case "codex":
2053
2068
  case "cursor":
2054
2069
  case "generic":
2055
2070
  case "all":
2056
2071
  case "opencode":
2057
- case "kimi":
2058
2072
  default:
2059
2073
  return join(root, ".agents", "skills");
2060
2074
  }
2061
2075
  }
2062
2076
  function resolveInstallDir(args) {
2077
+ const target = args.target ?? "generic";
2063
2078
  if (args.to) {
2064
2079
  return {
2065
- target: args.target ?? "generic",
2080
+ target,
2066
2081
  dir: args.to,
2067
2082
  origin: "explicit",
2068
- compatibleAgents: COMPATIBLE_AGENTS[args.target ?? "generic"]
2083
+ compatibleAgents: COMPATIBLE_AGENTS[target]
2069
2084
  };
2070
2085
  }
2071
- const targetEnvDir = envDirForTarget(args.target ?? "generic");
2086
+ const targetEnvDir = envDirForTarget(target);
2072
2087
  if (targetEnvDir) {
2073
2088
  return {
2074
- target: args.target ?? "generic",
2089
+ target,
2075
2090
  dir: targetEnvDir,
2076
2091
  origin: "env",
2077
- compatibleAgents: COMPATIBLE_AGENTS[args.target ?? "generic"]
2092
+ compatibleAgents: COMPATIBLE_AGENTS[target]
2078
2093
  };
2079
2094
  }
2080
- const target = args.target ?? "generic";
2081
2095
  return {
2082
2096
  target,
2083
2097
  dir: presetDir(target, { global: args.global, cwd: args.cwd }),
@@ -2364,7 +2378,7 @@ var log = {
2364
2378
  err: (msg) => console.error(chalk.red("\u2717 ") + msg),
2365
2379
  step: (msg) => console.log(chalk.dim("\xB7 ") + msg),
2366
2380
  heading: (msg) => console.log("\n" + chalk.bold(msg)),
2367
- kv: (key, value) => console.log(` ${chalk.dim(key.padEnd(16))}${value}`),
2381
+ kv: (key, value) => console.log(` ${chalk.dim(key.padEnd(18))}${value}`),
2368
2382
  blank: () => console.log("")
2369
2383
  };
2370
2384
 
@@ -2380,7 +2394,8 @@ var CONFIG_DIR = join3(homedir2(), ".floom");
2380
2394
  var AUTH_FILE = join3(CONFIG_DIR, "auth.json");
2381
2395
  var DEFAULT_APP_URL = "https://skills.floom.dev";
2382
2396
  var DEFAULT_API_URL = "https://skills.floom.dev/api/v1";
2383
- 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"]);
2384
2399
  async function ensureDir() {
2385
2400
  await mkdir2(CONFIG_DIR, { recursive: true, mode: 448 });
2386
2401
  }
@@ -2394,12 +2409,15 @@ async function readRawAuth() {
2394
2409
  return JSON.parse(raw);
2395
2410
  } catch (e) {
2396
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
+ }
2397
2415
  throw e;
2398
2416
  }
2399
2417
  }
2400
2418
  async function writeAuth(state) {
2401
2419
  await ensureDir();
2402
- 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 });
2403
2421
  try {
2404
2422
  await chmod(AUTH_FILE, 384);
2405
2423
  } catch {
@@ -2422,14 +2440,16 @@ function getAppUrl() {
2422
2440
  }
2423
2441
  function getApiBaseUrls(preferred) {
2424
2442
  const explicitApiUrl = process.env.FLOOM_API_URL?.trim();
2425
- if (explicitApiUrl) return [normalizeApiUrl(explicitApiUrl)];
2426
- const primary = normalizeApiUrl(preferred ?? getApiUrl());
2443
+ if (explicitApiUrl) return [trustedApiUrlOrDefault(explicitApiUrl)];
2444
+ const primary = trustedApiUrlOrDefault(preferred ?? getApiUrl());
2427
2445
  const bases = [primary];
2428
2446
  if (preferred && !process.env.FLOOM_APP_URL) bases.push(DEFAULT_API_URL);
2429
2447
  return Array.from(new Set(bases));
2430
2448
  }
2431
2449
  function normalizeApiUrl(apiUrl) {
2432
- 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;
2433
2453
  try {
2434
2454
  const url = new URL(trimmed);
2435
2455
  if (LEGACY_API_HOSTS.has(url.hostname)) return DEFAULT_API_URL;
@@ -2438,6 +2458,22 @@ function normalizeApiUrl(apiUrl) {
2438
2458
  }
2439
2459
  return trimmed;
2440
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
+ }
2441
2477
  function isLegacyApiUrl(apiUrl) {
2442
2478
  if (!apiUrl) return false;
2443
2479
  try {
@@ -2448,7 +2484,7 @@ function isLegacyApiUrl(apiUrl) {
2448
2484
  }
2449
2485
 
2450
2486
  // src/version.ts
2451
- var VERSION = "0.2.8";
2487
+ var VERSION = "0.2.10";
2452
2488
 
2453
2489
  // src/api-client.ts
2454
2490
  var DEFAULT_TIMEOUT_MS = 2e4;
@@ -2490,7 +2526,8 @@ async function api(path, opts = {}) {
2490
2526
  "User-Agent": `floom-cli/${VERSION}`,
2491
2527
  "x-floom-cli-version": VERSION
2492
2528
  };
2493
- if (token) headers.Authorization = `Bearer ${token}`;
2529
+ const requestToken = opts.tokenOverride ?? token;
2530
+ if (requestToken) headers.Authorization = `Bearer ${requestToken}`;
2494
2531
  let res;
2495
2532
  try {
2496
2533
  res = await fetchWithTimeout(url.toString(), {
@@ -2587,8 +2624,9 @@ async function loginCommand() {
2587
2624
  const interval = Math.max(2, session.poll_interval_seconds) * 1e3;
2588
2625
  while (Date.now() < deadline) {
2589
2626
  await new Promise((r) => setTimeout(r, interval));
2590
- const pollPath = `/cli/sessions/${session.session_id}?device_code=${encodeURIComponent(session.device_code)}`;
2591
- const poll = await api(pollPath);
2627
+ const poll = await api(`/cli/sessions/${session.session_id}`, {
2628
+ tokenOverride: session.device_code
2629
+ });
2592
2630
  if (poll.status === "approved" && poll.token && poll.handle && poll.email) {
2593
2631
  await writeAuth({
2594
2632
  token: poll.token,
@@ -2957,7 +2995,8 @@ async function publishCommand(opts = {}) {
2957
2995
  log.ok(`Published ${complete.ref}`);
2958
2996
  log.blank();
2959
2997
  log.info("View:");
2960
- 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}`);
2961
3000
  log.info("Install:");
2962
3001
  log.kv("", complete.install_command);
2963
3002
  }
@@ -2968,7 +3007,7 @@ import { join as join8 } from "node:path";
2968
3007
  import { tmpdir } from "node:os";
2969
3008
 
2970
3009
  // src/lib/floom-lock.ts
2971
- import { readFile as readFile7, writeFile as writeFile3, stat as stat3, readdir as readdir2 } from "node:fs/promises";
3010
+ import { readFile as readFile7, writeFile as writeFile3, mkdir as mkdir3, stat as stat3, readdir as readdir2 } from "node:fs/promises";
2972
3011
  import { join as join7, relative as relative2, sep as sep2, posix as posix2 } from "node:path";
2973
3012
  import { createHash as createHash3 } from "node:crypto";
2974
3013
  var EMPTY = { schema_version: "0.1", skills: {} };
@@ -2977,6 +3016,7 @@ async function readLock(projectDir) {
2977
3016
  const raw = await readFile7(join7(projectDir, "floom.lock"), "utf8");
2978
3017
  const parsed = JSON.parse(raw);
2979
3018
  if (parsed.schema_version === "0.1") return parsed;
3019
+ if (parsed.schema_version === "0.2") return parsed;
2980
3020
  const version = typeof parsed.schema_version === "string" ? parsed.schema_version : "missing";
2981
3021
  throw new Error(
2982
3022
  `LOCK_SCHEMA_UNSUPPORTED: floom.lock schema_version ${version} is not supported by this CLI. The existing floom.lock was left unchanged; migrate it or reinstall skills with the current CLI.`
@@ -2987,11 +3027,32 @@ async function readLock(projectDir) {
2987
3027
  }
2988
3028
  }
2989
3029
  async function writeLock(projectDir, lock) {
3030
+ await mkdir3(projectDir, { recursive: true });
2990
3031
  await writeFile3(join7(projectDir, "floom.lock"), JSON.stringify(lock, null, 2) + "\n", "utf8");
2991
3032
  }
2992
3033
  function setLockEntry(lock, ref, entry) {
2993
3034
  return { ...lock, skills: { ...lock.skills, [ref]: entry } };
2994
3035
  }
3036
+ function upgradeLockToV02(lock, opts = {}) {
3037
+ if (lock.schema_version === "0.2") {
3038
+ return {
3039
+ ...lock,
3040
+ target: opts.target ?? lock.target,
3041
+ scope: opts.scope ?? lock.scope,
3042
+ default_workspace: opts.defaultWorkspace ?? lock.default_workspace,
3043
+ last_sync_at: (/* @__PURE__ */ new Date()).toISOString()
3044
+ };
3045
+ }
3046
+ return {
3047
+ schema_version: "0.2",
3048
+ default_workspace: opts.defaultWorkspace,
3049
+ target: opts.target,
3050
+ scope: opts.scope,
3051
+ last_sync_at: (/* @__PURE__ */ new Date()).toISOString(),
3052
+ instructions: [],
3053
+ skills: lock.skills
3054
+ };
3055
+ }
2995
3056
  async function hashInstalledFolder(folderAbs) {
2996
3057
  const out = [];
2997
3058
  async function walk2(dir) {
@@ -3046,6 +3107,12 @@ async function installCommand(refStr, opts = {}) {
3046
3107
  log.info("Expected: @owner/slug, workspace-slug/slug, or with @version suffix");
3047
3108
  process.exit(1);
3048
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";
3049
3116
  let info;
3050
3117
  try {
3051
3118
  info = await api(`/skills/${ref.owner}/${ref.slug}`);
@@ -3070,7 +3137,7 @@ async function installCommand(refStr, opts = {}) {
3070
3137
  process.exit(1);
3071
3138
  }
3072
3139
  const target = resolveInstallDir({
3073
- target: opts.for ?? "generic",
3140
+ target: installTarget,
3074
3141
  to: opts.to,
3075
3142
  global: opts.global
3076
3143
  });
@@ -3131,7 +3198,7 @@ async function installCommand(refStr, opts = {}) {
3131
3198
  bundle_sha256: dl.bundle_sha256,
3132
3199
  installed_at: (/* @__PURE__ */ new Date()).toISOString(),
3133
3200
  path: destFolder.replace(projectDir + "/", ""),
3134
- preset: opts.for
3201
+ preset: installTarget
3135
3202
  });
3136
3203
  await writeLock(projectDir, next);
3137
3204
  log.blank();
@@ -3406,14 +3473,521 @@ async function libraryLeaveCommand(librarySlug) {
3406
3473
  log.ok(`Left workspace ${librarySlug}`);
3407
3474
  }
3408
3475
 
3476
+ // src/lib/config-file.ts
3477
+ import { mkdir as mkdir6, readFile as readFile8, writeFile as writeFile4 } from "node:fs/promises";
3478
+ import { homedir as homedir3 } from "node:os";
3479
+ import { dirname, join as join10 } from "node:path";
3480
+ import { z as z2 } from "zod";
3481
+ var FLOOM_CONFIG_VERSION = "0.1";
3482
+ var TARGETS = ["claude", "codex", "cursor", "kimi", "opencode"];
3483
+ var targetConfigSchema = z2.object({
3484
+ active_workspaces: z2.array(z2.string().min(1)).default([]),
3485
+ instruction_path: z2.string().min(1).optional()
3486
+ });
3487
+ var configSchema = z2.object({
3488
+ version: z2.literal(FLOOM_CONFIG_VERSION).default(FLOOM_CONFIG_VERSION),
3489
+ default_workspace: z2.string().min(1).optional(),
3490
+ targets: z2.record(z2.enum(TARGETS), targetConfigSchema).default({})
3491
+ });
3492
+ function configPath(scope, cwd = process.cwd()) {
3493
+ if (scope === "global") return join10(homedir3(), ".floom", "config.json");
3494
+ return join10(cwd, ".floom", "config.json");
3495
+ }
3496
+ async function readFloomConfig(scope, cwd = process.cwd()) {
3497
+ try {
3498
+ const raw = await readFile8(configPath(scope, cwd), "utf8");
3499
+ return configSchema.parse(JSON.parse(raw));
3500
+ } catch (e) {
3501
+ if (e.code === "ENOENT") return configSchema.parse({});
3502
+ throw e;
3503
+ }
3504
+ }
3505
+ async function writeFloomConfig(scope, config, cwd = process.cwd()) {
3506
+ const path = configPath(scope, cwd);
3507
+ const parsed = configSchema.parse(config);
3508
+ await mkdir6(dirname(path), { recursive: true, mode: 448 });
3509
+ await writeFile4(path, JSON.stringify(parsed, null, 2) + "\n", { mode: 384 });
3510
+ }
3511
+ function normalizeScope(value) {
3512
+ return value === "global" ? "global" : "local";
3513
+ }
3514
+ function assertTarget(value) {
3515
+ if (TARGETS.includes(value)) return value;
3516
+ throw new Error(`Invalid target: ${value}. Expected ${TARGETS.join(", ")}`);
3517
+ }
3518
+ async function setDefaultWorkspace(slug, scope, cwd = process.cwd()) {
3519
+ const config = await readFloomConfig(scope, cwd);
3520
+ const next = { ...config, default_workspace: slug };
3521
+ await writeFloomConfig(scope, next, cwd);
3522
+ return next;
3523
+ }
3524
+ async function setWorkspaceActive(input) {
3525
+ const config = await readFloomConfig(input.scope, input.cwd);
3526
+ const current = config.targets[input.target] ?? { active_workspaces: [] };
3527
+ const set = new Set(current.active_workspaces);
3528
+ if (input.active) set.add(input.workspace);
3529
+ else set.delete(input.workspace);
3530
+ const next = {
3531
+ ...config,
3532
+ targets: {
3533
+ ...config.targets,
3534
+ [input.target]: {
3535
+ ...current,
3536
+ active_workspaces: Array.from(set).sort()
3537
+ }
3538
+ }
3539
+ };
3540
+ await writeFloomConfig(input.scope, next, input.cwd);
3541
+ return next;
3542
+ }
3543
+
3544
+ // src/commands/instruction.ts
3545
+ import { mkdir as mkdir7, readFile as readFile9, writeFile as writeFile5 } from "node:fs/promises";
3546
+ import { basename as basename2, dirname as dirname2, join as join11 } from "node:path";
3547
+ import { createHash as createHash4 } from "node:crypto";
3548
+ var START = "<!-- FLOOM START -->";
3549
+ var END = "<!-- FLOOM END -->";
3550
+ var AGENT_INSTRUCTION_TARGETS = ["claude", "codex", "cursor", "opencode"];
3551
+ function defaultInstructionPath(target) {
3552
+ if (target === "claude") return "CLAUDE.md";
3553
+ if (target === "codex") return "AGENTS.md";
3554
+ if (target === "cursor") return ".cursor/rules/floom.mdc";
3555
+ if (target === "opencode") return ".opencode/instructions/floom.md";
3556
+ throw new Error(`No default instruction path for target ${target}`);
3557
+ }
3558
+ function replaceManagedBlock(existing, blockBody) {
3559
+ const block = `${START}
3560
+ ${blockBody.trim()}
3561
+ ${END}
3562
+ `;
3563
+ const start = existing.indexOf(START);
3564
+ const end = existing.indexOf(END);
3565
+ if (start >= 0 && end > start) {
3566
+ const afterEnd = end + END.length;
3567
+ const prefix = existing.slice(0, start);
3568
+ const suffix = existing.slice(afterEnd).replace(/^\n/, "");
3569
+ return { content: `${prefix}${block}${suffix}`, hadBlock: true };
3570
+ }
3571
+ return { content: existing.trim() ? `${existing.trimEnd()}
3572
+
3573
+ ${block}` : block, hadBlock: false };
3574
+ }
3575
+ function resolveScope(opts) {
3576
+ if (opts.account && opts.workspace) throw new Error("Use either --account or --workspace, not both.");
3577
+ if (opts.account) return "account";
3578
+ if (opts.workspace) return "workspace";
3579
+ throw new Error("Instruction command requires --account or --workspace <slug>.");
3580
+ }
3581
+ function bodySha256(body) {
3582
+ return createHash4("sha256").update(body, "utf8").digest("hex");
3583
+ }
3584
+ async function readUtf8FileStrict(path) {
3585
+ const bytes = await readFile9(path);
3586
+ try {
3587
+ new TextDecoder("utf-8", { fatal: true }).decode(bytes);
3588
+ } catch {
3589
+ throw new Error(`${path} is not valid UTF-8; refusing to rewrite it as a managed instruction file.`);
3590
+ }
3591
+ return bytes.toString("utf8");
3592
+ }
3593
+ function assertAgentInstructionTarget(value) {
3594
+ if (AGENT_INSTRUCTION_TARGETS.includes(value)) return value;
3595
+ throw new Error(`Invalid instruction target: ${value}. Expected ${AGENT_INSTRUCTION_TARGETS.join(", ")}. Kimi uses the Floom guide skill in V0.`);
3596
+ }
3597
+ function assertPublishInstructionTarget(value) {
3598
+ if (value === "default") return "default";
3599
+ return assertAgentInstructionTarget(value);
3600
+ }
3601
+ async function fetchInstruction(input) {
3602
+ return api("/instructions", {
3603
+ authRequired: true,
3604
+ query: input
3605
+ });
3606
+ }
3607
+ function buildInstructionBlock(sections) {
3608
+ return [
3609
+ "# Floom",
3610
+ "",
3611
+ "Use Floom for workspace-approved AI skills and instructions.",
3612
+ "",
3613
+ ...sections.flatMap((section) => [
3614
+ `## ${section.label}`,
3615
+ "",
3616
+ section.body_md.trim(),
3617
+ ""
3618
+ ])
3619
+ ].join("\n");
3620
+ }
3621
+ async function writeManagedInstructionFile(input) {
3622
+ let existing = "";
3623
+ let existed = true;
3624
+ try {
3625
+ existing = await readUtf8FileStrict(input.path);
3626
+ } catch (e) {
3627
+ if (e.code === "ENOENT") existed = false;
3628
+ else throw e;
3629
+ }
3630
+ const next = replaceManagedBlock(existing, input.blockBody);
3631
+ if (existed && !next.hadBlock && !input.apply && !input.force) {
3632
+ log.warn(`Refusing to modify ${input.path} without an existing Floom managed block.`);
3633
+ log.info("Re-run with --apply to append the managed block, or --path <file> for a dedicated file.");
3634
+ process.exit(1);
3635
+ }
3636
+ if (existed && existing === next.content) return { changed: false };
3637
+ let backupPath;
3638
+ if (existed && existing) {
3639
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
3640
+ backupPath = join11(".floom", "backups", `${basename2(input.path)}.${stamp}.bak`);
3641
+ await mkdir7(dirname2(backupPath), { recursive: true, mode: 448 });
3642
+ await writeFile5(backupPath, existing, { mode: 384 });
3643
+ }
3644
+ await mkdir7(dirname2(input.path), { recursive: true });
3645
+ await writeFile5(input.path, next.content, "utf8");
3646
+ return { changed: true, backupPath };
3647
+ }
3648
+ async function recordCliActivity(input) {
3649
+ try {
3650
+ await api("/cli/activity", { method: "POST", authRequired: true, body: input });
3651
+ } catch (e) {
3652
+ log.warn(`Activity event not recorded: ${e.message}`);
3653
+ }
3654
+ }
3655
+ async function upsertInstructionLock(input) {
3656
+ const lock = upgradeLockToV02(await readLock(process.cwd()), {
3657
+ target: input.target,
3658
+ scope: input.configScope,
3659
+ defaultWorkspace: input.defaultWorkspace
3660
+ });
3661
+ const instructions = (lock.instructions ?? []).filter(
3662
+ (entry) => !(entry.scope === input.scope && entry.workspace === input.workspace && entry.target === input.target && entry.path === input.path)
3663
+ );
3664
+ instructions.push({
3665
+ scope: input.scope,
3666
+ workspace: input.workspace,
3667
+ target: input.target,
3668
+ version_id: input.version_id,
3669
+ body_sha256: input.body_sha256,
3670
+ pulled_at: (/* @__PURE__ */ new Date()).toISOString(),
3671
+ path: input.path
3672
+ });
3673
+ await writeLock(process.cwd(), { ...lock, instructions, last_sync_at: (/* @__PURE__ */ new Date()).toISOString() });
3674
+ }
3675
+ async function instructionPullCommand(opts = {}) {
3676
+ const target = assertAgentInstructionTarget(opts.target ?? "codex");
3677
+ const configScope = normalizeScope(opts.scope);
3678
+ const scope = resolveScope(opts);
3679
+ const config = await readFloomConfig(configScope);
3680
+ const workspace = opts.workspace ?? (scope === "workspace" ? config.default_workspace : void 0);
3681
+ if (scope === "workspace" && !workspace) throw new Error("Workspace instruction pull requires --workspace <slug> or a configured default workspace.");
3682
+ const path = opts.path ?? defaultInstructionPath(target);
3683
+ const resp = await fetchInstruction({ scope, workspace, target });
3684
+ if (!resp.instruction?.latest) {
3685
+ log.info("No remote instruction found.");
3686
+ return;
3687
+ }
3688
+ const label = scope === "account" ? "Account Instructions" : `Workspace Instructions: ${workspace}`;
3689
+ const writeResult = await writeManagedInstructionFile({
3690
+ path,
3691
+ blockBody: buildInstructionBlock([{ label, body_md: resp.instruction.latest.body_md }]),
3692
+ apply: opts.apply,
3693
+ force: opts.force
3694
+ });
3695
+ if (!writeResult.changed) {
3696
+ log.info(`${path} is already at the latest ${scope} instruction.`);
3697
+ return;
3698
+ }
3699
+ if (writeResult.backupPath) log.info(`Backed up previous instruction file to ${writeResult.backupPath}`);
3700
+ await upsertInstructionLock({
3701
+ scope,
3702
+ workspace,
3703
+ target,
3704
+ path,
3705
+ version_id: resp.instruction.latest.id,
3706
+ body_sha256: resp.instruction.latest.body_sha256,
3707
+ configScope,
3708
+ defaultWorkspace: config.default_workspace
3709
+ });
3710
+ await recordCliActivity({
3711
+ event_type: "instruction.pulled_cli",
3712
+ scope,
3713
+ workspace,
3714
+ target,
3715
+ instruction_id: resp.instruction.id,
3716
+ version_id: resp.instruction.latest.id,
3717
+ path
3718
+ });
3719
+ log.ok(`Pulled ${scope} instruction to ${path}`);
3720
+ }
3721
+ async function instructionPushCommand(file, opts = {}) {
3722
+ const target = assertPublishInstructionTarget(opts.target ?? "default");
3723
+ const scope = resolveScope(opts);
3724
+ if (scope === "workspace") {
3725
+ const workspace = opts.workspace;
3726
+ const info = await api(`/libraries/${workspace}`, { authRequired: true });
3727
+ if (info.role !== "admin" && info.role !== "editor") {
3728
+ throw new Error(`Workspace instruction push requires editor or admin role in ${workspace}.`);
3729
+ }
3730
+ }
3731
+ const body = await readFile9(file, "utf8");
3732
+ const resp = await api("/instructions", {
3733
+ method: "POST",
3734
+ authRequired: true,
3735
+ body: {
3736
+ scope,
3737
+ workspace: opts.workspace,
3738
+ target,
3739
+ body_md: body,
3740
+ changelog: opts.changelog ?? `Published from CLI (${file}, ${bodySha256(body).slice(0, 12)})`
3741
+ }
3742
+ });
3743
+ log.ok(`Published ${scope} instruction ${resp.instruction?.latest?.version_seq ? `v${resp.instruction.latest.version_seq}` : ""}`.trim());
3744
+ }
3745
+
3746
+ // src/commands/workspace-config.ts
3747
+ async function assertWorkspaceExists(slug) {
3748
+ await api(`/libraries/${slug}`, { authRequired: true });
3749
+ }
3750
+ function parseOptions(opts) {
3751
+ return {
3752
+ target: assertTarget(opts.target ?? "codex"),
3753
+ scope: normalizeScope(opts.scope)
3754
+ };
3755
+ }
3756
+ async function defaultWorkspaceCommand(slug, opts = {}) {
3757
+ const scope = normalizeScope(opts.scope);
3758
+ if (!slug) {
3759
+ const config = await readFloomConfig(scope);
3760
+ if (!config.default_workspace) {
3761
+ log.info(`No default workspace configured for ${scope} scope.`);
3762
+ return;
3763
+ }
3764
+ console.log(config.default_workspace);
3765
+ return;
3766
+ }
3767
+ await assertWorkspaceExists(slug);
3768
+ await setDefaultWorkspace(slug, scope);
3769
+ log.ok(`Default workspace (${scope}) set to ${slug}`);
3770
+ }
3771
+ async function workspaceActivateCommand(slug, opts = {}) {
3772
+ const parsed = parseOptions(opts);
3773
+ await assertWorkspaceExists(slug);
3774
+ await setWorkspaceActive({ workspace: slug, target: parsed.target, scope: parsed.scope, active: true });
3775
+ await recordCliActivity({ event_type: "workspace.activate_cli", workspace: slug, target: parsed.target });
3776
+ log.ok(`Activated ${slug} for ${parsed.target} (${parsed.scope})`);
3777
+ }
3778
+ async function workspaceDeactivateCommand(slug, opts = {}) {
3779
+ const parsed = parseOptions(opts);
3780
+ await setWorkspaceActive({ workspace: slug, target: parsed.target, scope: parsed.scope, active: false });
3781
+ await recordCliActivity({ event_type: "workspace.deactivate_cli", workspace: slug, target: parsed.target });
3782
+ log.ok(`Deactivated ${slug} for ${parsed.target} (${parsed.scope})`);
3783
+ }
3784
+ async function workspaceActiveCommand(opts = {}) {
3785
+ const parsed = parseOptions(opts);
3786
+ const config = await readFloomConfig(parsed.scope);
3787
+ const active = config.targets[parsed.target]?.active_workspaces ?? [];
3788
+ if (!active.length) {
3789
+ log.info(`No active workspaces for ${parsed.target} (${parsed.scope}).`);
3790
+ return;
3791
+ }
3792
+ for (const workspace of active) console.log(workspace);
3793
+ }
3794
+
3795
+ // src/commands/sync.ts
3796
+ import { mkdir as mkdir8, writeFile as writeFile6 } from "node:fs/promises";
3797
+ import { dirname as dirname3, join as join12 } from "node:path";
3798
+ var ROUTER_SKILL = [
3799
+ "# Floom Find Skills",
3800
+ "",
3801
+ "Use this router skill instead of loading every Floom skill into context.",
3802
+ "",
3803
+ "When a task may need a reusable skill:",
3804
+ "",
3805
+ "1. Call the Floom MCP `search_skills` tool with a short query.",
3806
+ "2. Review the compact candidate list.",
3807
+ "3. Call `get_skill` only for the selected candidate.",
3808
+ "4. Call `install_skill` only when the skill needs to be available locally.",
3809
+ "",
3810
+ "Do not enumerate the whole workspace library into model context. Keep skill bodies out of context until selected.",
3811
+ ""
3812
+ ].join("\n");
3813
+ async function installRouter(target) {
3814
+ const install = resolveInstallDir({ target });
3815
+ const routerDir = target === "kimi" ? "floom" : "floom-find-skills";
3816
+ const path = join12(install.dir, routerDir, "SKILL.md");
3817
+ await mkdir8(dirname3(path), { recursive: true });
3818
+ await writeFile6(path, ROUTER_SKILL, "utf8");
3819
+ return path;
3820
+ }
3821
+ async function pullInstructions(input) {
3822
+ const path = defaultInstructionPath(input.target);
3823
+ const sections = [];
3824
+ const account = await fetchInstruction({ scope: "account", target: input.target });
3825
+ if (account.instruction?.latest) {
3826
+ sections.push({
3827
+ label: "Account Instructions",
3828
+ scope: "account",
3829
+ instruction_id: account.instruction.id,
3830
+ version_id: account.instruction.latest.id,
3831
+ body_sha256: account.instruction.latest.body_sha256,
3832
+ body_md: account.instruction.latest.body_md
3833
+ });
3834
+ }
3835
+ for (const workspace of input.activeWorkspaces) {
3836
+ const resp = await fetchInstruction({ scope: "workspace", workspace, target: input.target });
3837
+ if (!resp.instruction?.latest) continue;
3838
+ sections.push({
3839
+ label: `Workspace Instructions: ${workspace}`,
3840
+ scope: "workspace",
3841
+ workspace,
3842
+ instruction_id: resp.instruction.id,
3843
+ version_id: resp.instruction.latest.id,
3844
+ body_sha256: resp.instruction.latest.body_sha256,
3845
+ body_md: resp.instruction.latest.body_md
3846
+ });
3847
+ }
3848
+ if (sections.length === 0) {
3849
+ log.info("No remote instructions found.");
3850
+ return;
3851
+ }
3852
+ const writeResult = await writeManagedInstructionFile({
3853
+ path,
3854
+ blockBody: buildInstructionBlock(sections.map((section) => ({ label: section.label, body_md: section.body_md }))),
3855
+ apply: input.apply,
3856
+ force: input.force
3857
+ });
3858
+ if (!writeResult.changed) {
3859
+ log.info(`${path} already contains the latest Floom instruction block.`);
3860
+ return;
3861
+ }
3862
+ if (writeResult.backupPath) log.info(`Backed up previous instruction file to ${writeResult.backupPath}`);
3863
+ for (const section of sections) {
3864
+ await upsertInstructionLock({
3865
+ scope: section.scope,
3866
+ workspace: section.workspace,
3867
+ target: input.target,
3868
+ path,
3869
+ version_id: section.version_id,
3870
+ body_sha256: section.body_sha256,
3871
+ configScope: input.scope,
3872
+ defaultWorkspace: input.defaultWorkspace
3873
+ });
3874
+ await recordCliActivity({
3875
+ event_type: "instruction.pulled_cli",
3876
+ scope: section.scope,
3877
+ workspace: section.workspace,
3878
+ target: input.target,
3879
+ instruction_id: section.instruction_id,
3880
+ version_id: section.version_id,
3881
+ path
3882
+ });
3883
+ }
3884
+ log.ok(`Pulled ${sections.length} instruction section(s) to ${path}`);
3885
+ }
3886
+ async function statusCommand(opts = {}) {
3887
+ const target = assertTarget(opts.target ?? "codex");
3888
+ const scope = normalizeScope(opts.scope);
3889
+ const config = await readFloomConfig(scope);
3890
+ const active = config.targets[target]?.active_workspaces ?? [];
3891
+ log.heading(`Floom status for ${target} (${scope})`);
3892
+ log.kv("Default workspace", config.default_workspace ?? "(none)");
3893
+ log.kv("Active workspaces", active.length ? active.join(", ") : "(none)");
3894
+ log.kv("Local pull policy", "router + pinned skills + instructions only");
3895
+ for (const workspace of active) {
3896
+ const pins = await api(`/libraries/${workspace}/pins`, {
3897
+ authRequired: true,
3898
+ query: { target }
3899
+ });
3900
+ log.blank();
3901
+ log.info(`${workspace}: ${pins.pins.length} pinned skill(s) for ${target}`);
3902
+ for (const pin of pins.pins) {
3903
+ log.kv("", `${pin.skill?.slug ?? pin.skill_id}${pin.skill?.latest?.version ? `@${pin.skill.latest.version}` : ""}`);
3904
+ }
3905
+ }
3906
+ }
3907
+ async function pullCommand(opts = {}) {
3908
+ const target = assertTarget(opts.target ?? "codex");
3909
+ const scope = normalizeScope(opts.scope);
3910
+ const config = await readFloomConfig(scope);
3911
+ const active = config.targets[target]?.active_workspaces ?? [];
3912
+ const routerPath = await installRouter(target);
3913
+ log.ok(`Installed Floom router skill to ${routerPath}`);
3914
+ if (target === "kimi") {
3915
+ log.info("Kimi uses the Floom guide/router skill in V0; account and workspace instructions are not merged into Kimi context.");
3916
+ } else {
3917
+ await pullInstructions({
3918
+ target: assertAgentInstructionTarget(target),
3919
+ scope,
3920
+ defaultWorkspace: config.default_workspace,
3921
+ activeWorkspaces: active,
3922
+ apply: opts.apply,
3923
+ force: opts.force
3924
+ });
3925
+ }
3926
+ for (const workspace of active) {
3927
+ const pins = await api(`/libraries/${workspace}/pins`, {
3928
+ authRequired: true,
3929
+ query: { target }
3930
+ });
3931
+ for (const pin of pins.pins) {
3932
+ if (!pin.skill?.slug) continue;
3933
+ await installCommand(`${workspace}/${pin.skill.slug}`, { for: target, force: opts.force });
3934
+ }
3935
+ await recordCliActivity({
3936
+ event_type: "workspace.pulled_cli",
3937
+ workspace,
3938
+ target,
3939
+ resources: pins.pins.map((pin) => ({ type: "skill", id: pin.skill_id, name: pin.skill?.slug ?? pin.skill_id }))
3940
+ });
3941
+ }
3942
+ log.blank();
3943
+ log.ok("Pull complete.");
3944
+ }
3945
+
3946
+ // src/commands/pin.ts
3947
+ async function resolveWorkspace(opts) {
3948
+ if (opts.workspace) return opts.workspace;
3949
+ const config = await readFloomConfig("local");
3950
+ if (config.default_workspace) return config.default_workspace;
3951
+ throw new Error("Pin command requires --workspace <slug> or a configured default workspace.");
3952
+ }
3953
+ async function pinCommand(ref, opts = {}) {
3954
+ const target = assertTarget(opts.target ?? "codex");
3955
+ const workspace = await resolveWorkspace(opts);
3956
+ await api(`/libraries/${workspace}/pins`, {
3957
+ method: "POST",
3958
+ authRequired: true,
3959
+ body: { ref, target }
3960
+ });
3961
+ log.ok(`Pinned ${ref} for ${target} in ${workspace}`);
3962
+ }
3963
+ async function unpinCommand(ref, opts = {}) {
3964
+ const target = assertTarget(opts.target ?? "codex");
3965
+ const workspace = await resolveWorkspace(opts);
3966
+ await api(`/libraries/${workspace}/pins`, {
3967
+ method: "DELETE",
3968
+ authRequired: true,
3969
+ query: { ref, target }
3970
+ });
3971
+ log.ok(`Unpinned ${ref} for ${target} in ${workspace}`);
3972
+ }
3973
+
3409
3974
  // src/commands/mcp.ts
3410
- import { mkdtemp, mkdir as mkdir6, readdir as readdir4, readFile as readFile8, rename as rename3, rm as rm3, writeFile as writeFile4 } from "node:fs/promises";
3411
- import { join as join10 } from "node:path";
3975
+ import { mkdtemp, mkdir as mkdir9, readdir as readdir4, readFile as readFile10, rename as rename3, rm as rm3, writeFile as writeFile7 } from "node:fs/promises";
3976
+ import { join as join13 } from "node:path";
3412
3977
  import { tmpdir as tmpdir3 } from "node:os";
3413
- import { z as z2 } from "zod";
3978
+ import { z as z3 } from "zod";
3414
3979
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3415
3980
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3416
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
+ }
3417
3991
  async function fetchWithTimeout2(url, init = {}) {
3418
3992
  const controller = new AbortController();
3419
3993
  const timer = setTimeout(() => controller.abort(), API_TIMEOUT_MS);
@@ -3471,19 +4045,26 @@ async function apiRequest(token, path, query) {
3471
4045
  }
3472
4046
  throw lastError ?? new Error("API request failed");
3473
4047
  }
3474
- async function installViaApi(token, refText, target) {
4048
+ async function installViaApi(token, refText, target, options = {}) {
3475
4049
  const parsed = parseSkillRef(refText);
3476
4050
  if (!parsed) throw new Error(`Invalid ref: ${refText}`);
4051
+ if (!isInstallTarget(target)) throw new Error(`Invalid install target: ${target}`);
3477
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
+ }
3478
4056
  const dl = await apiRequest(token, `/skills/${parsed.owner}/${parsed.slug}/download`, parsed.version ? { version: parsed.version } : void 0);
3479
4057
  const bundle = await rawGet(dl.download.url);
3480
4058
  if (!verifyBundleHash(bundle, dl.bundle_sha256)) throw new Error("Bundle hash mismatch");
3481
4059
  const install = resolveInstallDir({ target });
3482
- await mkdir6(install.dir, { recursive: true });
3483
- const dest = join10(install.dir, parsed.slug);
4060
+ await mkdir9(install.dir, { recursive: true });
4061
+ const dest = join13(install.dir, parsed.slug);
3484
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
+ }
3485
4066
  if (exists) await rm3(dest, { recursive: true, force: true });
3486
- const temp = await mkdtemp(join10(tmpdir3(), `floom-mcp-${parsed.slug}-`));
4067
+ const temp = await mkdtemp(join13(tmpdir3(), `floom-mcp-${parsed.slug}-`));
3487
4068
  try {
3488
4069
  await extractBundle(bundle, temp);
3489
4070
  await rename3(temp, dest);
@@ -3503,10 +4084,10 @@ async function installViaApi(token, refText, target) {
3503
4084
  preset: target
3504
4085
  });
3505
4086
  await writeLock(process.cwd(), next);
3506
- 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 };
3507
4088
  }
3508
4089
  async function parseSkillBundle(bundle) {
3509
- const tmp = await mkdtemp(join10(tmpdir3(), "floom-mcp-read-"));
4090
+ const tmp = await mkdtemp(join13(tmpdir3(), "floom-mcp-read-"));
3510
4091
  try {
3511
4092
  await extractBundle(bundle, tmp);
3512
4093
  const files = [];
@@ -3514,16 +4095,16 @@ async function parseSkillBundle(bundle) {
3514
4095
  const entries = await readdir4(dir, { withFileTypes: true });
3515
4096
  for (const entry of entries) {
3516
4097
  const nextRel = rel ? `${rel}/${entry.name}` : entry.name;
3517
- const full = join10(dir, entry.name);
4098
+ const full = join13(dir, entry.name);
3518
4099
  if (entry.isDirectory()) await walk2(full, nextRel);
3519
4100
  else files.push(nextRel);
3520
4101
  }
3521
4102
  };
3522
4103
  await walk2(tmp);
3523
4104
  const skillMdPath = files.find((f) => f.toUpperCase() === "SKILL.MD");
3524
- const skillMd = skillMdPath ? await readFile8(join10(tmp, skillMdPath), "utf8") : "";
4105
+ const skillMd = skillMdPath ? await readFile10(join13(tmp, skillMdPath), "utf8") : "";
3525
4106
  const skillJsonPath = files.find((f) => f.toLowerCase() === "skill.json");
3526
- const skillJson = skillJsonPath ? JSON.parse(await readFile8(join10(tmp, skillJsonPath), "utf8")) : null;
4107
+ const skillJson = skillJsonPath ? JSON.parse(await readFile10(join13(tmp, skillJsonPath), "utf8")) : null;
3527
4108
  return { files, skill_md: skillMd, skill_json: skillJson };
3528
4109
  } finally {
3529
4110
  await rm3(tmp, { recursive: true, force: true });
@@ -3531,13 +4112,13 @@ async function parseSkillBundle(bundle) {
3531
4112
  }
3532
4113
  async function mcpCommand() {
3533
4114
  const server = new McpServer({ name: "floom", version: VERSION });
3534
- server.tool("search_skills", { query: z2.string().min(1), workspace: z2.string().optional(), library: z2.string().optional() }, async ({ query, workspace, library }) => {
4115
+ server.tool("search_skills", { query: z3.string().min(1), workspace: z3.string().optional(), library: z3.string().optional() }, async ({ query, workspace, library }) => {
3535
4116
  const token = await resolveRequiredToken();
3536
4117
  const workspaceSlug = workspace ?? library;
3537
4118
  const result = await apiRequest(token, "/skills", { q: query, ...workspaceSlug ? { library: workspaceSlug } : {} });
3538
4119
  return { content: [{ type: "text", text: JSON.stringify(result) }] };
3539
4120
  });
3540
- server.tool("get_skill", { ref: z2.string().min(3) }, async ({ ref }) => {
4121
+ server.tool("get_skill", { ref: z3.string().min(3) }, async ({ ref }) => {
3541
4122
  const token = await resolveOptionalToken();
3542
4123
  const parsed = parseSkillRef(ref);
3543
4124
  if (!parsed) throw new Error("Invalid ref. Expected @owner/slug or workspace/slug");
@@ -3554,9 +4135,61 @@ async function mcpCommand() {
3554
4135
  }
3555
4136
  server.tool("list_workspaces", {}, listWorkspaces);
3556
4137
  server.tool("list_libraries", {}, listWorkspaces);
3557
- server.tool("install_skill", { ref: z2.string().min(3), target: z2.enum(["claude", "codex", "cursor", "kimi", "opencode", "gemini"]) }, async ({ ref, target }) => {
4138
+ server.tool("get_floom_guide", {}, async () => {
4139
+ return {
4140
+ content: [{
4141
+ type: "text",
4142
+ text: JSON.stringify({
4143
+ guide: [
4144
+ "Use Floom to discover approved AI skills without loading every skill into context.",
4145
+ "Call search_skills when you need a skill that is not already installed.",
4146
+ "Call get_skill only after selecting a specific skill candidate.",
4147
+ "Call install_skill only after the user or task requires local installation.",
4148
+ "Use get_instruction for account/workspace guidance visible to this agent."
4149
+ ]
4150
+ })
4151
+ }]
4152
+ };
4153
+ });
4154
+ server.tool("get_instruction", {
4155
+ scope: z3.enum(["account", "workspace"]).default("account"),
4156
+ workspace: z3.string().optional(),
4157
+ target: z3.enum(["default", "claude", "codex", "cursor", "opencode"]).default("default")
4158
+ }, async ({ scope, workspace, target }) => {
4159
+ const token = await resolveRequiredToken();
4160
+ const result = await apiRequest(token, "/instructions", {
4161
+ scope,
4162
+ ...workspace ? { workspace } : {},
4163
+ target
4164
+ });
4165
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
4166
+ });
4167
+ server.tool("list_active_workspaces", {
4168
+ target: z3.enum(["claude", "codex", "cursor", "kimi", "opencode"]).default("codex"),
4169
+ scope: z3.enum(["global", "local"]).default("local")
4170
+ }, async ({ target, scope }) => {
4171
+ const config = await readFloomConfig(normalizeScope(scope));
4172
+ const active = config.targets[assertTarget(target)]?.active_workspaces ?? [];
4173
+ return { content: [{ type: "text", text: JSON.stringify({ target, scope, active_workspaces: active }) }] };
4174
+ });
4175
+ server.tool("list_pinned_skills", {
4176
+ workspace: z3.string().optional(),
4177
+ target: z3.enum(["claude", "codex", "cursor", "kimi", "opencode"]).default("codex")
4178
+ }, async ({ workspace, target }) => {
4179
+ const token = await resolveRequiredToken();
4180
+ const config = await readFloomConfig("local");
4181
+ const workspaceSlug = workspace ?? config.default_workspace;
4182
+ if (!workspaceSlug) throw new Error("workspace is required when no default workspace is configured");
4183
+ const result = await apiRequest(token, `/libraries/${workspaceSlug}/pins`, { target });
4184
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
4185
+ });
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 }) => {
3558
4191
  const token = await resolveOptionalToken();
3559
- const installed = await installViaApi(token, ref, target);
4192
+ const installed = await installViaApi(token, ref, target, { force });
3560
4193
  return { content: [{ type: "text", text: JSON.stringify(installed) }] };
3561
4194
  });
3562
4195
  const transport = new StdioServerTransport();
@@ -3566,7 +4199,7 @@ async function mcpCommand() {
3566
4199
  // src/commands/doctor.ts
3567
4200
  import { mkdtemp as mkdtemp2, readdir as readdir5, rm as rm4 } from "node:fs/promises";
3568
4201
  import { tmpdir as tmpdir4 } from "node:os";
3569
- import { join as join11 } from "node:path";
4202
+ import { join as join14 } from "node:path";
3570
4203
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
3571
4204
  import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
3572
4205
  function textOf(result) {
@@ -3597,6 +4230,15 @@ function warn(name, detail) {
3597
4230
  function fail(name, detail) {
3598
4231
  return { name, ok: false, detail };
3599
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
+ }
3600
4242
  async function validateCurrentToken(token) {
3601
4243
  if (!token) return warn("fresh_agent_auth", "missing token; authenticated MCP calls skipped");
3602
4244
  try {
@@ -3626,7 +4268,7 @@ async function doctorCommand(opts = {}) {
3626
4268
  const checks2 = [
3627
4269
  pass("cli_version", VERSION),
3628
4270
  authCheck2,
3629
- 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)
3630
4272
  ];
3631
4273
  emitDoctor(checks2, opts.json);
3632
4274
  if (checks2.some((check) => !check.ok)) process.exit(1);
@@ -3644,9 +4286,9 @@ async function doctorCommand(opts = {}) {
3644
4286
  emitDoctor(checks, opts.json);
3645
4287
  process.exit(1);
3646
4288
  }
3647
- const tmpHome = await mkdtemp2(join11(tmpdir4(), "floom-doctor-home-"));
3648
- const tmpSkills = await mkdtemp2(join11(tmpdir4(), "floom-doctor-skills-"));
3649
- const tmpProject = await mkdtemp2(join11(tmpdir4(), "floom-doctor-project-"));
4289
+ const tmpHome = await mkdtemp2(join14(tmpdir4(), "floom-doctor-home-"));
4290
+ const tmpSkills = await mkdtemp2(join14(tmpdir4(), "floom-doctor-skills-"));
4291
+ const tmpProject = await mkdtemp2(join14(tmpdir4(), "floom-doctor-project-"));
3650
4292
  const transport = new StdioClientTransport({
3651
4293
  command: process.execPath,
3652
4294
  args: [cliPath, "mcp"],
@@ -3656,7 +4298,7 @@ async function doctorCommand(opts = {}) {
3656
4298
  HOME: tmpHome,
3657
4299
  FLOOM_SKILLS_DIR: tmpSkills,
3658
4300
  ...hasValidToken && token ? { FLOOM_API_TOKEN: token } : {},
3659
- ...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) } : {}
3660
4302
  },
3661
4303
  stderr: "pipe"
3662
4304
  });
@@ -3682,12 +4324,15 @@ async function doctorCommand(opts = {}) {
3682
4324
  } else {
3683
4325
  checks.push(warn("mcp_authenticated_tools", "skipped list_workspaces/search_skills because no valid token is available"));
3684
4326
  }
3685
- const ref = opts.ref ?? "floom-demo/brand-voice";
3686
- const skill = await client.callTool({ name: "get_skill", arguments: { ref } });
3687
- checks.push(/SKILL\.md/.test(textOf(skill)) ? pass("mcp_get_skill", ref) : fail("mcp_get_skill", "bundle did not include SKILL.md"));
3688
- const installed = await client.callTool({ name: "install_skill", arguments: { ref, target: opts.target ?? "codex" } });
3689
- const entries = await readdir5(tmpSkills);
3690
- 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
+ }
3691
4336
  } catch (e) {
3692
4337
  checks.push(fail("fresh_agent_mcp", e.message));
3693
4338
  } finally {
@@ -3730,16 +4375,28 @@ program.command("outdated").description("Show installed skills with newer versio
3730
4375
  program.command("update [ref]").description("Update installed skills to latest.").option("--force", "Overwrite local edits").action((ref, opts) => updateCommand(ref, opts));
3731
4376
  program.command("list").description("List remote skills.").option("--mine", "Only your own skills (default)").option("--workspace <slug>", "Filter by workspace slug").option("--library <slug>", "Legacy alias for --workspace").option("--folder <uuid>", "Filter by folder id").option("--flat", "Print one ref per line").option("--query <q>", "Filter by query").action(listCommand);
3732
4377
  program.command("info <ref>").description("Show details for a remote skill.").action(infoCommand);
4378
+ program.command("status").description("Show local vs remote Floom workspace state").option("--target <target>", "claude | codex | cursor | kimi | opencode", "codex").option("--scope <scope>", "global | local", "local").action((opts) => statusCommand(opts));
4379
+ program.command("pull").description("Pull account/workspace instructions for active workspaces").option("--target <target>", "claude | codex | cursor | kimi | opencode", "codex").option("--scope <scope>", "global | local", "local").option("--apply", "Append managed block when the target file has no Floom block").option("--force", "Write without the first-apply guard").action((opts) => pullCommand(opts));
4380
+ program.command("pin <ref>").description("Pin a workspace skill for local pull").option("--workspace <slug>", "Workspace slug").option("--target <target>", "claude | codex | cursor | kimi | opencode", "codex").action((ref, opts) => pinCommand(ref, opts));
4381
+ 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));
3733
4382
  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));
3734
4383
  program.command("unshare <ref> <email>").description("Revoke someone's access.").action((ref, email) => unshareCommand(ref, email));
4384
+ var configCmd = program.command("config").description("Manage local Floom configuration");
4385
+ configCmd.command("default-workspace [slug]").description("Show or set the default workspace").option("--scope <scope>", "global | local", "local").action((slug, opts) => defaultWorkspaceCommand(slug, opts));
3735
4386
  function addWorkspaceCommands(cmd) {
3736
4387
  cmd.command("list").action(libraryListCommand);
3737
4388
  cmd.command("create <slug> <name>").action((slug, name) => libraryCreateCommand(slug, name));
3738
4389
  cmd.command("invite <workspaceSlug> <email>").option("--role <role>", "viewer|editor|admin", "viewer").action((workspaceSlug, email, opts) => libraryInviteCommand(workspaceSlug, email, opts.role));
3739
4390
  cmd.command("leave <workspaceSlug>").action((workspaceSlug) => libraryLeaveCommand(workspaceSlug));
4391
+ cmd.command("activate <workspaceSlug>").option("--target <target>", "claude | codex | cursor | kimi | opencode", "codex").option("--scope <scope>", "global | local", "local").action((workspaceSlug, opts) => workspaceActivateCommand(workspaceSlug, opts));
4392
+ cmd.command("deactivate <workspaceSlug>").option("--target <target>", "claude | codex | cursor | kimi | opencode", "codex").option("--scope <scope>", "global | local", "local").action((workspaceSlug, opts) => workspaceDeactivateCommand(workspaceSlug, opts));
4393
+ cmd.command("active").option("--target <target>", "claude | codex | cursor | kimi | opencode", "codex").option("--scope <scope>", "global | local", "local").action((opts) => workspaceActiveCommand(opts));
3740
4394
  }
3741
4395
  addWorkspaceCommands(program.command("workspace").description("Manage workspaces"));
3742
4396
  addWorkspaceCommands(program.command("library").description("Manage workspaces (legacy alias)"));
4397
+ var instructionCmd = program.command("instruction").description("Manage account and workspace instructions");
4398
+ instructionCmd.command("pull").description("Pull an account or workspace instruction into a managed local block").option("--account", "Pull account instruction").option("--workspace <slug>", "Pull workspace instruction").option("--target <target>", "default | claude | codex | cursor | opencode", "codex").option("--scope <scope>", "global | local", "local").option("--path <path>", "Instruction file path override").option("--apply", "Append managed block when the target file has no Floom block").option("--force", "Write without the first-apply guard").action((opts) => instructionPullCommand(opts));
4399
+ instructionCmd.command("push <file>").description("Publish an account or workspace instruction from a markdown file").option("--account", "Publish account instruction").option("--workspace <slug>", "Publish workspace instruction").option("--target <target>", "default | claude | codex | cursor | opencode", "default").option("--changelog <text>", "Version changelog").action((file, opts) => instructionPushCommand(file, opts));
3743
4400
  program.command("doctor").description("Check local Floom CLI, auth, and fresh-agent MCP installability.").option("--fresh-agent", "Run MCP checks with a clean HOME and temp skills directory").option("--ref <ref>", "Skill ref to install during --fresh-agent, e.g. @depontefede/pdf").option("--target <target>", "Install target for --fresh-agent", "codex").option("--query <query>", "Search query for --fresh-agent", "pdf").option("--json", "Emit machine-readable JSON").action((opts) => doctorCommand(opts));
3744
4401
  program.command("mcp").description("Run local MCP server over stdio.").action(mcpCommand);
3745
4402
  async function main() {