@floomhq/floom 1.0.25 → 1.0.27

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.
@@ -0,0 +1,245 @@
1
+ import { constants } from "node:fs";
2
+ import { mkdir, open, readdir, rename } from "node:fs/promises";
3
+ import { createHash } from "node:crypto";
4
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
+ import { CONFIG_DIR } from "./config.js";
6
+ import { FloomError } from "./errors.js";
7
+ import { publishSkillPath } from "./publish.js";
8
+ import { readSkillPackage } from "./package.js";
9
+ import { manifestKey, markSynced, readSyncManifest, writeSyncManifest } from "./sync-manifest.js";
10
+ import { c, symbols } from "./ui.js";
11
+ import { targetSkillsDir } from "./targets.js";
12
+ const MANIFEST_VERSION = 1;
13
+ const PUSH_MANIFEST_PATH = join(CONFIG_DIR, "push-manifest.json");
14
+ const MAX_SCAN_DEPTH = 8;
15
+ const SKIP_DIRS = new Set([".cache", ".git", ".next", ".pytest_cache", "__pycache__", "build", "coverage", "dist", "node_modules", "out"]);
16
+ const SLUG_RE = /^[A-Za-z0-9_-]{1,128}$/;
17
+ function emptyManifest() {
18
+ return { version: MANIFEST_VERSION, files: {} };
19
+ }
20
+ async function readPushManifest() {
21
+ try {
22
+ const handle = await open(PUSH_MANIFEST_PATH, constants.O_RDONLY | constants.O_NOFOLLOW);
23
+ try {
24
+ const parsed = JSON.parse(await handle.readFile("utf8"));
25
+ if (parsed.version !== MANIFEST_VERSION || !parsed.files || typeof parsed.files !== "object")
26
+ return emptyManifest();
27
+ return parsed;
28
+ }
29
+ finally {
30
+ await handle.close();
31
+ }
32
+ }
33
+ catch (err) {
34
+ if (err.code === "ENOENT")
35
+ return emptyManifest();
36
+ if (err instanceof SyntaxError)
37
+ return emptyManifest();
38
+ throw err;
39
+ }
40
+ }
41
+ async function writePushManifest(manifest) {
42
+ await mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 });
43
+ const tmp = join(CONFIG_DIR, `.push-manifest.${process.pid}.${Date.now()}.tmp`);
44
+ const handle = await open(tmp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
45
+ try {
46
+ await handle.writeFile(JSON.stringify(manifest, null, 2));
47
+ }
48
+ finally {
49
+ await handle.close();
50
+ }
51
+ await rename(tmp, PUSH_MANIFEST_PATH);
52
+ }
53
+ function hashPackage(skillPath, body, files) {
54
+ const hash = createHash("sha256");
55
+ hash.update(skillPath);
56
+ hash.update("\0");
57
+ hash.update(body);
58
+ for (const file of files) {
59
+ hash.update("\0");
60
+ hash.update(file.path);
61
+ hash.update("\0");
62
+ hash.update(file.sha256);
63
+ }
64
+ return hash.digest("hex");
65
+ }
66
+ async function findSkillPackages(root) {
67
+ const out = [];
68
+ async function walk(dir, depth) {
69
+ if (depth > MAX_SCAN_DEPTH)
70
+ return;
71
+ let entries;
72
+ try {
73
+ entries = await readdir(dir, { withFileTypes: true });
74
+ }
75
+ catch (err) {
76
+ if (err.code === "ENOENT")
77
+ return;
78
+ throw err;
79
+ }
80
+ if (entries.some((entry) => entry.isFile() && entry.name === "SKILL.md")) {
81
+ out.push(dir);
82
+ }
83
+ for (const entry of entries) {
84
+ if (!entry.isDirectory() || entry.name.startsWith(".") || SKIP_DIRS.has(entry.name))
85
+ continue;
86
+ if (entry.isSymbolicLink())
87
+ continue;
88
+ await walk(join(dir, entry.name), depth + 1);
89
+ }
90
+ }
91
+ await walk(root, 0);
92
+ return out.sort();
93
+ }
94
+ function safeRootRelative(root, path) {
95
+ const relativePath = relative(resolve(root), resolve(path));
96
+ if (relativePath === ".." || relativePath.startsWith(`..${sep}`) || isAbsolute(relativePath)) {
97
+ throw new FloomError("Invalid watched skill path.");
98
+ }
99
+ return relativePath.split(sep).join("/");
100
+ }
101
+ function slugFromSyncManifest(root, skillFilePath, syncManifest) {
102
+ const key = manifestKey(root, skillFilePath);
103
+ const entry = syncManifest.files[key];
104
+ return entry?.slug ?? null;
105
+ }
106
+ function slugFromPushManifest(key, pushManifest) {
107
+ return pushManifest.files[key]?.slug ?? null;
108
+ }
109
+ function pushManifestKey(target, relativeSkillPath) {
110
+ return `${target}:${relativeSkillPath}`;
111
+ }
112
+ function isUnchangedSyncedPackage(root, skillPackage, syncManifest) {
113
+ const files = [
114
+ { target: skillPackage.skillPath, hash: createHash("sha256").update(skillPackage.skillBody).digest("hex") },
115
+ ...skillPackage.packageFiles.map((file) => ({
116
+ target: join(dirname(skillPackage.skillPath), ...file.path.split("/")),
117
+ hash: file.sha256,
118
+ })),
119
+ ];
120
+ return files.every((file) => syncManifest.files[manifestKey(root, file.target)]?.hash === file.hash);
121
+ }
122
+ function markPackageSynced(root, skillPackage, syncManifest, slug) {
123
+ markSynced(syncManifest, manifestKey(root, skillPackage.skillPath), slug, createHash("sha256").update(skillPackage.skillBody).digest("hex"));
124
+ for (const file of skillPackage.packageFiles) {
125
+ markSynced(syncManifest, manifestKey(root, join(dirname(skillPackage.skillPath), ...file.path.split("/"))), slug, file.sha256);
126
+ }
127
+ }
128
+ function fallbackSlugFromPath(packagePath) {
129
+ const slug = packagePath.split(/[\\/]/).filter(Boolean).at(-1);
130
+ return slug && SLUG_RE.test(slug) ? slug : null;
131
+ }
132
+ export async function pushWatchOnce(opts) {
133
+ const root = targetSkillsDir(opts.target);
134
+ const pushManifest = await readPushManifest();
135
+ const syncManifest = await readSyncManifest();
136
+ const packages = await findSkillPackages(root);
137
+ let published = 0;
138
+ let updated = 0;
139
+ let adopted = 0;
140
+ let skipped = 0;
141
+ for (const packagePath of packages) {
142
+ let skillPackage;
143
+ try {
144
+ skillPackage = await readSkillPackage(packagePath);
145
+ }
146
+ catch (err) {
147
+ skipped += 1;
148
+ if (!opts.quiet) {
149
+ process.stderr.write(`[floom] skipped ${packagePath}: ${err instanceof Error ? err.message : String(err)}\n`);
150
+ }
151
+ continue;
152
+ }
153
+ const key = safeRootRelative(root, skillPackage.skillPath);
154
+ const pushKey = pushManifestKey(opts.target, key);
155
+ const hash = hashPackage(key, skillPackage.skillBody, skillPackage.packageFiles);
156
+ const pushed = pushManifest.files[pushKey];
157
+ if (pushed?.hash === hash) {
158
+ skipped += 1;
159
+ continue;
160
+ }
161
+ if (!opts.yolo) {
162
+ const syncedSlug = slugFromSyncManifest(root, skillPackage.skillPath, syncManifest);
163
+ const pushedSlug = slugFromPushManifest(pushKey, pushManifest);
164
+ const fallbackSlug = fallbackSlugFromPath(packagePath);
165
+ pushManifest.files[pushKey] = {
166
+ hash,
167
+ slug: pushedSlug ?? syncedSlug ?? fallbackSlug ?? key,
168
+ path: key,
169
+ pushedAt: new Date().toISOString(),
170
+ };
171
+ adopted += 1;
172
+ continue;
173
+ }
174
+ const syncedSlug = slugFromSyncManifest(root, skillPackage.skillPath, syncManifest);
175
+ const pushedSlug = slugFromPushManifest(pushKey, pushManifest);
176
+ const fallbackSlug = fallbackSlugFromPath(packagePath);
177
+ const slug = pushedSlug ?? syncedSlug ?? fallbackSlug;
178
+ if (!pushed && syncedSlug && isUnchangedSyncedPackage(root, skillPackage, syncManifest)) {
179
+ pushManifest.files[pushKey] = {
180
+ hash,
181
+ slug: syncedSlug,
182
+ path: key,
183
+ pushedAt: new Date().toISOString(),
184
+ };
185
+ adopted += 1;
186
+ continue;
187
+ }
188
+ if (pushed || syncedSlug) {
189
+ if (!slug) {
190
+ skipped += 1;
191
+ continue;
192
+ }
193
+ try {
194
+ await publishSkillPath({ file: packagePath, update: true, updateSlug: slug, quiet: true });
195
+ updated += 1;
196
+ pushManifest.files[pushKey] = { hash, slug, path: key, pushedAt: new Date().toISOString() };
197
+ markPackageSynced(root, skillPackage, syncManifest, slug);
198
+ await writeSyncManifest(syncManifest);
199
+ }
200
+ catch (err) {
201
+ if (err instanceof Error && /Skill not found/i.test(err.message)) {
202
+ const result = await publishSkillPath({ file: packagePath, visibility: "unlisted", quiet: true });
203
+ published += 1;
204
+ pushManifest.files[pushKey] = { hash, slug: result.data.slug, path: key, pushedAt: new Date().toISOString() };
205
+ markPackageSynced(root, skillPackage, syncManifest, result.data.slug);
206
+ await writeSyncManifest(syncManifest);
207
+ continue;
208
+ }
209
+ skipped += 1;
210
+ if (!opts.quiet) {
211
+ process.stderr.write(`[floom] skipped ${packagePath}: ${err instanceof Error ? err.message : String(err)}\n`);
212
+ }
213
+ }
214
+ continue;
215
+ }
216
+ try {
217
+ const result = await publishSkillPath({ file: packagePath, visibility: "unlisted", quiet: true });
218
+ published += 1;
219
+ pushManifest.files[pushKey] = { hash, slug: result.data.slug, path: key, pushedAt: new Date().toISOString() };
220
+ markPackageSynced(root, skillPackage, syncManifest, result.data.slug);
221
+ await writeSyncManifest(syncManifest);
222
+ }
223
+ catch (err) {
224
+ skipped += 1;
225
+ if (!opts.quiet) {
226
+ process.stderr.write(`[floom] skipped ${packagePath}: ${err instanceof Error ? err.message : String(err)}\n`);
227
+ }
228
+ }
229
+ }
230
+ await writePushManifest(pushManifest);
231
+ if (!opts.quiet && (published > 0 || updated > 0 || adopted > 0)) {
232
+ process.stdout.write(`${symbols.ok} Floom push watch: ${packages.length} scanned, ${published} published, ${updated} updated, ${adopted} adopted\n`);
233
+ }
234
+ return { scanned: packages.length, published, updated, adopted, skipped };
235
+ }
236
+ export async function watchPush(intervalSeconds, opts) {
237
+ for (;;) {
238
+ await pushWatchOnce(opts);
239
+ if (opts.once)
240
+ return;
241
+ await new Promise((resolve) => setTimeout(resolve, intervalSeconds * 1000));
242
+ if (!opts.quiet)
243
+ process.stdout.write(c.dim("[floom] push watch tick\n"));
244
+ }
245
+ }
package/dist/setup.js CHANGED
@@ -5,27 +5,24 @@ import { createInterface } from "node:readline/promises";
5
5
  import { stdin as input, stdout as output } from "node:process";
6
6
  import { FloomError } from "./errors.js";
7
7
  import { c, symbols } from "./ui.js";
8
+ import { targetLabel } from "./targets.js";
8
9
  const START_MARKER = "<!-- FLOOM SETUP START -->";
9
10
  const END_MARKER = "<!-- FLOOM SETUP END -->";
10
11
  const CLI_COMMAND = "npx -y @floomhq/floom";
11
- const TARGETS = {
12
- claude: { label: "Claude Code", filename: "CLAUDE.md" },
13
- codex: { label: "Codex", filename: "AGENTS.md" },
12
+ const INSTRUCTION_FILES = {
13
+ claude: { filename: "CLAUDE.md" },
14
+ codex: { filename: "AGENTS.md" },
14
15
  };
15
16
  function floomAgentInstructions(target) {
16
- const addCommand = target === "codex"
17
- ? `${CLI_COMMAND} add <slug-or-url> --target codex`
18
- : `${CLI_COMMAND} add <slug-or-url> --target claude`;
19
- const localSkillsDir = target === "codex" ? "~/.codex/skills" : "~/.claude/skills";
17
+ const addCommand = `${CLI_COMMAND} add <slug-or-url> --target ${target}`;
20
18
  return `${START_MARKER}
21
- ## Floom Skills
19
+ ## Floom
22
20
 
23
- - Use Floom skills when they fit the task. Search local skills before recreating behavior from scratch.
24
- - Local skills live in \`${localSkillsDir}\`.
21
+ - Before recreating agent behavior from scratch, check Floom for reusable skills.
22
+ - Search or inspect skills with \`${CLI_COMMAND} search <query>\`, \`${CLI_COMMAND} info <slug-or-url>\`, and \`${CLI_COMMAND} list\`.
25
23
  - Add shared skills with \`${addCommand}\`; public and unlisted links do not require a Floom account.
26
- - Search or inspect skills with \`${CLI_COMMAND} search <query>\` and \`${CLI_COMMAND} info <slug-or-url>\`.
27
- - If Floom MCP is available, use it for compact skill search/status and load only the specific skill needed.
28
- - Do not publish, sync, or subscribe without user approval.
24
+ - Use installed Markdown skills from the local skills folder when they match the task.
25
+ - \`${CLI_COMMAND} sync --target ${target}\`, \`${CLI_COMMAND} watch --push --target ${target}\`, and \`@floomhq/floom-mcp-sync\` keep saved, published, and subscribed library skills current; review conflicts before relying on synced output.
29
26
  ${END_MARKER}`;
30
27
  }
31
28
  async function fileExists(path) {
@@ -55,6 +52,12 @@ function parseTargetFromFile(file) {
55
52
  return "claude";
56
53
  if (upper.endsWith("AGENTS.MD"))
57
54
  return "codex";
55
+ if (upper.endsWith(".MDC") || upper.includes(`${join(".cursor", "rules").toUpperCase()}`))
56
+ return "cursor";
57
+ if (upper.includes(`${join(".config", "opencode").toUpperCase()}`))
58
+ return "opencode";
59
+ if (upper.includes(`${join(".kimi", "agents").toUpperCase()}`))
60
+ return "kimi";
58
61
  return undefined;
59
62
  }
60
63
  async function findUp(filename) {
@@ -74,32 +77,53 @@ async function detectTarget(opts) {
74
77
  const agent = opts.target ?? (opts.file ? parseTargetFromFile(opts.file) : undefined);
75
78
  if (opts.file) {
76
79
  if (!agent) {
77
- throw new FloomError("Cannot infer agent target from that file name.", "Pass `--target claude` or `--target codex`, or use CLAUDE.md / AGENTS.md.");
80
+ throw new FloomError("Cannot infer agent target from that file name.", "Pass `--target claude`, `--target codex`, `--target cursor`, `--target opencode`, or `--target kimi`.");
78
81
  }
79
82
  return {
80
83
  agent,
81
- label: TARGETS[agent].label,
84
+ label: targetLabel(agent),
82
85
  path: resolve(process.cwd(), opts.file),
83
86
  };
84
87
  }
85
88
  if (agent) {
86
- const existing = await findUp(TARGETS[agent].filename);
89
+ if (agent === "cursor") {
90
+ return {
91
+ agent,
92
+ label: targetLabel(agent),
93
+ path: join(homedir(), ".cursor", "rules", "floom.mdc"),
94
+ };
95
+ }
96
+ if (agent === "opencode") {
97
+ return {
98
+ agent,
99
+ label: targetLabel(agent),
100
+ path: join(homedir(), ".config", "opencode", "floom.md"),
101
+ };
102
+ }
103
+ if (agent === "kimi") {
104
+ return {
105
+ agent,
106
+ label: targetLabel(agent),
107
+ path: join(homedir(), ".kimi", "agents", "federico-system.md"),
108
+ };
109
+ }
110
+ const existing = await findUp(INSTRUCTION_FILES[agent].filename);
87
111
  return {
88
112
  agent,
89
- label: TARGETS[agent].label,
90
- path: existing ?? resolve(process.cwd(), TARGETS[agent].filename),
113
+ label: targetLabel(agent),
114
+ path: existing ?? resolve(process.cwd(), INSTRUCTION_FILES[agent].filename),
91
115
  };
92
116
  }
93
- const claude = await findUp(TARGETS.claude.filename);
94
- const codex = await findUp(TARGETS.codex.filename);
117
+ const claude = await findUp(INSTRUCTION_FILES.claude.filename);
118
+ const codex = await findUp(INSTRUCTION_FILES.codex.filename);
95
119
  if (claude && codex) {
96
- throw new FloomError("Found both Claude Code and Codex instruction files.", "Pass `--target claude` or `--target codex`.");
120
+ throw new FloomError("Found both Claude Code and Codex instruction files.", "Pass an explicit `--target`.");
97
121
  }
98
122
  if (claude)
99
- return { agent: "claude", label: TARGETS.claude.label, path: claude };
123
+ return { agent: "claude", label: targetLabel("claude"), path: claude };
100
124
  if (codex)
101
- return { agent: "codex", label: TARGETS.codex.label, path: codex };
102
- throw new FloomError("No agent instruction file found.", `Run \`${CLI_COMMAND} setup --target claude --yes\` or \`${CLI_COMMAND} setup --target codex --yes\` from the repo root.`);
125
+ return { agent: "codex", label: targetLabel("codex"), path: codex };
126
+ throw new FloomError("No agent instruction file found.", `Run \`${CLI_COMMAND} setup --target claude --yes\`, \`${CLI_COMMAND} setup --target codex --yes\`, \`${CLI_COMMAND} setup --target cursor --yes\`, \`${CLI_COMMAND} setup --target opencode --yes\`, or \`${CLI_COMMAND} setup --target kimi --yes\`.`);
103
127
  }
104
128
  function renderPreview(target, existing) {
105
129
  const action = existing === null ? "create" : "append";
@@ -115,6 +139,41 @@ function renderPreview(target, existing) {
115
139
  "",
116
140
  ].join("\n");
117
141
  }
142
+ function renderInstructions(target, existing) {
143
+ const body = floomAgentInstructions(target);
144
+ if (target === "cursor" && existing === null) {
145
+ return [
146
+ "---",
147
+ "description: Use Floom skills and sync",
148
+ "alwaysApply: true",
149
+ "---",
150
+ "",
151
+ body,
152
+ "",
153
+ ].join("\n");
154
+ }
155
+ return body;
156
+ }
157
+ async function ensureOpencodeInstructionReference(instructionPath) {
158
+ const configPath = join(homedir(), ".config", "opencode", "opencode.json");
159
+ const reference = `{file:${instructionPath.replace(homedir(), "~")}}`;
160
+ let parsed = {};
161
+ const existing = await readIfExists(configPath);
162
+ if (existing) {
163
+ try {
164
+ parsed = JSON.parse(existing);
165
+ }
166
+ catch {
167
+ throw new FloomError("OpenCode config is not valid JSON.", configPath);
168
+ }
169
+ }
170
+ const current = Array.isArray(parsed.instructions) ? parsed.instructions : [];
171
+ if (current.includes(reference))
172
+ return;
173
+ parsed.instructions = [...current.filter((item) => typeof item === "string"), reference];
174
+ await mkdir(dirname(configPath), { recursive: true });
175
+ await writeFile(configPath, `${JSON.stringify(parsed, null, 2)}\n`, "utf8");
176
+ }
118
177
  async function confirmWrite(target, existing) {
119
178
  if (!process.stdin.isTTY) {
120
179
  throw new FloomError("Refusing to update agent instructions without confirmation in non-interactive mode.", "Pass `--yes` to write, or `--dry-run` to preview.");
@@ -149,7 +208,7 @@ export async function setupAgent(opts) {
149
208
  }
150
209
  }
151
210
  await mkdir(dirname(target.path), { recursive: true });
152
- const instructions = floomAgentInstructions(target.agent);
211
+ const instructions = renderInstructions(target.agent, existing);
153
212
  const next = existing === null
154
213
  ? `${instructions}\n`
155
214
  : `${existing.replace(/\s*$/, "")}\n\n${instructions}\n`;
@@ -164,6 +223,9 @@ export async function setupAgent(opts) {
164
223
  else {
165
224
  await writeFile(target.path, next, "utf8");
166
225
  }
226
+ if (target.agent === "opencode") {
227
+ await ensureOpencodeInstructionReference(target.path);
228
+ }
167
229
  process.stdout.write(`\n${symbols.ok} Added Floom instructions to ${c.bold(target.path)}\n`);
168
230
  process.stdout.write(` ${c.dim("MCP setup guidance:")} ${c.cyan(`${CLI_COMMAND} mcp`)}\n\n`);
169
231
  }
@@ -9,6 +9,49 @@ const LOCK_TIMEOUT_MS = 15_000;
9
9
  const LOCK_STALE_MS = 5 * 60_000;
10
10
  const SLUG_RE = /^[A-Za-z0-9_-]{1,128}$/;
11
11
  const FD_PATH_ROOT = "/proc/self/fd";
12
+ const SUPPORT_DIRS = new Set([
13
+ "agents",
14
+ "assets",
15
+ "canvas-fonts",
16
+ "checks",
17
+ "codex",
18
+ "core",
19
+ "cursor",
20
+ "evidence",
21
+ "examples",
22
+ "helpers",
23
+ "kimi",
24
+ "opencode",
25
+ "reference",
26
+ "references",
27
+ "scripts",
28
+ "sdk",
29
+ "schemas",
30
+ "templates",
31
+ "tests",
32
+ "themes",
33
+ ]);
34
+ const ROOT_SUPPORT_FILES = new Set([
35
+ ".env.example",
36
+ "ACKNOWLEDGEMENTS.md",
37
+ "CHANGELOG.md",
38
+ "LICENSE",
39
+ "LICENSE.md",
40
+ "LICENSE.txt",
41
+ "README.md",
42
+ "SKILL.md.tmpl",
43
+ "editing.md",
44
+ "forms.md",
45
+ "instructions.md",
46
+ "nanobanana.py",
47
+ "plan.md",
48
+ "pptxgenjs.md",
49
+ "reference.md",
50
+ "requirements.txt",
51
+ "skill.md",
52
+ "style_guidelines.md",
53
+ "theme-showcase.pdf",
54
+ ]);
12
55
  function emptyManifest() {
13
56
  return { version: MANIFEST_VERSION, files: {} };
14
57
  }
@@ -33,10 +76,12 @@ function isEntryForKey(key, value) {
33
76
  function isPackageFilePath(packagePath) {
34
77
  if (packagePath.length === 1 && packagePath[0] === "SKILL.md")
35
78
  return true;
79
+ if (packagePath.length === 1 && ROOT_SUPPORT_FILES.has(packagePath[0] ?? ""))
80
+ return true;
36
81
  if (packagePath.length < 2)
37
82
  return false;
38
83
  const first = packagePath[0];
39
- if (first === undefined || !["references", "examples", "scripts", "assets"].includes(first))
84
+ if (first === undefined || !SUPPORT_DIRS.has(first))
40
85
  return false;
41
86
  return packagePath.every((segment) => segment !== "." && segment !== ".." && segment.length > 0);
42
87
  }
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";
@@ -10,17 +9,11 @@ import { c, symbols } from "./ui.js";
10
9
  import { FloomError } from "./errors.js";
11
10
  import { ensureSyncManifestDir, manifestKey, markSynced, readSyncManifest, unmarkSynced, withSyncLock, writeSyncManifest } from "./sync-manifest.js";
12
11
  import { normalizeRemotePackageFiles } from "./package.js";
12
+ import { targetSkillsDir } from "./targets.js";
13
13
  const SLUG_RE = /^[A-Za-z0-9_-]{1,128}$/;
14
14
  const PATH_SEGMENT_RE = /^[a-z0-9._-]{1,128}$/;
15
15
  const MANIFEST_SEGMENT_RE = /^[A-Za-z0-9._-]{1,128}$/;
16
16
  const FD_PATH_ROOT = "/proc/self/fd";
17
- function skillsDir(target = "claude") {
18
- if (target === "codex") {
19
- const codexHome = process.env.CODEX_HOME ?? join(homedir(), ".codex");
20
- return process.env.CODEX_SKILLS_DIR ?? join(codexHome, "skills");
21
- }
22
- return process.env.CLAUDE_SKILLS_DIR ?? join(homedir(), ".claude", "skills");
23
- }
24
17
  function sha256(input) {
25
18
  return createHash("sha256").update(input).digest("hex");
26
19
  }
@@ -64,7 +57,7 @@ function safePathSegments(value, label) {
64
57
  function skillPath(skill, targetAgent) {
65
58
  if (!SLUG_RE.test(skill.slug))
66
59
  throw new FloomError(`Invalid skill slug: ${skill.slug}`);
67
- const root = skillsDir(targetAgent);
60
+ const root = targetSkillsDir(targetAgent);
68
61
  const segments = [root];
69
62
  segments.push(...safePathSegments(skill.library_slug, "library slug"));
70
63
  segments.push(...safePathSegments(skill.folder, "folder"));
@@ -79,17 +72,6 @@ function skillPath(skill, targetAgent) {
79
72
  function syncKey(skill) {
80
73
  return `${skill.library_slug ?? ""}\0${skill.folder ?? ""}\0${skill.slug}`;
81
74
  }
82
- function hasStructuredPath(skill) {
83
- return Boolean(skill.library_slug || skill.folder);
84
- }
85
- function dedupeSyncSkills(skills) {
86
- const structuredSlugs = new Set();
87
- for (const skill of skills) {
88
- if (hasStructuredPath(skill))
89
- structuredSlugs.add(skill.slug);
90
- }
91
- return skills.filter((skill) => !structuredSlugs.has(skill.slug) || hasStructuredPath(skill));
92
- }
93
75
  function validateSyncSkillShape(skill) {
94
76
  if (!skill || typeof skill !== "object")
95
77
  throw new FloomError("Invalid sync response.");
@@ -195,8 +177,12 @@ async function planPackageSync(root, files, manifest) {
195
177
  missing += 1;
196
178
  continue;
197
179
  }
198
- if (!tracked)
199
- return { kind: "conflict", target: file.target, reason: "existing file is not tracked by Floom sync" };
180
+ if (!tracked) {
181
+ if (state.hash !== file.hash)
182
+ return { kind: "conflict", target: file.target, reason: "existing file is not tracked by Floom sync" };
183
+ unchanged += 1;
184
+ continue;
185
+ }
200
186
  if (state.hash !== tracked.hash)
201
187
  return { kind: "conflict", target: file.target, reason: "local file changed since the last Floom sync" };
202
188
  if (state.hash !== file.hash)
@@ -292,14 +278,14 @@ export async function sync(opts = {}) {
292
278
  }
293
279
  try {
294
280
  return await withSyncLock(async () => {
295
- await mkdir(skillsDir(targetAgent), { recursive: true, mode: 0o700 });
281
+ await mkdir(targetSkillsDir(targetAgent), { recursive: true, mode: 0o700 });
296
282
  if (!Array.isArray(payload.skills)) {
297
283
  throw new FloomError("Invalid sync response.");
298
284
  }
299
285
  for (const skill of payload.skills)
300
286
  validateSyncSkillShape(skill);
301
- // Version 1 syncs published, saved, and subscribed library skills.
302
- const all = dedupeSyncSkills(payload.skills);
287
+ // Version 1 preview syncs published, saved, and subscribed library skills.
288
+ const all = payload.skills;
303
289
  const seen = new Set();
304
290
  let unchanged = 0;
305
291
  let updated = 0;
@@ -307,7 +293,7 @@ export async function sync(opts = {}) {
307
293
  let conflicts = 0;
308
294
  const conflictNotes = [];
309
295
  const manifest = await readSyncManifest();
310
- const root = skillsDir(targetAgent);
296
+ const root = targetSkillsDir(targetAgent);
311
297
  const activeTargetKeys = new Set();
312
298
  const pruneBlockedSlugs = new Set();
313
299
  let manifestChanged = false;
@@ -354,6 +340,9 @@ export async function sync(opts = {}) {
354
340
  continue;
355
341
  }
356
342
  if (plan.kind === "unchanged") {
343
+ for (const file of packageFiles)
344
+ markSynced(manifest, manifestKey(root, file.target), skill.slug, file.hash);
345
+ await writeSyncManifest(manifest);
357
346
  unchanged += 1;
358
347
  continue;
359
348
  }
package/dist/targets.js CHANGED
@@ -1,16 +1,49 @@
1
1
  import { homedir } from "node:os";
2
2
  import { join } from "node:path";
3
- export function resolveSkillsDir(target) {
4
- if (process.env.FLOOM_SKILLS_DIR)
5
- return process.env.FLOOM_SKILLS_DIR;
6
- if (target === "codex") {
7
- const codexHome = process.env.CODEX_HOME ?? join(homedir(), ".codex");
8
- return process.env.CODEX_SKILLS_DIR ?? join(codexHome, "skills");
9
- }
10
- return process.env.CLAUDE_SKILLS_DIR ?? join(homedir(), ".claude", "skills");
3
+ export const AGENT_TARGETS = ["claude", "codex", "cursor", "opencode", "kimi"];
4
+ const TARGETS = {
5
+ claude: {
6
+ label: "Claude Code",
7
+ skillsDirEnv: "CLAUDE_SKILLS_DIR",
8
+ defaultSkillsDir: () => join(homedir(), ".claude", "skills"),
9
+ },
10
+ codex: {
11
+ label: "Codex",
12
+ skillsDirEnv: "CODEX_SKILLS_DIR",
13
+ defaultSkillsDir: () => join(process.env.CODEX_HOME ?? join(homedir(), ".codex"), "skills"),
14
+ },
15
+ cursor: {
16
+ label: "Cursor",
17
+ skillsDirEnv: "CURSOR_SKILLS_DIR",
18
+ defaultSkillsDir: () => join(homedir(), ".cursor", "skills-cursor"),
19
+ },
20
+ opencode: {
21
+ label: "OpenCode",
22
+ skillsDirEnv: "OPENCODE_SKILLS_DIR",
23
+ defaultSkillsDir: () => join(homedir(), ".config", "opencode", "skills"),
24
+ },
25
+ kimi: {
26
+ label: "Kimi",
27
+ skillsDirEnv: "KIMI_SKILLS_DIR",
28
+ defaultSkillsDir: () => join(homedir(), ".kimi", "skills"),
29
+ },
30
+ };
31
+ export const TARGET_HINT = AGENT_TARGETS.join("|");
32
+ export function isAgentTarget(value) {
33
+ return AGENT_TARGETS.includes(value);
11
34
  }
12
- export function skillsDirHint(target) {
13
- if (process.env.FLOOM_SKILLS_DIR)
14
- return "FLOOM_SKILLS_DIR";
15
- return target === "codex" ? "CODEX_SKILLS_DIR" : "CLAUDE_SKILLS_DIR";
35
+ export function parseAgentTarget(value) {
36
+ if (isAgentTarget(value))
37
+ return value;
38
+ throw new Error(`Invalid agent target: ${value}`);
39
+ }
40
+ export function targetLabel(target) {
41
+ return TARGETS[target].label;
42
+ }
43
+ export function targetSkillsDir(target) {
44
+ const info = TARGETS[target];
45
+ return process.env[info.skillsDirEnv] ?? info.defaultSkillsDir();
46
+ }
47
+ export function targetSkillsDirEnv(target) {
48
+ return TARGETS[target].skillsDirEnv;
16
49
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floomhq/floom",
3
- "version": "1.0.25",
3
+ "version": "1.0.27",
4
4
  "description": "Sync AI skills across agents and machines.",
5
5
  "license": "MIT",
6
6
  "type": "module",