@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.
- package/README.md +6 -5
- package/dist/cli.js +61 -242
- package/dist/config.js +1 -51
- package/dist/doctor.js +11 -39
- package/dist/errors.js +1 -1
- package/dist/install.js +19 -74
- package/dist/login.js +8 -67
- package/dist/mcp.js +5 -2
- package/dist/package.js +177 -81
- package/dist/publish.js +41 -51
- package/dist/push-watch.js +245 -0
- package/dist/setup.js +87 -25
- package/dist/sync-manifest.js +46 -1
- package/dist/sync.js +15 -26
- package/dist/targets.js +45 -12
- package/package.json +1 -1
|
@@ -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
|
|
12
|
-
claude: {
|
|
13
|
-
codex: {
|
|
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
|
|
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
|
|
19
|
+
## Floom
|
|
22
20
|
|
|
23
|
-
-
|
|
24
|
-
-
|
|
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
|
-
-
|
|
27
|
-
-
|
|
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
|
|
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:
|
|
84
|
+
label: targetLabel(agent),
|
|
82
85
|
path: resolve(process.cwd(), opts.file),
|
|
83
86
|
};
|
|
84
87
|
}
|
|
85
88
|
if (agent) {
|
|
86
|
-
|
|
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:
|
|
90
|
-
path: existing ?? resolve(process.cwd(),
|
|
113
|
+
label: targetLabel(agent),
|
|
114
|
+
path: existing ?? resolve(process.cwd(), INSTRUCTION_FILES[agent].filename),
|
|
91
115
|
};
|
|
92
116
|
}
|
|
93
|
-
const claude = await findUp(
|
|
94
|
-
const codex = await findUp(
|
|
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
|
|
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:
|
|
123
|
+
return { agent: "claude", label: targetLabel("claude"), path: claude };
|
|
100
124
|
if (codex)
|
|
101
|
-
return { agent: "codex", label:
|
|
102
|
-
throw new FloomError("No agent instruction file found.", `Run \`${CLI_COMMAND} setup --target claude --yes
|
|
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 =
|
|
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
|
}
|
package/dist/sync-manifest.js
CHANGED
|
@@ -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 || !
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
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 =
|
|
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 =
|
|
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
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
}
|
|
10
|
-
|
|
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
|
|
13
|
-
if (
|
|
14
|
-
return
|
|
15
|
-
|
|
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
|
}
|