@floomhq/floom 1.0.0
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/LICENSE +21 -0
- package/README.md +53 -0
- package/bin/floom.js +2 -0
- package/dist/cli.js +355 -0
- package/dist/config.js +44 -0
- package/dist/delete.js +55 -0
- package/dist/doctor.js +270 -0
- package/dist/errors.js +51 -0
- package/dist/info.js +66 -0
- package/dist/init.js +59 -0
- package/dist/install.js +175 -0
- package/dist/lib/api.js +58 -0
- package/dist/library.js +77 -0
- package/dist/list.js +60 -0
- package/dist/login.js +163 -0
- package/dist/mcp.js +22 -0
- package/dist/publish.js +189 -0
- package/dist/share.js +70 -0
- package/dist/sync-manifest.js +123 -0
- package/dist/sync.js +402 -0
- package/dist/ui.js +31 -0
- package/dist/whoami.js +61 -0
- package/package.json +56 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { lstat, mkdir, open, rename } from "node:fs/promises";
|
|
3
|
+
import { join, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { CONFIG_DIR } from "./config.js";
|
|
5
|
+
const MANIFEST_VERSION = 1;
|
|
6
|
+
const MANIFEST_PATH = join(CONFIG_DIR, "sync-manifest.json");
|
|
7
|
+
const SLUG_RE = /^[A-Za-z0-9_-]{1,128}$/;
|
|
8
|
+
const FD_PATH_ROOT = "/proc/self/fd";
|
|
9
|
+
function emptyManifest() {
|
|
10
|
+
return { version: MANIFEST_VERSION, files: {} };
|
|
11
|
+
}
|
|
12
|
+
function isEntryForKey(key, value) {
|
|
13
|
+
if (!value || typeof value !== "object")
|
|
14
|
+
return false;
|
|
15
|
+
const entry = value;
|
|
16
|
+
return (typeof entry.hash === "string" &&
|
|
17
|
+
typeof entry.slug === "string" &&
|
|
18
|
+
typeof entry.target === "string" &&
|
|
19
|
+
typeof entry.syncedAt === "string" &&
|
|
20
|
+
entry.target === key &&
|
|
21
|
+
SLUG_RE.test(entry.slug) &&
|
|
22
|
+
key.split("/").at(-1) === `${entry.slug}.md`);
|
|
23
|
+
}
|
|
24
|
+
export async function readSyncManifest() {
|
|
25
|
+
try {
|
|
26
|
+
await ensureSyncManifestDir();
|
|
27
|
+
const handle = await open(MANIFEST_PATH, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
28
|
+
let body;
|
|
29
|
+
try {
|
|
30
|
+
body = await handle.readFile("utf8");
|
|
31
|
+
}
|
|
32
|
+
finally {
|
|
33
|
+
await handle.close();
|
|
34
|
+
}
|
|
35
|
+
const parsed = JSON.parse(body);
|
|
36
|
+
if (parsed.version !== MANIFEST_VERSION || !parsed.files || typeof parsed.files !== "object") {
|
|
37
|
+
return emptyManifest();
|
|
38
|
+
}
|
|
39
|
+
const manifest = emptyManifest();
|
|
40
|
+
for (const [key, value] of Object.entries(parsed.files)) {
|
|
41
|
+
if (isEntryForKey(key, value))
|
|
42
|
+
manifest.files[key] = value;
|
|
43
|
+
}
|
|
44
|
+
return manifest;
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
if (err.code === "ENOENT")
|
|
48
|
+
return emptyManifest();
|
|
49
|
+
if (err instanceof SyntaxError)
|
|
50
|
+
return emptyManifest();
|
|
51
|
+
throw err;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
export async function writeSyncManifest(manifest) {
|
|
55
|
+
await ensureSyncManifestDir();
|
|
56
|
+
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()}`;
|
|
58
|
+
const body = JSON.stringify(manifest, null, 2);
|
|
59
|
+
try {
|
|
60
|
+
for (let attempt = 0; attempt < 10; attempt += 1) {
|
|
61
|
+
const tmpName = attempt === 0 ? `${tmpBase}.tmp` : `${tmpBase}.${attempt}.tmp`;
|
|
62
|
+
const tmpPath = childPath(dir, CONFIG_DIR, tmpName);
|
|
63
|
+
let handle = null;
|
|
64
|
+
try {
|
|
65
|
+
handle = await open(tmpPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
66
|
+
await handle.writeFile(body, "utf8");
|
|
67
|
+
await handle.close();
|
|
68
|
+
handle = null;
|
|
69
|
+
await rename(tmpPath, childPath(dir, CONFIG_DIR, "sync-manifest.json"));
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
await handle?.close().catch(() => { });
|
|
74
|
+
if (err.code === "EEXIST")
|
|
75
|
+
continue;
|
|
76
|
+
throw err;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
finally {
|
|
81
|
+
await dir.close();
|
|
82
|
+
}
|
|
83
|
+
const err = new Error("temporary sync manifest file already exists");
|
|
84
|
+
err.code = "EEXIST";
|
|
85
|
+
throw err;
|
|
86
|
+
}
|
|
87
|
+
function childPath(parent, fallbackParent, name) {
|
|
88
|
+
if (process.platform === "linux")
|
|
89
|
+
return `${FD_PATH_ROOT}/${parent.fd}/${name}`;
|
|
90
|
+
return join(resolve(fallbackParent), name);
|
|
91
|
+
}
|
|
92
|
+
export async function ensureSyncManifestDir() {
|
|
93
|
+
await mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
94
|
+
const stat = await lstat(CONFIG_DIR);
|
|
95
|
+
if (stat.isSymbolicLink()) {
|
|
96
|
+
const err = new Error("sync manifest directory is a symbolic link");
|
|
97
|
+
err.code = "ELOOP";
|
|
98
|
+
throw err;
|
|
99
|
+
}
|
|
100
|
+
if (!stat.isDirectory()) {
|
|
101
|
+
const err = new Error("sync manifest path is blocked by an existing local file");
|
|
102
|
+
err.code = "ENOTDIR";
|
|
103
|
+
throw err;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
export function manifestKey(root, target) {
|
|
107
|
+
const relativeTarget = relative(resolve(root), resolve(target));
|
|
108
|
+
if (relativeTarget === ".." || relativeTarget.startsWith(`..${sep}`)) {
|
|
109
|
+
throw new Error("Invalid manifest target path.");
|
|
110
|
+
}
|
|
111
|
+
return relativeTarget.split(sep).join("/");
|
|
112
|
+
}
|
|
113
|
+
export function markSynced(manifest, key, slug, hash) {
|
|
114
|
+
manifest.files[key] = {
|
|
115
|
+
hash,
|
|
116
|
+
slug,
|
|
117
|
+
target: key,
|
|
118
|
+
syncedAt: new Date().toISOString(),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
export function unmarkSynced(manifest, key) {
|
|
122
|
+
delete manifest.files[key];
|
|
123
|
+
}
|
package/dist/sync.js
ADDED
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { lstat, mkdir, open } from "node:fs/promises";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
6
|
+
import ora from "ora";
|
|
7
|
+
import { getApiUrl, readConfig } from "./config.js";
|
|
8
|
+
import { getJson } from "./lib/api.js";
|
|
9
|
+
import { c, symbols } from "./ui.js";
|
|
10
|
+
import { FloomError } from "./errors.js";
|
|
11
|
+
import { ensureSyncManifestDir, manifestKey, markSynced, readSyncManifest, unmarkSynced, writeSyncManifest } from "./sync-manifest.js";
|
|
12
|
+
const SLUG_RE = /^[A-Za-z0-9_-]{1,128}$/;
|
|
13
|
+
const PATH_SEGMENT_RE = /^[a-z0-9._-]{1,128}$/;
|
|
14
|
+
const MANIFEST_SEGMENT_RE = /^[A-Za-z0-9._-]{1,128}$/;
|
|
15
|
+
const FD_PATH_ROOT = "/proc/self/fd";
|
|
16
|
+
function skillsDir() {
|
|
17
|
+
return process.env.CLAUDE_SKILLS_DIR ?? join(homedir(), ".claude", "skills");
|
|
18
|
+
}
|
|
19
|
+
function sha256(input) {
|
|
20
|
+
return createHash("sha256").update(input).digest("hex");
|
|
21
|
+
}
|
|
22
|
+
async function localState(path) {
|
|
23
|
+
try {
|
|
24
|
+
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
25
|
+
try {
|
|
26
|
+
const stat = await handle.stat();
|
|
27
|
+
if (!stat.isFile()) {
|
|
28
|
+
return { kind: "conflict", reason: "path is blocked by an existing local file or directory" };
|
|
29
|
+
}
|
|
30
|
+
return { kind: "file", hash: sha256(await handle.readFile("utf8")) };
|
|
31
|
+
}
|
|
32
|
+
finally {
|
|
33
|
+
await handle.close();
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
catch (err) {
|
|
37
|
+
const code = err.code;
|
|
38
|
+
if (code === "ENOENT")
|
|
39
|
+
return { kind: "missing" };
|
|
40
|
+
if (code === "ELOOP")
|
|
41
|
+
return { kind: "conflict", reason: "path is a symbolic link" };
|
|
42
|
+
if (code === "ENOTDIR" || code === "EISDIR") {
|
|
43
|
+
return { kind: "conflict", reason: "path is blocked by an existing local file or directory" };
|
|
44
|
+
}
|
|
45
|
+
throw err;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function safePathSegments(value, label) {
|
|
49
|
+
if (!value)
|
|
50
|
+
return [];
|
|
51
|
+
if (isAbsolute(value))
|
|
52
|
+
throw new FloomError(`Invalid ${label}.`);
|
|
53
|
+
const segments = value.split(/[\\/]+/).filter(Boolean);
|
|
54
|
+
if (segments.some((segment) => segment === "." || segment === ".." || !PATH_SEGMENT_RE.test(segment))) {
|
|
55
|
+
throw new FloomError(`Invalid ${label}.`);
|
|
56
|
+
}
|
|
57
|
+
return segments;
|
|
58
|
+
}
|
|
59
|
+
function skillPath(skill) {
|
|
60
|
+
if (!SLUG_RE.test(skill.slug))
|
|
61
|
+
throw new FloomError(`Invalid skill slug: ${skill.slug}`);
|
|
62
|
+
const root = skillsDir();
|
|
63
|
+
const segments = [root];
|
|
64
|
+
segments.push(...safePathSegments(skill.library_slug, "library slug"));
|
|
65
|
+
segments.push(...safePathSegments(skill.folder, "folder"));
|
|
66
|
+
segments.push(`${skill.slug}.md`);
|
|
67
|
+
const target = join(...segments);
|
|
68
|
+
const relativeTarget = relative(resolve(root), resolve(target));
|
|
69
|
+
if (relativeTarget === ".." || relativeTarget.startsWith(`..${sep}`) || isAbsolute(relativeTarget)) {
|
|
70
|
+
throw new FloomError("Invalid skill target path.");
|
|
71
|
+
}
|
|
72
|
+
return target;
|
|
73
|
+
}
|
|
74
|
+
function syncKey(skill) {
|
|
75
|
+
return `${skill.library_slug ?? ""}\0${skill.folder ?? ""}\0${skill.slug}`;
|
|
76
|
+
}
|
|
77
|
+
function validateSyncSkillShape(skill) {
|
|
78
|
+
if (!skill || typeof skill !== "object")
|
|
79
|
+
throw new FloomError("Invalid sync response.");
|
|
80
|
+
const candidate = skill;
|
|
81
|
+
if (typeof candidate.slug !== "string" || typeof candidate.body_md !== "string") {
|
|
82
|
+
throw new FloomError("Invalid sync response.");
|
|
83
|
+
}
|
|
84
|
+
if (candidate.folder !== undefined &&
|
|
85
|
+
candidate.folder !== null &&
|
|
86
|
+
typeof candidate.folder !== "string") {
|
|
87
|
+
throw new FloomError("Invalid sync response.");
|
|
88
|
+
}
|
|
89
|
+
if (candidate.library_slug !== undefined &&
|
|
90
|
+
candidate.library_slug !== null &&
|
|
91
|
+
typeof candidate.library_slug !== "string") {
|
|
92
|
+
throw new FloomError("Invalid sync response.");
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
function targetFromManifestKey(root, key) {
|
|
96
|
+
if (!key || isAbsolute(key) || key.includes("\\") || key.length > 512) {
|
|
97
|
+
throw new FloomError("Invalid manifest target path.");
|
|
98
|
+
}
|
|
99
|
+
const segments = key.split("/");
|
|
100
|
+
if (segments.some((segment) => segment === "." || segment === ".." || !MANIFEST_SEGMENT_RE.test(segment))) {
|
|
101
|
+
throw new FloomError("Invalid manifest target path.");
|
|
102
|
+
}
|
|
103
|
+
const target = join(root, ...segments);
|
|
104
|
+
const relativeTarget = relative(resolve(root), resolve(target));
|
|
105
|
+
if (relativeTarget === ".." || relativeTarget.startsWith(`..${sep}`) || isAbsolute(relativeTarget)) {
|
|
106
|
+
throw new FloomError("Invalid manifest target path.");
|
|
107
|
+
}
|
|
108
|
+
return target;
|
|
109
|
+
}
|
|
110
|
+
async function writeSyncedFile(target, body) {
|
|
111
|
+
const parent = await openSafeParentDirectory(skillsDir(), target, true);
|
|
112
|
+
let handle = null;
|
|
113
|
+
try {
|
|
114
|
+
handle = await open(childCreatePath(parent, dirname(target), basename(target)), constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
115
|
+
await writeAll(handle, body);
|
|
116
|
+
}
|
|
117
|
+
finally {
|
|
118
|
+
await handle?.close();
|
|
119
|
+
await parent.close();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
async function openSafeParentDirectory(root, target, create) {
|
|
123
|
+
if (create)
|
|
124
|
+
await ensureSafeParentDirectory(root, target);
|
|
125
|
+
else
|
|
126
|
+
await assertSafeExistingParentDirectory(root, target);
|
|
127
|
+
return open(dirname(target), constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
|
128
|
+
}
|
|
129
|
+
function childCreatePath(parent, fallbackParent, name) {
|
|
130
|
+
if (process.platform === "linux")
|
|
131
|
+
return `${FD_PATH_ROOT}/${parent.fd}/${name}`;
|
|
132
|
+
return join(resolve(fallbackParent), name);
|
|
133
|
+
}
|
|
134
|
+
async function writeAll(handle, body) {
|
|
135
|
+
const buffer = Buffer.from(body, "utf8");
|
|
136
|
+
let offset = 0;
|
|
137
|
+
while (offset < buffer.length) {
|
|
138
|
+
const result = await handle.write(buffer, offset, buffer.length - offset, offset);
|
|
139
|
+
if (result.bytesWritten === 0)
|
|
140
|
+
throw conflictError("failed to write local skill file", "EIO");
|
|
141
|
+
offset += result.bytesWritten;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
async function ensureSafeParentDirectory(root, target) {
|
|
145
|
+
const resolvedRoot = resolve(root);
|
|
146
|
+
const resolvedParent = resolve(dirname(target));
|
|
147
|
+
const relativeParent = relative(resolvedRoot, resolvedParent);
|
|
148
|
+
if (relativeParent === ".." || relativeParent.startsWith(`..${sep}`) || isAbsolute(relativeParent)) {
|
|
149
|
+
throw conflictError("Invalid skill target path.", "EINVAL");
|
|
150
|
+
}
|
|
151
|
+
await mkdir(resolvedRoot, { recursive: true, mode: 0o700 });
|
|
152
|
+
await assertSafeDirectory(resolvedRoot);
|
|
153
|
+
if (!relativeParent || relativeParent === ".")
|
|
154
|
+
return;
|
|
155
|
+
let current = resolvedRoot;
|
|
156
|
+
for (const segment of relativeParent.split(sep).filter(Boolean)) {
|
|
157
|
+
current = join(current, segment);
|
|
158
|
+
try {
|
|
159
|
+
await assertSafeDirectory(current);
|
|
160
|
+
}
|
|
161
|
+
catch (err) {
|
|
162
|
+
if (err.code !== "ENOENT")
|
|
163
|
+
throw err;
|
|
164
|
+
await mkdir(current, { mode: 0o700 });
|
|
165
|
+
await assertSafeDirectory(current);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
async function assertSafeExistingParentDirectory(root, target) {
|
|
170
|
+
const resolvedRoot = resolve(root);
|
|
171
|
+
const resolvedParent = resolve(dirname(target));
|
|
172
|
+
const relativeParent = relative(resolvedRoot, resolvedParent);
|
|
173
|
+
if (relativeParent === ".." || relativeParent.startsWith(`..${sep}`) || isAbsolute(relativeParent)) {
|
|
174
|
+
throw conflictError("Invalid skill target path.", "EINVAL");
|
|
175
|
+
}
|
|
176
|
+
await assertSafeDirectory(resolvedRoot);
|
|
177
|
+
if (!relativeParent || relativeParent === ".")
|
|
178
|
+
return;
|
|
179
|
+
let current = resolvedRoot;
|
|
180
|
+
for (const segment of relativeParent.split(sep).filter(Boolean)) {
|
|
181
|
+
current = join(current, segment);
|
|
182
|
+
try {
|
|
183
|
+
await assertSafeDirectory(current);
|
|
184
|
+
}
|
|
185
|
+
catch (err) {
|
|
186
|
+
if (err.code === "ENOENT")
|
|
187
|
+
return;
|
|
188
|
+
throw err;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
async function assertSafeDirectory(path) {
|
|
193
|
+
const stat = await lstat(path);
|
|
194
|
+
if (stat.isSymbolicLink())
|
|
195
|
+
throw conflictError("path contains a symbolic link", "ELOOP");
|
|
196
|
+
if (!stat.isDirectory()) {
|
|
197
|
+
throw conflictError("path is blocked by an existing local file or directory", "ENOTDIR");
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
function conflictError(message, code) {
|
|
201
|
+
const err = new Error(message);
|
|
202
|
+
err.code = code;
|
|
203
|
+
return err;
|
|
204
|
+
}
|
|
205
|
+
export async function sync(opts = {}) {
|
|
206
|
+
const cfg = await readConfig();
|
|
207
|
+
if (!cfg)
|
|
208
|
+
throw new FloomError("Not signed in.", "Run `floom login` first.");
|
|
209
|
+
await ensureSyncManifestDir();
|
|
210
|
+
const apiUrl = cfg.apiUrl ?? getApiUrl();
|
|
211
|
+
const spinner = opts.spinner === false ? null : ora({ text: c.dim("Syncing skills..."), color: "yellow" }).start();
|
|
212
|
+
let payload;
|
|
213
|
+
try {
|
|
214
|
+
payload = await getJson(`${apiUrl}/api/me/skills`, "load your skills", cfg.accessToken);
|
|
215
|
+
}
|
|
216
|
+
catch (err) {
|
|
217
|
+
spinner?.stop();
|
|
218
|
+
throw err;
|
|
219
|
+
}
|
|
220
|
+
await mkdir(skillsDir(), { recursive: true, mode: 0o700 });
|
|
221
|
+
if (!Array.isArray(payload.skills)) {
|
|
222
|
+
throw new FloomError("Invalid sync response.");
|
|
223
|
+
}
|
|
224
|
+
for (const skill of payload.skills)
|
|
225
|
+
validateSyncSkillShape(skill);
|
|
226
|
+
// Version 1 preview syncs owned published skills only.
|
|
227
|
+
const all = payload.skills;
|
|
228
|
+
const seen = new Set();
|
|
229
|
+
let unchanged = 0;
|
|
230
|
+
let updated = 0;
|
|
231
|
+
let skipped = 0;
|
|
232
|
+
let conflicts = 0;
|
|
233
|
+
const conflictNotes = [];
|
|
234
|
+
const manifest = await readSyncManifest();
|
|
235
|
+
const root = skillsDir();
|
|
236
|
+
const activeTargetKeys = new Set();
|
|
237
|
+
const pruneBlockedSlugs = new Set();
|
|
238
|
+
let manifestChanged = false;
|
|
239
|
+
const noteConflict = (target, reason) => {
|
|
240
|
+
conflicts += 1;
|
|
241
|
+
const rel = manifestKey(root, target);
|
|
242
|
+
conflictNotes.push(`${rel} (${reason})`);
|
|
243
|
+
};
|
|
244
|
+
const noteManifestConflict = (key, reason) => {
|
|
245
|
+
conflicts += 1;
|
|
246
|
+
conflictNotes.push(`${key} (${reason})`);
|
|
247
|
+
};
|
|
248
|
+
try {
|
|
249
|
+
for (const skill of all) {
|
|
250
|
+
const key = syncKey(skill);
|
|
251
|
+
if (seen.has(key))
|
|
252
|
+
continue;
|
|
253
|
+
seen.add(key);
|
|
254
|
+
if (!SLUG_RE.test(skill.slug)) {
|
|
255
|
+
skipped += 1;
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
let target;
|
|
259
|
+
try {
|
|
260
|
+
target = skillPath(skill);
|
|
261
|
+
}
|
|
262
|
+
catch (err) {
|
|
263
|
+
if (err instanceof FloomError) {
|
|
264
|
+
pruneBlockedSlugs.add(skill.slug);
|
|
265
|
+
skipped += 1;
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
throw err;
|
|
269
|
+
}
|
|
270
|
+
const targetKey = manifestKey(root, target);
|
|
271
|
+
activeTargetKeys.add(targetKey);
|
|
272
|
+
const remoteHash = sha256(skill.body_md);
|
|
273
|
+
const tracked = manifest.files[targetKey];
|
|
274
|
+
try {
|
|
275
|
+
await assertSafeExistingParentDirectory(root, target);
|
|
276
|
+
}
|
|
277
|
+
catch (err) {
|
|
278
|
+
const code = err.code;
|
|
279
|
+
if (code === "ELOOP") {
|
|
280
|
+
noteConflict(target, "path contains a symbolic link");
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
if (code === "ENOTDIR" || code === "EISDIR") {
|
|
284
|
+
noteConflict(target, "path is blocked by an existing local file or directory");
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
if (code === "EEXIST" || code === "ENOENT") {
|
|
288
|
+
noteConflict(target, err instanceof Error ? err.message : "local file changed during Floom sync");
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
throw err;
|
|
292
|
+
}
|
|
293
|
+
const state = await localState(target);
|
|
294
|
+
if (state.kind === "conflict") {
|
|
295
|
+
noteConflict(target, state.reason);
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
if (state.kind === "file" && !tracked) {
|
|
299
|
+
noteConflict(target, "existing file is not tracked by Floom sync");
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
if (state.kind === "file" && state.hash !== tracked?.hash) {
|
|
303
|
+
noteConflict(target, "local file changed since the last Floom sync");
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
if (state.kind === "file" && state.hash === remoteHash) {
|
|
307
|
+
unchanged += 1;
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
if (state.kind === "file") {
|
|
311
|
+
noteConflict(target, "remote skill changed; move or delete the local file to accept the Floom version");
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
try {
|
|
315
|
+
await writeSyncedFile(target, skill.body_md);
|
|
316
|
+
}
|
|
317
|
+
catch (err) {
|
|
318
|
+
const code = err.code;
|
|
319
|
+
if (code === "ELOOP") {
|
|
320
|
+
noteConflict(target, "path contains a symbolic link");
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
if (code === "ENOTDIR" || code === "EISDIR") {
|
|
324
|
+
noteConflict(target, "path is blocked by an existing local file or directory");
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
throw err;
|
|
328
|
+
}
|
|
329
|
+
markSynced(manifest, targetKey, skill.slug, remoteHash);
|
|
330
|
+
await writeSyncManifest(manifest);
|
|
331
|
+
updated += 1;
|
|
332
|
+
}
|
|
333
|
+
if (payload.full_sync === true) {
|
|
334
|
+
for (const [key, entry] of Object.entries(manifest.files)) {
|
|
335
|
+
if (activeTargetKeys.has(key))
|
|
336
|
+
continue;
|
|
337
|
+
if (pruneBlockedSlugs.has(entry.slug)) {
|
|
338
|
+
noteManifestConflict(key, "remote metadata is invalid for this skill");
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
let target;
|
|
342
|
+
try {
|
|
343
|
+
target = targetFromManifestKey(root, key);
|
|
344
|
+
await assertSafeExistingParentDirectory(root, target);
|
|
345
|
+
}
|
|
346
|
+
catch (err) {
|
|
347
|
+
if (err instanceof FloomError) {
|
|
348
|
+
noteManifestConflict(key, "invalid manifest target path");
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
const code = err.code;
|
|
352
|
+
if (code === "ELOOP") {
|
|
353
|
+
noteManifestConflict(key, "path contains a symbolic link");
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
if (code === "ENOTDIR" || code === "EISDIR") {
|
|
357
|
+
noteManifestConflict(key, "path is blocked by an existing local file or directory");
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
throw err;
|
|
361
|
+
}
|
|
362
|
+
const state = await localState(target);
|
|
363
|
+
if (state.kind === "missing") {
|
|
364
|
+
unmarkSynced(manifest, key);
|
|
365
|
+
manifestChanged = true;
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
if (state.kind === "conflict") {
|
|
369
|
+
noteConflict(target, state.reason);
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
if (state.hash !== entry.hash) {
|
|
373
|
+
noteConflict(target, "local file changed since the last Floom sync");
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
unmarkSynced(manifest, key);
|
|
377
|
+
manifestChanged = true;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
if (manifestChanged)
|
|
381
|
+
await writeSyncManifest(manifest);
|
|
382
|
+
}
|
|
383
|
+
catch (err) {
|
|
384
|
+
spinner?.stop();
|
|
385
|
+
throw err;
|
|
386
|
+
}
|
|
387
|
+
spinner?.stop();
|
|
388
|
+
const synced = activeTargetKeys.size;
|
|
389
|
+
const skippedNote = skipped > 0 ? c.dim(` (${skipped} skipped — invalid path)`) : "";
|
|
390
|
+
const conflictNote = conflicts > 0 ? c.dim(`, ${conflicts} conflict${conflicts === 1 ? "" : "s"} skipped`) : "";
|
|
391
|
+
const result = { synced, unchanged, updated, skipped, conflicts };
|
|
392
|
+
if (!(opts.quietUnchanged && updated === 0 && skipped === 0 && conflicts === 0)) {
|
|
393
|
+
for (const note of conflictNotes) {
|
|
394
|
+
process.stderr.write(`${symbols.bullet} [floom] skipped local conflict: ${note}\n`);
|
|
395
|
+
}
|
|
396
|
+
if (conflicts > 0) {
|
|
397
|
+
process.stderr.write(` ${c.dim("Move or delete the local file, then run `floom sync` again.")}\n`);
|
|
398
|
+
}
|
|
399
|
+
process.stdout.write(`\n${symbols.ok} [floom] synced ${synced} skills (${unchanged} unchanged, ${updated} updated${conflictNote})${skippedNote}\n\n`);
|
|
400
|
+
}
|
|
401
|
+
return result;
|
|
402
|
+
}
|
package/dist/ui.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import pc from "picocolors";
|
|
2
|
+
// Coral / teal palette to match the warm Lovable vibe.
|
|
3
|
+
// picocolors only supports ANSI named colors, so we map:
|
|
4
|
+
// - coral / primary action: yellow (warm) for the dot, red for emphasis when needed
|
|
5
|
+
// - success: green
|
|
6
|
+
// - muted: gray (dim)
|
|
7
|
+
const isTty = process.stdout.isTTY === true;
|
|
8
|
+
export const c = {
|
|
9
|
+
coral: (s) => (isTty ? `\x1b[38;5;209m${s}\x1b[0m` : s),
|
|
10
|
+
teal: (s) => (isTty ? `\x1b[38;5;73m${s}\x1b[0m` : s),
|
|
11
|
+
green: pc.green,
|
|
12
|
+
red: pc.red,
|
|
13
|
+
yellow: pc.yellow,
|
|
14
|
+
dim: pc.dim,
|
|
15
|
+
bold: pc.bold,
|
|
16
|
+
cyan: pc.cyan,
|
|
17
|
+
};
|
|
18
|
+
export function wordmark() {
|
|
19
|
+
// bold "floom" with a coral leading dot
|
|
20
|
+
return `${c.coral("●")} ${c.bold("floom")}`;
|
|
21
|
+
}
|
|
22
|
+
export function header() {
|
|
23
|
+
return `\n ${c.bold("floom")}\n ${c.dim("─────")}\n`;
|
|
24
|
+
}
|
|
25
|
+
export const symbols = {
|
|
26
|
+
ok: c.green("✓"),
|
|
27
|
+
fail: c.red("✗"),
|
|
28
|
+
arrow: c.coral("→"),
|
|
29
|
+
bullet: c.dim("○"),
|
|
30
|
+
dot: c.coral("●"),
|
|
31
|
+
};
|
package/dist/whoami.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import ora from "ora";
|
|
2
|
+
import { getApiUrl, readConfig, CONFIG_PATH } from "./config.js";
|
|
3
|
+
import { c, symbols } from "./ui.js";
|
|
4
|
+
import { FloomError, friendlyHttp, friendlyNetwork } from "./errors.js";
|
|
5
|
+
export async function whoami() {
|
|
6
|
+
const cfg = await readConfig();
|
|
7
|
+
if (!cfg) {
|
|
8
|
+
throw new FloomError("Not signed in.", "Run `floom login` to sign in.");
|
|
9
|
+
}
|
|
10
|
+
const apiUrl = cfg.apiUrl ?? getApiUrl();
|
|
11
|
+
const spinner = ora({ text: c.dim("Checking session..."), color: "yellow" }).start();
|
|
12
|
+
let me;
|
|
13
|
+
let count = 0;
|
|
14
|
+
try {
|
|
15
|
+
const res = await fetch(`${apiUrl}/api/me`, {
|
|
16
|
+
headers: { authorization: `Bearer ${cfg.accessToken}` },
|
|
17
|
+
});
|
|
18
|
+
if (!res.ok) {
|
|
19
|
+
spinner.stop();
|
|
20
|
+
throw friendlyHttp(res.status, "check your account");
|
|
21
|
+
}
|
|
22
|
+
me = (await res.json());
|
|
23
|
+
}
|
|
24
|
+
catch (err) {
|
|
25
|
+
spinner.stop();
|
|
26
|
+
if (err instanceof FloomError)
|
|
27
|
+
throw err;
|
|
28
|
+
throw friendlyNetwork(err);
|
|
29
|
+
}
|
|
30
|
+
// Best-effort: count published skills. Don't fail whoami if it errors.
|
|
31
|
+
try {
|
|
32
|
+
const res = await fetch(`${apiUrl}/api/skills/mine`, {
|
|
33
|
+
headers: { authorization: `Bearer ${cfg.accessToken}` },
|
|
34
|
+
});
|
|
35
|
+
if (res.ok) {
|
|
36
|
+
const data = (await res.json());
|
|
37
|
+
if (typeof data.count === "number")
|
|
38
|
+
count = data.count;
|
|
39
|
+
else if (Array.isArray(data.items))
|
|
40
|
+
count = data.items.length;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
// ignore
|
|
45
|
+
}
|
|
46
|
+
spinner.stop();
|
|
47
|
+
process.stdout.write(`\n${symbols.ok} ${c.bold(me.email ?? me.id)}\n`);
|
|
48
|
+
process.stdout.write(` ${c.dim(`Token at ${tildify(CONFIG_PATH)}`)}\n`);
|
|
49
|
+
if (count > 0) {
|
|
50
|
+
process.stdout.write(` ${c.dim(`${count} skill${count === 1 ? "" : "s"} published`)}\n\n`);
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
process.stdout.write("\n");
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function tildify(p) {
|
|
57
|
+
const home = process.env.HOME ?? "";
|
|
58
|
+
if (home && p.startsWith(home))
|
|
59
|
+
return "~" + p.slice(home.length);
|
|
60
|
+
return p;
|
|
61
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@floomhq/floom",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Publish AI skills from your terminal. Share with a link.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"floom": "bin/floom.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"bin",
|
|
12
|
+
"dist",
|
|
13
|
+
"README.md",
|
|
14
|
+
"LICENSE"
|
|
15
|
+
],
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=20"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "tsc",
|
|
21
|
+
"dev": "tsc --watch",
|
|
22
|
+
"typecheck": "tsc --noEmit",
|
|
23
|
+
"test": "npm run build && node --test test/*.mjs",
|
|
24
|
+
"pack:check": "npm pack --dry-run",
|
|
25
|
+
"prepack": "npm run build",
|
|
26
|
+
"prepublishOnly": "npm run build"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"clipboardy": "4.0.0",
|
|
30
|
+
"open": "10.1.0",
|
|
31
|
+
"ora": "8.1.1",
|
|
32
|
+
"picocolors": "1.1.1",
|
|
33
|
+
"update-notifier": "7.3.1"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@types/node": "22.10.5",
|
|
37
|
+
"@types/update-notifier": "6.0.8",
|
|
38
|
+
"typescript": "5.7.3"
|
|
39
|
+
},
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public"
|
|
42
|
+
},
|
|
43
|
+
"repository": {
|
|
44
|
+
"type": "git",
|
|
45
|
+
"url": "git+https://github.com/floomhq/floom.git",
|
|
46
|
+
"directory": "cli"
|
|
47
|
+
},
|
|
48
|
+
"keywords": [
|
|
49
|
+
"ai",
|
|
50
|
+
"skills",
|
|
51
|
+
"claude",
|
|
52
|
+
"markdown",
|
|
53
|
+
"cli",
|
|
54
|
+
"floom"
|
|
55
|
+
]
|
|
56
|
+
}
|