@floomhq/floom 1.0.11 → 1.0.12

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/README.md CHANGED
@@ -27,7 +27,7 @@ The package is designed for `npx -y @floomhq/floom ...`. Global installs expose
27
27
  - `npx -y @floomhq/floom publish <file.md>` — scan and upload a Markdown file. Optional `--public` / `--private` / `--unlisted`, `--type knowledge|instruction|workflow|skill`, `--installs-as <target>`, `--skill-version <label>`, and `--share <email>`. `--share` sends the normal link by email; no account is needed to add unlisted or public links.
28
28
  - `npx -y @floomhq/floom share <slug>` — email-share one of your skills. Optional `--add <email>`, `--remove <email>`, and `--list`.
29
29
  - `npx -y @floomhq/floom list` — show your published skills. Optional `--json`.
30
- - `npx -y @floomhq/floom add <url-or-slug>` — fetch a skill into `~/.claude/skills/<slug>.md`. Optional `--setup` connects Claude Code, `--force` / `--update` replaces an existing local copy, and `--json` prints machine-readable output.
30
+ - `npx -y @floomhq/floom add <url-or-slug>` — fetch a skill into the local agent skills folder. Optional `--target claude|codex`, `--setup`, `--force` / `--update`, and `--json`.
31
31
  - `npx -y @floomhq/floom update <url-or-slug>` — install the latest remote skill content, replacing the local copy.
32
32
  - `npx -y @floomhq/floom info <url-or-slug>` — show skill metadata. Optional `--json`.
33
33
  - `npx -y @floomhq/floom search <query>` — search public skills and starter libraries. Optional `--library <slug>`, `--type knowledge|instruction|workflow|skill`, and `--json`.
@@ -35,15 +35,15 @@ The package is designed for `npx -y @floomhq/floom ...`. Global installs expose
35
35
  - `npx -y @floomhq/floom setup` — add Floom usage guidance to `CLAUDE.md` or `AGENTS.md`. Optional `--target claude|codex`, `--dry-run`, `--yes`, `--file <path>`.
36
36
  - `npx -y @floomhq/floom connect` — alias for setup.
37
37
  - `npx -y @floomhq/floom mcp` — print MCP setup commands for supported agent CLIs.
38
- - `npx -y @floomhq/floom sync` — preview: pull your published, saved, and subscribed library skills into `~/.claude/skills/`.
39
- - `npx -y @floomhq/floom watch` — preview: run sync repeatedly. Optional `--interval <seconds>`; minimum `10`.
38
+ - `npx -y @floomhq/floom sync` — preview: pull your published, saved, and subscribed library skills into the local agent skills folder. Optional `--target claude|codex`.
39
+ - `npx -y @floomhq/floom watch` — preview: run sync repeatedly. Optional `--target claude|codex`, `--interval <seconds>`; minimum `10`.
40
40
  - `npx -y @floomhq/floom library list` — list public starter libraries. Optional `--json`.
41
41
  - `npx -y @floomhq/floom library create <slug> --name <name>` — create a personal or starter library. Optional `--public` / `--private` / `--unlisted`.
42
42
  - `npx -y @floomhq/floom library add <library> <skill> [--folder <path>] [--tags a,b]` — add a skill to a library.
43
43
  - `npx -y @floomhq/floom library subscribe <slug>` — subscribe to a public or unlisted library so sync can pull it locally.
44
44
  - `npx -y @floomhq/floom move <slug> --folder <path>` — set your local folder override for a saved or library skill.
45
45
  - `npx -y @floomhq/floom delete <url-or-slug>` — delete one of your published skills. Optional `--yes`.
46
- - `npx -y @floomhq/floom doctor` — diagnose your Floom setup. Optional `--json`.
46
+ - `npx -y @floomhq/floom doctor` — diagnose your Floom setup. Optional `--target claude|codex`, `--json`.
47
47
  - `npx -y @floomhq/floom whoami` — show the signed-in account.
48
48
  - `npx -y @floomhq/floom logout` — delete local credentials.
49
49
 
package/dist/cli.js CHANGED
@@ -104,7 +104,7 @@ function commandUsage() {
104
104
  ${c.dim("Alias: paste")}
105
105
  ${c.dim("Flags: --target claude|codex")}
106
106
  ${c.cyan("doctor")} Troubleshoot auth, API, and local folders
107
- ${c.dim("Flags: --json")}
107
+ ${c.dim("Flags: --target claude|codex, --json")}
108
108
 
109
109
  ${c.dim("Advanced")}
110
110
  ${c.cyan("library")} Create, browse, and subscribe to libraries
@@ -112,7 +112,9 @@ function commandUsage() {
112
112
  ${c.cyan("move")} ${c.dim("<slug> --folder <path>")} Place a saved skill in a local folder
113
113
  ${c.cyan("mcp")} Print optional MCP setup guidance
114
114
  ${c.cyan("sync")} Preview pull of published, saved, and library skills
115
+ ${c.dim("Flags: --target claude|codex")}
115
116
  ${c.cyan("watch")} Preview polling sync loop
117
+ ${c.dim("Flags: --target claude|codex, --interval <seconds>")}
116
118
 
117
119
  ${c.bold("Examples")}
118
120
  ${c.cyan("npx -y @floomhq/floom add")} ${c.dim("https://floom.dev/s/ffas93ud --setup")}
@@ -362,15 +364,27 @@ function parseAddArgs(argv) {
362
364
  }
363
365
  return target ? { slug, target, setup, force, json } : { slug, setup, force, json };
364
366
  }
367
+ function parseTargetFlag(value) {
368
+ if (value !== "claude" && value !== "codex") {
369
+ throw new FloomError("Invalid --target.", "Use `claude` or `codex`.");
370
+ }
371
+ return value;
372
+ }
365
373
  function parseDoctorArgs(argv) {
366
374
  const out = { json: false };
367
- for (const a of argv) {
375
+ for (let i = 0; i < argv.length; i++) {
376
+ const a = argv[i] ?? "";
368
377
  if (a === "--json")
369
378
  out.json = true;
379
+ else if (a === "--target" || a.startsWith("--target=")) {
380
+ const { value, nextIndex } = readFlagValue(argv, i, "--target");
381
+ out.target = parseTargetFlag(value);
382
+ i = nextIndex;
383
+ }
370
384
  else if (a.startsWith("--"))
371
- throw new FloomError(`Unknown flag: ${a}`, "Try `npx -y @floomhq/floom doctor --json`.");
385
+ throw new FloomError(`Unknown flag: ${a}`, "Try `npx -y @floomhq/floom doctor --target codex --json`.");
372
386
  else
373
- throw new FloomError(`Unexpected argument: ${a}`, "Try `npx -y @floomhq/floom doctor --json`.");
387
+ throw new FloomError(`Unexpected argument: ${a}`, "Try `npx -y @floomhq/floom doctor --target codex --json`.");
374
388
  }
375
389
  return out;
376
390
  }
@@ -634,11 +648,34 @@ function parseWatchFlags(argv) {
634
648
  out.intervalSeconds = interval;
635
649
  i = nextIndex;
636
650
  }
651
+ else if (a === "--target" || a.startsWith("--target=")) {
652
+ const { value, nextIndex } = readFlagValue(argv, i, "--target");
653
+ out.target = parseTargetFlag(value);
654
+ i = nextIndex;
655
+ }
656
+ else if (a.startsWith("--")) {
657
+ throw new FloomError(`Unknown flag: ${a}`, "Try `npx -y @floomhq/floom watch --target codex --interval 60`.");
658
+ }
659
+ else {
660
+ throw new FloomError(`Unexpected argument: ${a}`, "Try `npx -y @floomhq/floom watch --target codex --interval 60`.");
661
+ }
662
+ }
663
+ return out;
664
+ }
665
+ function parseSyncFlags(argv) {
666
+ const out = {};
667
+ for (let i = 0; i < argv.length; i++) {
668
+ const a = argv[i] ?? "";
669
+ if (a === "--target" || a.startsWith("--target=")) {
670
+ const { value, nextIndex } = readFlagValue(argv, i, "--target");
671
+ out.target = parseTargetFlag(value);
672
+ i = nextIndex;
673
+ }
637
674
  else if (a.startsWith("--")) {
638
- throw new FloomError(`Unknown flag: ${a}`, "Try `npx -y @floomhq/floom watch --interval 60`.");
675
+ throw new FloomError(`Unknown flag: ${a}`, "Try `npx -y @floomhq/floom sync --target codex`.");
639
676
  }
640
677
  else {
641
- throw new FloomError(`Unexpected argument: ${a}`, "Try `npx -y @floomhq/floom watch --interval 60`.");
678
+ throw new FloomError(`Unexpected argument: ${a}`, "Try `npx -y @floomhq/floom sync --target codex`.");
642
679
  }
643
680
  }
644
681
  return out;
@@ -674,7 +711,7 @@ function sleep(ms, signal) {
674
711
  }, { once: true });
675
712
  });
676
713
  }
677
- async function watch(intervalSeconds) {
714
+ async function watch(intervalSeconds, target = "claude") {
678
715
  const cfg = await readConfig();
679
716
  if (!cfg) {
680
717
  throw new FloomError("Not signed in.", "Run `npx -y @floomhq/floom login` before watch, or use `npx -y @floomhq/floom add <link>` without an account.");
@@ -691,9 +728,9 @@ async function watch(intervalSeconds) {
691
728
  };
692
729
  process.on("SIGINT", stop);
693
730
  process.on("SIGTERM", stop);
694
- process.stdout.write(`${symbols.bullet} Watching Floom sync every ${intervalSeconds}s. Press Ctrl-C to stop.\n`);
731
+ process.stdout.write(`${symbols.bullet} Watching Floom ${target} sync every ${intervalSeconds}s. Press Ctrl-C to stop.\n`);
695
732
  while (!controller.signal.aborted) {
696
- await sync({ spinner: false, quietUnchanged: true });
733
+ await sync({ spinner: false, quietUnchanged: true, target });
697
734
  await sleep(intervalSeconds * 1000, controller.signal);
698
735
  }
699
736
  }
@@ -843,8 +880,10 @@ async function main() {
843
880
  return;
844
881
  }
845
882
  case "sync":
846
- rejectArgs(rest, "Try `npx -y @floomhq/floom sync`.");
847
- await sync();
883
+ {
884
+ const flags = parseSyncFlags(rest);
885
+ await sync(flags);
886
+ }
848
887
  return;
849
888
  case "setup":
850
889
  case "connect": {
@@ -860,7 +899,7 @@ async function main() {
860
899
  }
861
900
  case "watch": {
862
901
  const flags = parseWatchFlags(rest);
863
- await watch(flags.intervalSeconds);
902
+ await watch(flags.intervalSeconds, flags.target ?? "claude");
864
903
  return;
865
904
  }
866
905
  case "delete":
package/dist/doctor.js CHANGED
@@ -5,6 +5,7 @@ import { readConfig, CONFIG_PATH, resolveApiUrl } from "./config.js";
5
5
  import { floomFetch } from "./lib/api.js";
6
6
  import { c, symbols } from "./ui.js";
7
7
  import { CLI_VERSION, compareSemverish, formatVersionLabel } from "./version.js";
8
+ import { resolveSkillsDir } from "./targets.js";
8
9
  function statusBadge(s) {
9
10
  if (s === "ok")
10
11
  return c.green(symbols.ok);
@@ -97,27 +98,24 @@ async function checkMcp() {
97
98
  detail: `Registered with: ${found.map((f) => f.name).join(", ")}`,
98
99
  };
99
100
  }
100
- function targetSkillsDir() {
101
- return process.env.CLAUDE_SKILLS_DIR ?? join(homedir(), ".claude", "skills");
102
- }
103
- async function checkTargetDir() {
104
- const dir = targetSkillsDir();
101
+ async function checkTargetDir(target) {
102
+ const dir = resolveSkillsDir(target);
105
103
  try {
106
104
  const s = await stat(dir);
107
105
  if (!s.isDirectory()) {
108
106
  return {
109
- name: "Target dir",
107
+ name: `${target} dir`,
110
108
  status: "fail",
111
109
  detail: `${dir} exists but is not a directory.`,
112
110
  };
113
111
  }
114
112
  try {
115
113
  await access(dir, constants.W_OK);
116
- return { name: "Target dir", status: "ok", detail: `${dir} (writable)` };
114
+ return { name: `${target} dir`, status: "ok", detail: `${dir} (writable)` };
117
115
  }
118
116
  catch {
119
117
  return {
120
- name: "Target dir",
118
+ name: `${target} dir`,
121
119
  status: "fail",
122
120
  detail: `${dir} is not writable.`,
123
121
  hint: `Try: chmod u+w ${dir}`,
@@ -127,7 +125,7 @@ async function checkTargetDir() {
127
125
  catch (err) {
128
126
  if (err.code === "ENOENT") {
129
127
  return {
130
- name: "Target dir",
128
+ name: `${target} dir`,
131
129
  status: "warn",
132
130
  detail: `${dir} does not exist yet.`,
133
131
  hint: "It will be created on first `npx -y @floomhq/floom add`.",
@@ -136,14 +134,14 @@ async function checkTargetDir() {
136
134
  throw err;
137
135
  }
138
136
  }
139
- async function checkLastSync() {
140
- const dir = targetSkillsDir();
137
+ async function checkLastSync(target) {
138
+ const dir = resolveSkillsDir(target);
141
139
  try {
142
140
  const entries = await readdir(dir);
143
141
  const skills = entries.filter((e) => e.endsWith(".md") || !e.startsWith("."));
144
142
  if (skills.length === 0) {
145
143
  return {
146
- name: "Last sync",
144
+ name: `${target} sync`,
147
145
  status: "warn",
148
146
  detail: "No synced skills found yet.",
149
147
  hint: "Run `npx -y @floomhq/floom add <link>` to install your first skill.",
@@ -163,7 +161,7 @@ async function checkLastSync() {
163
161
  }
164
162
  if (newest.mtime === 0) {
165
163
  return {
166
- name: "Last sync",
164
+ name: `${target} sync`,
167
165
  status: "warn",
168
166
  detail: `${skills.length} entries, no readable mtime.`,
169
167
  };
@@ -177,7 +175,7 @@ async function checkLastSync() {
177
175
  ? `${Math.round(ageSec / 3600)}h ago`
178
176
  : `${Math.round(ageSec / 86400)}d ago`;
179
177
  return {
180
- name: "Last sync",
178
+ name: `${target} sync`,
181
179
  status: "ok",
182
180
  detail: `${newest.name} — ${human} (${skills.length} total)`,
183
181
  };
@@ -185,13 +183,13 @@ async function checkLastSync() {
185
183
  catch (err) {
186
184
  if (err.code === "ENOENT") {
187
185
  return {
188
- name: "Last sync",
186
+ name: `${target} sync`,
189
187
  status: "warn",
190
188
  detail: "Skills dir not created yet.",
191
189
  };
192
190
  }
193
191
  return {
194
- name: "Last sync",
192
+ name: `${target} sync`,
195
193
  status: "warn",
196
194
  detail: `Cannot read skills dir: ${err.message}`,
197
195
  };
@@ -240,11 +238,12 @@ async function checkVersion() {
240
238
  }
241
239
  }
242
240
  export async function doctor(opts = {}) {
241
+ const target = opts.target ?? "claude";
243
242
  const checks = await Promise.all([
244
243
  checkAuth(),
245
244
  checkMcp(),
246
- checkTargetDir(),
247
- checkLastSync(),
245
+ checkTargetDir(target),
246
+ checkLastSync(target),
248
247
  checkVersion(),
249
248
  ]);
250
249
  const anyFail = checks.some((c) => c.status === "fail");
@@ -255,6 +254,7 @@ export async function doctor(opts = {}) {
255
254
  ok: !anyFail,
256
255
  status,
257
256
  version: CLI_VERSION,
257
+ target,
258
258
  config_path: CONFIG_PATH,
259
259
  checks,
260
260
  }, null, 2)}\n`);
package/dist/install.js CHANGED
@@ -1,13 +1,13 @@
1
1
  import { constants } from "node:fs";
2
2
  import { lstat, mkdir, open } from "node:fs/promises";
3
3
  import { createHash } from "node:crypto";
4
- import { homedir } from "node:os";
5
4
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
6
5
  import ora from "ora";
7
6
  import { readConfig, resolveApiUrl } from "./config.js";
8
7
  import { getJson } from "./lib/api.js";
9
8
  import { c, symbols } from "./ui.js";
10
9
  import { FloomError } from "./errors.js";
10
+ import { resolveSkillsDir, skillsDirHint } from "./targets.js";
11
11
  const SLUG_RE = /^[A-Za-z0-9_-]{1,128}$/;
12
12
  const FD_PATH_ROOT = "/proc/self/fd";
13
13
  function slugFromInput(input) {
@@ -21,19 +21,9 @@ function slugFromInput(input) {
21
21
  return trimmed.replace(/\.(md|json)$/i, "");
22
22
  }
23
23
  }
24
- function skillsDir(target) {
25
- if (target === "codex") {
26
- const codexHome = process.env.CODEX_HOME ?? join(homedir(), ".codex");
27
- return process.env.CODEX_SKILLS_DIR ?? join(codexHome, "skills");
28
- }
29
- return process.env.CLAUDE_SKILLS_DIR ?? join(homedir(), ".claude", "skills");
30
- }
31
24
  function skillPath(root, slug) {
32
25
  return join(root, `${slug}.md`);
33
26
  }
34
- function skillsDirHint(target) {
35
- return target === "codex" ? "CODEX_SKILLS_DIR" : "CLAUDE_SKILLS_DIR";
36
- }
37
27
  function setupCommand(target) {
38
28
  return `npx -y @floomhq/floom setup --target ${target} --yes`;
39
29
  }
@@ -152,7 +142,7 @@ async function assertSafeDirectory(path) {
152
142
  }
153
143
  export async function install(slugInput, opts = {}) {
154
144
  const targetAgent = opts.target ?? "claude";
155
- const root = skillsDir(targetAgent);
145
+ const root = resolveSkillsDir(targetAgent);
156
146
  const slug = slugFromInput(slugInput);
157
147
  if (!SLUG_RE.test(slug)) {
158
148
  throw new FloomError(`Invalid skill slug: ${slugInput}`);
package/dist/login.js CHANGED
@@ -60,15 +60,17 @@ export async function login() {
60
60
  function waitForCallback() {
61
61
  return new Promise((resolve, reject) => {
62
62
  const apiUrl = getApiUrl();
63
+ const allowedOrigin = new URL(apiUrl).origin;
63
64
  const state = randomBytes(24).toString("base64url");
64
65
  let settled = false;
65
66
  let retriedEphemeralPort = false;
66
67
  const server = createServer((req, res) => {
67
68
  // CORS preflight from the browser bridge page.
68
- const origin = req.headers.origin ?? "*";
69
+ const origin = req.headers.origin;
70
+ const corsOrigin = origin === allowedOrigin ? origin : "null";
69
71
  if (req.method === "OPTIONS") {
70
72
  res.writeHead(204, {
71
- "access-control-allow-origin": origin,
73
+ "access-control-allow-origin": corsOrigin,
72
74
  "access-control-allow-methods": "POST, OPTIONS",
73
75
  "access-control-allow-headers": "content-type",
74
76
  "access-control-allow-private-network": "true",
@@ -85,7 +87,7 @@ function waitForCallback() {
85
87
  const data = parseCallbackBody(body, req.headers["content-type"]);
86
88
  if (!data.access_token || !data.refresh_token) {
87
89
  res.writeHead(400, {
88
- "access-control-allow-origin": origin,
90
+ "access-control-allow-origin": corsOrigin,
89
91
  "access-control-allow-private-network": "true",
90
92
  "content-type": "text/html; charset=utf-8",
91
93
  });
@@ -94,7 +96,7 @@ function waitForCallback() {
94
96
  }
95
97
  if (data.state !== state) {
96
98
  res.writeHead(400, {
97
- "access-control-allow-origin": origin,
99
+ "access-control-allow-origin": corsOrigin,
98
100
  "access-control-allow-private-network": "true",
99
101
  "content-type": "text/html; charset=utf-8",
100
102
  });
@@ -102,7 +104,7 @@ function waitForCallback() {
102
104
  return;
103
105
  }
104
106
  res.writeHead(200, {
105
- "access-control-allow-origin": origin,
107
+ "access-control-allow-origin": corsOrigin,
106
108
  "access-control-allow-private-network": "true",
107
109
  "content-type": "text/html; charset=utf-8",
108
110
  });
@@ -112,7 +114,7 @@ function waitForCallback() {
112
114
  resolve(data);
113
115
  }
114
116
  catch {
115
- res.writeHead(400, { "content-type": "text/html; charset=utf-8", "access-control-allow-origin": origin });
117
+ res.writeHead(400, { "content-type": "text/html; charset=utf-8", "access-control-allow-origin": corsOrigin });
116
118
  res.end(localCallbackPage("Invalid OAuth response."));
117
119
  }
118
120
  });
package/dist/secrets.js CHANGED
@@ -38,6 +38,9 @@ const PROMPT_INJECTION_PATTERNS = [
38
38
  const DATA_EXFILTRATION_PATTERNS = [
39
39
  { label: "Data exfiltration instruction", regex: /\b(?:send|post|upload|exfiltrate|copy) (?:[^.\n]{0,80})\b(?:api keys?|tokens?|secrets?|environment variables|\.env|credentials)\b(?:[^.\n]{0,120})\b(?:to|into) https?:\/\//gi },
40
40
  { label: "Data exfiltration instruction", regex: /\b(?:send|post|upload|exfiltrate|copy)\b[^.\n]{0,120}(?:~\/\.ssh\/[A-Za-z0-9_.-]+|id_rsa|id_ed25519|ssh keys?|private keys?|secret files?)\b[^.\n]{0,120}\b(?:to|into) https?:\/\//gi },
41
+ { label: "Data exfiltration instruction", regex: /\b(?:send|email|mail|forward|share|transmit|post|upload|exfiltrate|copy)\b[^.\n]{0,160}\b(?:api keys?|tokens?|secrets?|environment variables|\.env|credentials)\b[^.\n]{0,160}\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi },
42
+ { label: "Data exfiltration instruction", regex: /\b(?:send|email|mail|forward|share|transmit|post|upload|exfiltrate|copy)\b[^.\n]{0,160}(?:~\/\.ssh\/[A-Za-z0-9_.-]+|id_rsa|id_ed25519|ssh keys?|private keys?|secret files?)\b[^.\n]{0,160}\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi },
43
+ { label: "Data exfiltration instruction", regex: /(?:~\/\.ssh\/[A-Za-z0-9_.-]+|id_rsa|id_ed25519|ssh keys?|private keys?|secret files?|\bapi keys?|\btokens?|\bsecrets?|\benvironment variables|\.env|\bcredentials)\b[^.\n]{0,160}\b(?:send|email|mail|forward|share|transmit|post|upload|exfiltrate|copy)\b[^.\n]{0,120}\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi },
41
44
  { label: "Data exfiltration instruction", regex: /\b(?:curl|wget|fetch)\b[^\n]{0,160}\b(?:api keys?|tokens?|secrets?|environment variables|\.env|credentials)\b/gi },
42
45
  { label: "Data exfiltration instruction", regex: /\b(?:curl|wget|fetch)\b[^\n]{0,160}(?:~\/\.ssh\/[A-Za-z0-9_.-]+|id_rsa|id_ed25519|ssh keys?|private keys?|secret files?)\b/gi },
43
46
  { label: "Credential harvesting instruction", regex: /\b(?:collect|harvest|steal|extract) (?:[^.\n]{0,80})\b(?:api keys?|tokens?|secrets?|environment variables|\.env|credentials)\b/gi },
@@ -3,9 +3,14 @@ import { lstat, mkdir, open, rename } from "node:fs/promises";
3
3
  import { join, relative, resolve, sep } from "node:path";
4
4
  import { CONFIG_DIR } from "./config.js";
5
5
  const MANIFEST_VERSION = 1;
6
- const MANIFEST_PATH = join(CONFIG_DIR, "sync-manifest.json");
7
6
  const SLUG_RE = /^[A-Za-z0-9_-]{1,128}$/;
8
7
  const FD_PATH_ROOT = "/proc/self/fd";
8
+ function manifestFilename(scope = "claude") {
9
+ return scope === "claude" ? "sync-manifest.json" : `sync-manifest.${scope}.json`;
10
+ }
11
+ function manifestPath(scope = "claude") {
12
+ return join(CONFIG_DIR, manifestFilename(scope));
13
+ }
9
14
  function emptyManifest() {
10
15
  return { version: MANIFEST_VERSION, files: {} };
11
16
  }
@@ -21,10 +26,10 @@ function isEntryForKey(key, value) {
21
26
  SLUG_RE.test(entry.slug) &&
22
27
  key.split("/").at(-1) === `${entry.slug}.md`);
23
28
  }
24
- export async function readSyncManifest() {
29
+ export async function readSyncManifest(scope = "claude") {
25
30
  try {
26
31
  await ensureSyncManifestDir();
27
- const handle = await open(MANIFEST_PATH, constants.O_RDONLY | constants.O_NOFOLLOW);
32
+ const handle = await open(manifestPath(scope), constants.O_RDONLY | constants.O_NOFOLLOW);
28
33
  let body;
29
34
  try {
30
35
  body = await handle.readFile("utf8");
@@ -51,10 +56,11 @@ export async function readSyncManifest() {
51
56
  throw err;
52
57
  }
53
58
  }
54
- export async function writeSyncManifest(manifest) {
59
+ export async function writeSyncManifest(manifest, scope = "claude") {
55
60
  await ensureSyncManifestDir();
56
61
  const dir = await open(CONFIG_DIR, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
57
- const tmpBase = `sync-manifest.json.${process.pid}.${Date.now()}`;
62
+ const filename = manifestFilename(scope);
63
+ const tmpBase = `${filename}.${process.pid}.${Date.now()}`;
58
64
  const body = JSON.stringify(manifest, null, 2);
59
65
  try {
60
66
  for (let attempt = 0; attempt < 10; attempt += 1) {
@@ -66,7 +72,7 @@ export async function writeSyncManifest(manifest) {
66
72
  await handle.writeFile(body, "utf8");
67
73
  await handle.close();
68
74
  handle = null;
69
- await rename(tmpPath, childPath(dir, CONFIG_DIR, "sync-manifest.json"));
75
+ await rename(tmpPath, childPath(dir, CONFIG_DIR, filename));
70
76
  return;
71
77
  }
72
78
  catch (err) {
package/dist/sync.js CHANGED
@@ -1,7 +1,6 @@
1
1
  import { constants } from "node:fs";
2
2
  import { lstat, mkdir, open } from "node:fs/promises";
3
3
  import { createHash } from "node:crypto";
4
- import { homedir } from "node:os";
5
4
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
6
5
  import ora from "ora";
7
6
  import { readConfig, resolveApiUrl } from "./config.js";
@@ -9,13 +8,11 @@ import { getJson } from "./lib/api.js";
9
8
  import { c, symbols } from "./ui.js";
10
9
  import { FloomError } from "./errors.js";
11
10
  import { ensureSyncManifestDir, manifestKey, markSynced, readSyncManifest, unmarkSynced, writeSyncManifest } from "./sync-manifest.js";
11
+ import { resolveSkillsDir } from "./targets.js";
12
12
  const SLUG_RE = /^[A-Za-z0-9_-]{1,128}$/;
13
13
  const PATH_SEGMENT_RE = /^[a-z0-9._-]{1,128}$/;
14
14
  const MANIFEST_SEGMENT_RE = /^[A-Za-z0-9._-]{1,128}$/;
15
15
  const FD_PATH_ROOT = "/proc/self/fd";
16
- function skillsDir() {
17
- return process.env.CLAUDE_SKILLS_DIR ?? join(homedir(), ".claude", "skills");
18
- }
19
16
  function sha256(input) {
20
17
  return createHash("sha256").update(input).digest("hex");
21
18
  }
@@ -56,10 +53,9 @@ function safePathSegments(value, label) {
56
53
  }
57
54
  return segments;
58
55
  }
59
- function skillPath(skill) {
56
+ function skillPath(root, skill) {
60
57
  if (!SLUG_RE.test(skill.slug))
61
58
  throw new FloomError(`Invalid skill slug: ${skill.slug}`);
62
- const root = skillsDir();
63
59
  const segments = [root];
64
60
  segments.push(...safePathSegments(skill.library_slug, "library slug"));
65
61
  segments.push(...safePathSegments(skill.folder, "folder"));
@@ -107,8 +103,8 @@ function targetFromManifestKey(root, key) {
107
103
  }
108
104
  return target;
109
105
  }
110
- async function writeSyncedFile(target, body) {
111
- const parent = await openSafeParentDirectory(skillsDir(), target, true);
106
+ async function writeSyncedFile(root, target, body) {
107
+ const parent = await openSafeParentDirectory(root, target, true);
112
108
  let handle = null;
113
109
  try {
114
110
  handle = await open(childCreatePath(parent, dirname(target), basename(target)), constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
@@ -206,9 +202,11 @@ export async function sync(opts = {}) {
206
202
  const cfg = await readConfig();
207
203
  if (!cfg)
208
204
  throw new FloomError("Not signed in.", "Run `npx -y @floomhq/floom login` first.");
205
+ const targetAgent = opts.target ?? "claude";
206
+ const root = resolveSkillsDir(targetAgent);
209
207
  await ensureSyncManifestDir();
210
208
  const apiUrl = resolveApiUrl(cfg);
211
- const spinner = opts.spinner === false ? null : ora({ text: c.dim("Syncing skills..."), color: "yellow" }).start();
209
+ const spinner = opts.spinner === false ? null : ora({ text: c.dim(`Syncing ${targetAgent} skills...`), color: "yellow" }).start();
212
210
  let payload;
213
211
  try {
214
212
  payload = await getJson(`${apiUrl}/api/v1/me/skills`, "load your skills", cfg.accessToken);
@@ -217,7 +215,7 @@ export async function sync(opts = {}) {
217
215
  spinner?.stop();
218
216
  throw err;
219
217
  }
220
- await mkdir(skillsDir(), { recursive: true, mode: 0o700 });
218
+ await mkdir(root, { recursive: true, mode: 0o700 });
221
219
  if (!Array.isArray(payload.skills)) {
222
220
  throw new FloomError("Invalid sync response.");
223
221
  }
@@ -231,8 +229,7 @@ export async function sync(opts = {}) {
231
229
  let skipped = 0;
232
230
  let conflicts = 0;
233
231
  const conflictNotes = [];
234
- const manifest = await readSyncManifest();
235
- const root = skillsDir();
232
+ const manifest = await readSyncManifest(targetAgent);
236
233
  const activeTargetKeys = new Set();
237
234
  const pruneBlockedSlugs = new Set();
238
235
  let manifestChanged = false;
@@ -257,7 +254,7 @@ export async function sync(opts = {}) {
257
254
  }
258
255
  let target;
259
256
  try {
260
- target = skillPath(skill);
257
+ target = skillPath(root, skill);
261
258
  }
262
259
  catch (err) {
263
260
  if (err instanceof FloomError) {
@@ -312,7 +309,7 @@ export async function sync(opts = {}) {
312
309
  continue;
313
310
  }
314
311
  try {
315
- await writeSyncedFile(target, skill.body_md);
312
+ await writeSyncedFile(root, target, skill.body_md);
316
313
  }
317
314
  catch (err) {
318
315
  const code = err.code;
@@ -327,7 +324,7 @@ export async function sync(opts = {}) {
327
324
  throw err;
328
325
  }
329
326
  markSynced(manifest, targetKey, skill.slug, remoteHash);
330
- await writeSyncManifest(manifest);
327
+ await writeSyncManifest(manifest, targetAgent);
331
328
  updated += 1;
332
329
  }
333
330
  if (payload.full_sync === true) {
@@ -378,7 +375,7 @@ export async function sync(opts = {}) {
378
375
  }
379
376
  }
380
377
  if (manifestChanged)
381
- await writeSyncManifest(manifest);
378
+ await writeSyncManifest(manifest, targetAgent);
382
379
  }
383
380
  catch (err) {
384
381
  spinner?.stop();
@@ -394,7 +391,8 @@ export async function sync(opts = {}) {
394
391
  process.stderr.write(`${symbols.bullet} [floom] skipped local conflict: ${note}\n`);
395
392
  }
396
393
  if (conflicts > 0) {
397
- process.stderr.write(` ${c.dim("Move or delete the local file, then run `npx -y @floomhq/floom sync` again.")}\n`);
394
+ const targetFlag = targetAgent === "claude" ? "" : ` --target ${targetAgent}`;
395
+ process.stderr.write(` ${c.dim(`Move or delete the local file, then run \`npx -y @floomhq/floom sync${targetFlag}\` again.`)}\n`);
398
396
  }
399
397
  process.stdout.write(`\n${symbols.ok} [floom] synced ${synced} skills (${unchanged} unchanged, ${updated} updated${conflictNote})${skippedNote}\n\n`);
400
398
  }
@@ -0,0 +1,12 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+ export function resolveSkillsDir(target) {
4
+ if (target === "codex") {
5
+ const codexHome = process.env.CODEX_HOME ?? join(homedir(), ".codex");
6
+ return process.env.CODEX_SKILLS_DIR ?? join(codexHome, "skills");
7
+ }
8
+ return process.env.CLAUDE_SKILLS_DIR ?? join(homedir(), ".claude", "skills");
9
+ }
10
+ export function skillsDirHint(target) {
11
+ return target === "codex" ? "CODEX_SKILLS_DIR" : "CLAUDE_SKILLS_DIR";
12
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floomhq/floom",
3
- "version": "1.0.11",
3
+ "version": "1.0.12",
4
4
  "description": "Publish AI skills from your terminal. Share with a link.",
5
5
  "license": "MIT",
6
6
  "type": "module",