@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
package/dist/doctor.js
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { stat, readFile, access, readdir, constants } from "node:fs/promises";
|
|
4
|
+
import { getApiUrl, readConfig, CONFIG_PATH } from "./config.js";
|
|
5
|
+
import { c, symbols } from "./ui.js";
|
|
6
|
+
const CLI_VERSION = "1.0.0";
|
|
7
|
+
function statusBadge(s) {
|
|
8
|
+
if (s === "ok")
|
|
9
|
+
return c.green(symbols.ok);
|
|
10
|
+
if (s === "warn")
|
|
11
|
+
return c.yellow("!");
|
|
12
|
+
return c.red(symbols.fail);
|
|
13
|
+
}
|
|
14
|
+
async function checkAuth() {
|
|
15
|
+
const cfg = await readConfig();
|
|
16
|
+
if (!cfg) {
|
|
17
|
+
return {
|
|
18
|
+
name: "Auth",
|
|
19
|
+
status: "ok",
|
|
20
|
+
detail: "Receiver mode ready. Sign in only when publishing or listing your own skills.",
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
const apiUrl = cfg.apiUrl ?? getApiUrl();
|
|
24
|
+
try {
|
|
25
|
+
const res = await fetch(`${apiUrl}/api/me`, {
|
|
26
|
+
headers: { authorization: `Bearer ${cfg.accessToken}` },
|
|
27
|
+
});
|
|
28
|
+
if (res.status === 401) {
|
|
29
|
+
return {
|
|
30
|
+
name: "Auth",
|
|
31
|
+
status: "fail",
|
|
32
|
+
detail: "Token rejected (401).",
|
|
33
|
+
hint: "Run `floom logout && floom login` to refresh.",
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
if (!res.ok) {
|
|
37
|
+
return {
|
|
38
|
+
name: "Auth",
|
|
39
|
+
status: "warn",
|
|
40
|
+
detail: `API returned ${res.status}.`,
|
|
41
|
+
hint: "Service may be degraded; try again shortly.",
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
const data = (await res.json());
|
|
45
|
+
return {
|
|
46
|
+
name: "Auth",
|
|
47
|
+
status: "ok",
|
|
48
|
+
detail: data.email ? `Signed in as ${data.email}` : "Signed in",
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
catch (err) {
|
|
52
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
53
|
+
return {
|
|
54
|
+
name: "Auth",
|
|
55
|
+
status: "fail",
|
|
56
|
+
detail: `Cannot reach ${apiUrl}: ${msg}`,
|
|
57
|
+
hint: "Check your network. Try `curl " + apiUrl + "/api/me`.",
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
async function checkMcp() {
|
|
62
|
+
const candidates = [
|
|
63
|
+
{ name: "Claude Code", path: join(homedir(), ".claude.json") },
|
|
64
|
+
{ name: "Claude Code (settings)", path: join(homedir(), ".claude", "settings.json") },
|
|
65
|
+
{ name: "Codex", path: join(homedir(), ".codex", "config.toml") },
|
|
66
|
+
{ name: "Gemini", path: join(homedir(), ".gemini", "settings.json") },
|
|
67
|
+
];
|
|
68
|
+
const found = [];
|
|
69
|
+
for (const cand of candidates) {
|
|
70
|
+
try {
|
|
71
|
+
const buf = await readFile(cand.path, "utf8");
|
|
72
|
+
// Cheap match: any reference to a Floom MCP server. The actual MCP
|
|
73
|
+
// package name is @floomhq/floom-mcp-sync. Detect by package name OR
|
|
74
|
+
// by "floom" + "mcp_servers"/"mcpServers" co-occurrence.
|
|
75
|
+
const hasFloom = buf.includes("@floomhq/floom-mcp-sync") ||
|
|
76
|
+
(buf.includes("\"floom\"") && (buf.includes("mcpServers") || buf.includes("mcp_servers"))) ||
|
|
77
|
+
(buf.includes("[mcp_servers.floom]"));
|
|
78
|
+
if (hasFloom)
|
|
79
|
+
found.push({ name: cand.name, configPath: cand.path, hasFloom });
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// file missing — fine
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (found.length === 0) {
|
|
86
|
+
return {
|
|
87
|
+
name: "MCP",
|
|
88
|
+
status: "ok",
|
|
89
|
+
detail: "Optional MCP not registered. `floom add` still writes local skill files.",
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
name: "MCP",
|
|
94
|
+
status: "ok",
|
|
95
|
+
detail: `Registered with: ${found.map((f) => f.name).join(", ")}`,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
function targetSkillsDir() {
|
|
99
|
+
return process.env.CLAUDE_SKILLS_DIR ?? join(homedir(), ".claude", "skills");
|
|
100
|
+
}
|
|
101
|
+
async function checkTargetDir() {
|
|
102
|
+
const dir = targetSkillsDir();
|
|
103
|
+
try {
|
|
104
|
+
const s = await stat(dir);
|
|
105
|
+
if (!s.isDirectory()) {
|
|
106
|
+
return {
|
|
107
|
+
name: "Target dir",
|
|
108
|
+
status: "fail",
|
|
109
|
+
detail: `${dir} exists but is not a directory.`,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
await access(dir, constants.W_OK);
|
|
114
|
+
return { name: "Target dir", status: "ok", detail: `${dir} (writable)` };
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return {
|
|
118
|
+
name: "Target dir",
|
|
119
|
+
status: "fail",
|
|
120
|
+
detail: `${dir} is not writable.`,
|
|
121
|
+
hint: `Try: chmod u+w ${dir}`,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
catch (err) {
|
|
126
|
+
if (err.code === "ENOENT") {
|
|
127
|
+
return {
|
|
128
|
+
name: "Target dir",
|
|
129
|
+
status: "warn",
|
|
130
|
+
detail: `${dir} does not exist yet.`,
|
|
131
|
+
hint: "It will be created on first `floom add`.",
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
throw err;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
async function checkLastSync() {
|
|
138
|
+
const dir = targetSkillsDir();
|
|
139
|
+
try {
|
|
140
|
+
const entries = await readdir(dir);
|
|
141
|
+
const skills = entries.filter((e) => e.endsWith(".md") || !e.startsWith("."));
|
|
142
|
+
if (skills.length === 0) {
|
|
143
|
+
return {
|
|
144
|
+
name: "Last sync",
|
|
145
|
+
status: "warn",
|
|
146
|
+
detail: "No synced skills found yet.",
|
|
147
|
+
hint: "Run `floom add <link>` to install your first skill.",
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
// Find most recently modified entry
|
|
151
|
+
let newest = { name: "", mtime: 0 };
|
|
152
|
+
for (const name of skills) {
|
|
153
|
+
try {
|
|
154
|
+
const s = await stat(join(dir, name));
|
|
155
|
+
if (s.mtimeMs > newest.mtime)
|
|
156
|
+
newest = { name, mtime: s.mtimeMs };
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
// skip
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (newest.mtime === 0) {
|
|
163
|
+
return {
|
|
164
|
+
name: "Last sync",
|
|
165
|
+
status: "warn",
|
|
166
|
+
detail: `${skills.length} entries, no readable mtime.`,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
const ageSec = Math.round((Date.now() - newest.mtime) / 1000);
|
|
170
|
+
const human = ageSec < 60
|
|
171
|
+
? `${ageSec}s ago`
|
|
172
|
+
: ageSec < 3600
|
|
173
|
+
? `${Math.round(ageSec / 60)}m ago`
|
|
174
|
+
: ageSec < 86400
|
|
175
|
+
? `${Math.round(ageSec / 3600)}h ago`
|
|
176
|
+
: `${Math.round(ageSec / 86400)}d ago`;
|
|
177
|
+
return {
|
|
178
|
+
name: "Last sync",
|
|
179
|
+
status: "ok",
|
|
180
|
+
detail: `${newest.name} — ${human} (${skills.length} total)`,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
catch (err) {
|
|
184
|
+
if (err.code === "ENOENT") {
|
|
185
|
+
return {
|
|
186
|
+
name: "Last sync",
|
|
187
|
+
status: "warn",
|
|
188
|
+
detail: "Skills dir not created yet.",
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
return {
|
|
192
|
+
name: "Last sync",
|
|
193
|
+
status: "warn",
|
|
194
|
+
detail: `Cannot read skills dir: ${err.message}`,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
async function checkVersion() {
|
|
199
|
+
const apiUrl = (await readConfig())?.apiUrl ?? getApiUrl();
|
|
200
|
+
try {
|
|
201
|
+
const res = await fetch(`${apiUrl}/api/v1/cli-version`, {
|
|
202
|
+
headers: { accept: "application/json" },
|
|
203
|
+
});
|
|
204
|
+
if (!res.ok) {
|
|
205
|
+
// Endpoint optional — treat as info-only, not a failure
|
|
206
|
+
return {
|
|
207
|
+
name: "Version",
|
|
208
|
+
status: "ok",
|
|
209
|
+
detail: `CLI v${CLI_VERSION} (server check skipped)`,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
const data = (await res.json());
|
|
213
|
+
if (data.min && CLI_VERSION < data.min) {
|
|
214
|
+
return {
|
|
215
|
+
name: "Version",
|
|
216
|
+
status: "fail",
|
|
217
|
+
detail: `CLI v${CLI_VERSION} below required v${data.min}.`,
|
|
218
|
+
hint: "Run `npm i -g @floomhq/floom` to upgrade.",
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
if (data.latest && CLI_VERSION !== data.latest) {
|
|
222
|
+
return {
|
|
223
|
+
name: "Version",
|
|
224
|
+
status: "warn",
|
|
225
|
+
detail: `CLI v${CLI_VERSION}, latest is v${data.latest}.`,
|
|
226
|
+
hint: "Run `npm i -g @floomhq/floom` to upgrade.",
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
return { name: "Version", status: "ok", detail: `CLI v${CLI_VERSION} (current)` };
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
return {
|
|
233
|
+
name: "Version",
|
|
234
|
+
status: "ok",
|
|
235
|
+
detail: `CLI v${CLI_VERSION} (offline)`,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
export async function doctor() {
|
|
240
|
+
process.stdout.write(`\n${c.bold("floom doctor")} ${c.dim(`(v${CLI_VERSION})`)}\n\n`);
|
|
241
|
+
const checks = await Promise.all([
|
|
242
|
+
checkAuth(),
|
|
243
|
+
checkMcp(),
|
|
244
|
+
checkTargetDir(),
|
|
245
|
+
checkLastSync(),
|
|
246
|
+
checkVersion(),
|
|
247
|
+
]);
|
|
248
|
+
// Compute column widths for clean table output.
|
|
249
|
+
const nameW = Math.max(...checks.map((c) => c.name.length), 6);
|
|
250
|
+
for (const check of checks) {
|
|
251
|
+
const padded = check.name.padEnd(nameW);
|
|
252
|
+
process.stdout.write(` ${statusBadge(check.status)} ${c.bold(padded)} ${check.detail}\n`);
|
|
253
|
+
if (check.hint && check.status !== "ok") {
|
|
254
|
+
process.stdout.write(` ${c.dim("→ " + check.hint)}\n`);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
const anyFail = checks.some((c) => c.status === "fail");
|
|
258
|
+
const anyWarn = checks.some((c) => c.status === "warn");
|
|
259
|
+
process.stdout.write("\n");
|
|
260
|
+
if (anyFail) {
|
|
261
|
+
process.stdout.write(` ${c.red("✗ Some checks failed.")} See hints above.\n\n`);
|
|
262
|
+
process.exit(1);
|
|
263
|
+
}
|
|
264
|
+
if (anyWarn) {
|
|
265
|
+
process.stdout.write(` ${c.yellow("! All critical checks passed, with warnings.")}\n\n`);
|
|
266
|
+
process.exit(0);
|
|
267
|
+
}
|
|
268
|
+
process.stdout.write(` ${c.green("✓ All checks passed.")} Floom is healthy.\n\n`);
|
|
269
|
+
process.stdout.write(` ${c.dim("Config: " + CONFIG_PATH)}\n\n`);
|
|
270
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { c, symbols } from "./ui.js";
|
|
2
|
+
/**
|
|
3
|
+
* FloomError is a friendly, user-facing error. Throw this instead of raw Error
|
|
4
|
+
* when you want a clean message (no stack trace, no "Error:" prefix).
|
|
5
|
+
*/
|
|
6
|
+
export class FloomError extends Error {
|
|
7
|
+
hint;
|
|
8
|
+
constructor(message, hint) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = "FloomError";
|
|
11
|
+
if (hint)
|
|
12
|
+
this.hint = hint;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export function friendlyHttp(status, action) {
|
|
16
|
+
if (status === 401) {
|
|
17
|
+
return new FloomError("Your token expired.", "Run `floom login` to refresh.");
|
|
18
|
+
}
|
|
19
|
+
if (status === 403) {
|
|
20
|
+
return new FloomError(`You don't have permission to ${action}.`);
|
|
21
|
+
}
|
|
22
|
+
if (status === 404) {
|
|
23
|
+
return new FloomError("Skill not found.", "Run `floom publish` without `--update` to create a new one.");
|
|
24
|
+
}
|
|
25
|
+
if (status === 413) {
|
|
26
|
+
return new FloomError("That file is too large to publish.");
|
|
27
|
+
}
|
|
28
|
+
if (status >= 500) {
|
|
29
|
+
return new FloomError("Floom is having trouble right now.", "Try again in a moment.");
|
|
30
|
+
}
|
|
31
|
+
return new FloomError(`Request failed (HTTP ${status}) while trying to ${action}.`);
|
|
32
|
+
}
|
|
33
|
+
export function friendlyNetwork(err) {
|
|
34
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
35
|
+
if (/ENOTFOUND|EAI_AGAIN|ECONNREFUSED|ECONNRESET|ETIMEDOUT|fetch failed/i.test(msg)) {
|
|
36
|
+
return new FloomError("Couldn't reach floom (offline?).", "Try again in a moment.");
|
|
37
|
+
}
|
|
38
|
+
return new FloomError(msg);
|
|
39
|
+
}
|
|
40
|
+
export function printError(err) {
|
|
41
|
+
if (err instanceof FloomError) {
|
|
42
|
+
process.stderr.write(`\n${symbols.fail} ${err.message}\n`);
|
|
43
|
+
if (err.hint)
|
|
44
|
+
process.stderr.write(` ${c.dim(err.hint)}\n\n`);
|
|
45
|
+
else
|
|
46
|
+
process.stderr.write("\n");
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
50
|
+
process.stderr.write(`\n${symbols.fail} ${msg}\n\n`);
|
|
51
|
+
}
|
package/dist/info.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import ora from "ora";
|
|
2
|
+
import { getApiUrl, readConfig } from "./config.js";
|
|
3
|
+
import { getJson } from "./lib/api.js";
|
|
4
|
+
import { c, symbols } from "./ui.js";
|
|
5
|
+
import { FloomError } from "./errors.js";
|
|
6
|
+
function slugFromInput(input) {
|
|
7
|
+
const trimmed = input.trim();
|
|
8
|
+
try {
|
|
9
|
+
const url = new URL(trimmed);
|
|
10
|
+
const last = url.pathname.split("/").filter(Boolean).at(-1) ?? "";
|
|
11
|
+
return last.replace(/\.(md|json)$/i, "");
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return trimmed.replace(/\.(md|json)$/i, "");
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export async function info(opts) {
|
|
18
|
+
const slug = slugFromInput(opts.slug);
|
|
19
|
+
if (!slug)
|
|
20
|
+
throw new FloomError("Missing skill slug.", "Try: `floom info <slug>`");
|
|
21
|
+
const cfg = await readConfig();
|
|
22
|
+
const apiUrl = cfg?.apiUrl ?? getApiUrl();
|
|
23
|
+
const spinner = opts.json ? null : ora({ text: c.dim(`Loading ${slug}...`), color: "yellow" }).start();
|
|
24
|
+
let detail;
|
|
25
|
+
try {
|
|
26
|
+
detail = await getJson(`${apiUrl}/api/v1/skills/${encodeURIComponent(slug)}`, "load skill metadata", cfg?.accessToken);
|
|
27
|
+
}
|
|
28
|
+
finally {
|
|
29
|
+
spinner?.stop();
|
|
30
|
+
}
|
|
31
|
+
if (opts.json) {
|
|
32
|
+
// Strip body_md from JSON output to keep it summary-shaped.
|
|
33
|
+
const { body_md: _body, ...summary } = detail;
|
|
34
|
+
process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const title = detail.title ?? c.dim("(untitled)");
|
|
38
|
+
process.stdout.write(`\n${symbols.dot} ${c.bold(title)}\n\n`);
|
|
39
|
+
process.stdout.write(` ${c.dim("Slug: ")}${detail.slug}\n`);
|
|
40
|
+
if (detail.description) {
|
|
41
|
+
process.stdout.write(` ${c.dim("About: ")}${detail.description}\n`);
|
|
42
|
+
}
|
|
43
|
+
process.stdout.write(` ${c.dim("Visibility: ")}${detail.visibility}\n`);
|
|
44
|
+
process.stdout.write(` ${c.dim("Type: ")}${detail.asset_type ?? "skill"}\n`);
|
|
45
|
+
if (detail.version) {
|
|
46
|
+
process.stdout.write(` ${c.dim("Version: ")}${detail.version}\n`);
|
|
47
|
+
}
|
|
48
|
+
if (detail.installs_as) {
|
|
49
|
+
process.stdout.write(` ${c.dim("Installs: ")}${detail.installs_as}\n`);
|
|
50
|
+
}
|
|
51
|
+
if (detail.original_filename) {
|
|
52
|
+
process.stdout.write(` ${c.dim("File: ")}${detail.original_filename}\n`);
|
|
53
|
+
}
|
|
54
|
+
if (detail.owner_email) {
|
|
55
|
+
process.stdout.write(` ${c.dim("Owner: ")}${detail.owner_email}\n`);
|
|
56
|
+
}
|
|
57
|
+
if (typeof detail.save_count === "number") {
|
|
58
|
+
process.stdout.write(` ${c.dim("Saves: ")}${detail.save_count}\n`);
|
|
59
|
+
}
|
|
60
|
+
if (detail.shared_with_emails?.length) {
|
|
61
|
+
process.stdout.write(` ${c.dim("Shared: ")}${detail.shared_with_emails.join(", ")}\n`);
|
|
62
|
+
}
|
|
63
|
+
process.stdout.write(` ${c.dim("Created: ")}${detail.created_at}\n`);
|
|
64
|
+
process.stdout.write(` ${c.dim("Updated: ")}${detail.updated_at}\n`);
|
|
65
|
+
process.stdout.write(` ${c.dim("URL: ")}${c.cyan(detail.url.replace(/\.md$/, ""))}\n\n`);
|
|
66
|
+
}
|
package/dist/init.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { writeFile, access } from "node:fs/promises";
|
|
2
|
+
import { resolve, basename } from "node:path";
|
|
3
|
+
import { createInterface } from "node:readline/promises";
|
|
4
|
+
import { stdin as input, stdout as output } from "node:process";
|
|
5
|
+
import { c, symbols } from "./ui.js";
|
|
6
|
+
import { FloomError } from "./errors.js";
|
|
7
|
+
const TEMPLATE = `---
|
|
8
|
+
title:
|
|
9
|
+
description:
|
|
10
|
+
version: 1.0
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
# Goal
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# When to use
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# Inputs
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# Instructions
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# Output
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# Examples
|
|
29
|
+
`;
|
|
30
|
+
export async function init(filename) {
|
|
31
|
+
const target = filename ?? "skill.md";
|
|
32
|
+
const filePath = resolve(process.cwd(), target);
|
|
33
|
+
const exists = await fileExists(filePath);
|
|
34
|
+
if (exists) {
|
|
35
|
+
if (!process.stdin.isTTY) {
|
|
36
|
+
throw new FloomError(`${target} already exists.`, "Re-run with a different filename, or delete it first.");
|
|
37
|
+
}
|
|
38
|
+
const rl = createInterface({ input, output });
|
|
39
|
+
const answer = (await rl.question(`${c.yellow("?")} ${target} already exists. Overwrite? ${c.dim("(y/N)")} `)).trim().toLowerCase();
|
|
40
|
+
rl.close();
|
|
41
|
+
if (answer !== "y" && answer !== "yes") {
|
|
42
|
+
process.stdout.write(`\n${c.dim("Cancelled. Nothing was written.")}\n\n`);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
await writeFile(filePath, TEMPLATE, "utf8");
|
|
47
|
+
process.stdout.write(`\n${symbols.ok} Created ${c.bold(basename(filePath))}\n`);
|
|
48
|
+
process.stdout.write(` ${c.dim("Fill in the title + description, then run:")}\n`);
|
|
49
|
+
process.stdout.write(` ${c.cyan(`floom publish ${target}`)}\n\n`);
|
|
50
|
+
}
|
|
51
|
+
async function fileExists(p) {
|
|
52
|
+
try {
|
|
53
|
+
await access(p);
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
}
|
package/dist/install.js
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
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
|
+
const SLUG_RE = /^[A-Za-z0-9_-]{1,128}$/;
|
|
12
|
+
const FD_PATH_ROOT = "/proc/self/fd";
|
|
13
|
+
function slugFromInput(input) {
|
|
14
|
+
const trimmed = input.trim();
|
|
15
|
+
try {
|
|
16
|
+
const url = new URL(trimmed);
|
|
17
|
+
const last = url.pathname.split("/").filter(Boolean).at(-1) ?? "";
|
|
18
|
+
return last.replace(/\.(md|json)$/i, "");
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return trimmed.replace(/\.(md|json)$/i, "");
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function skillsDir() {
|
|
25
|
+
return process.env.CLAUDE_SKILLS_DIR ?? join(homedir(), ".claude", "skills");
|
|
26
|
+
}
|
|
27
|
+
function skillPath(slug) {
|
|
28
|
+
return join(skillsDir(), `${slug}.md`);
|
|
29
|
+
}
|
|
30
|
+
function sha256(input) {
|
|
31
|
+
return createHash("sha256").update(input).digest("hex");
|
|
32
|
+
}
|
|
33
|
+
async function localHash(path) {
|
|
34
|
+
try {
|
|
35
|
+
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
36
|
+
try {
|
|
37
|
+
const stat = await handle.stat();
|
|
38
|
+
if (!stat.isFile())
|
|
39
|
+
throw new FloomError("Local path is blocked by an existing file or directory.");
|
|
40
|
+
return sha256(await handle.readFile("utf8"));
|
|
41
|
+
}
|
|
42
|
+
finally {
|
|
43
|
+
await handle.close();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
const code = err.code;
|
|
48
|
+
if (code === "ENOENT")
|
|
49
|
+
return null;
|
|
50
|
+
if (code === "ELOOP")
|
|
51
|
+
throw new FloomError("Local path is a symbolic link.", "Move or delete the local path, then run `floom add` again.");
|
|
52
|
+
if (code === "ENOTDIR" || code === "EISDIR") {
|
|
53
|
+
throw new FloomError("Local path is blocked by an existing file or directory.");
|
|
54
|
+
}
|
|
55
|
+
throw err;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
async function writeInstallFile(target, body) {
|
|
59
|
+
const parent = await openSafeParentDirectory(skillsDir(), target);
|
|
60
|
+
let handle = null;
|
|
61
|
+
try {
|
|
62
|
+
handle = await open(childCreatePath(parent, dirname(target), basename(target)), constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
63
|
+
await writeAll(handle, body);
|
|
64
|
+
}
|
|
65
|
+
finally {
|
|
66
|
+
await handle?.close();
|
|
67
|
+
await parent.close();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
async function openSafeParentDirectory(root, target) {
|
|
71
|
+
await ensureSafeParentDirectory(root, target);
|
|
72
|
+
return open(dirname(target), constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
|
73
|
+
}
|
|
74
|
+
function childCreatePath(parent, fallbackParent, name) {
|
|
75
|
+
if (process.platform === "linux")
|
|
76
|
+
return `${FD_PATH_ROOT}/${parent.fd}/${name}`;
|
|
77
|
+
return join(resolve(fallbackParent), name);
|
|
78
|
+
}
|
|
79
|
+
async function writeAll(handle, body) {
|
|
80
|
+
const buffer = Buffer.from(body, "utf8");
|
|
81
|
+
let offset = 0;
|
|
82
|
+
while (offset < buffer.length) {
|
|
83
|
+
const result = await handle.write(buffer, offset, buffer.length - offset, offset);
|
|
84
|
+
if (result.bytesWritten === 0)
|
|
85
|
+
throw new FloomError("Failed to write local skill file.");
|
|
86
|
+
offset += result.bytesWritten;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
async function ensureSafeParentDirectory(root, target) {
|
|
90
|
+
const resolvedRoot = resolve(root);
|
|
91
|
+
const resolvedParent = resolve(dirname(target));
|
|
92
|
+
const relativeParent = relative(resolvedRoot, resolvedParent);
|
|
93
|
+
if (relativeParent === ".." || relativeParent.startsWith(`..${sep}`) || isAbsolute(relativeParent)) {
|
|
94
|
+
throw new FloomError("Invalid skill target path.");
|
|
95
|
+
}
|
|
96
|
+
await mkdir(resolvedRoot, { recursive: true, mode: 0o700 });
|
|
97
|
+
await assertSafeDirectory(resolvedRoot);
|
|
98
|
+
if (!relativeParent || relativeParent === ".")
|
|
99
|
+
return;
|
|
100
|
+
let current = resolvedRoot;
|
|
101
|
+
for (const segment of relativeParent.split(sep).filter(Boolean)) {
|
|
102
|
+
current = join(current, segment);
|
|
103
|
+
try {
|
|
104
|
+
await assertSafeDirectory(current);
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
if (err.code !== "ENOENT")
|
|
108
|
+
throw err;
|
|
109
|
+
await mkdir(current, { mode: 0o700 });
|
|
110
|
+
await assertSafeDirectory(current);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
async function assertSafeDirectory(path) {
|
|
115
|
+
const stat = await lstat(path);
|
|
116
|
+
if (stat.isSymbolicLink()) {
|
|
117
|
+
throw new FloomError("Local path contains a symbolic link.", "Move or delete the local path, then run `floom add` again.");
|
|
118
|
+
}
|
|
119
|
+
if (!stat.isDirectory()) {
|
|
120
|
+
throw new FloomError("Local path is blocked by an existing file or directory.");
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
export async function install(slugInput) {
|
|
124
|
+
const slug = slugFromInput(slugInput);
|
|
125
|
+
if (!SLUG_RE.test(slug)) {
|
|
126
|
+
throw new FloomError(`Invalid skill slug: ${slugInput}`);
|
|
127
|
+
}
|
|
128
|
+
const cfg = await readConfig();
|
|
129
|
+
const apiUrl = cfg?.apiUrl ?? getApiUrl();
|
|
130
|
+
const spinner = ora({ text: c.dim(`Adding ${slug}...`), color: "yellow" }).start();
|
|
131
|
+
let detail;
|
|
132
|
+
try {
|
|
133
|
+
detail = await getJson(`${apiUrl}/api/v1/skills/${encodeURIComponent(slug)}`, "fetch skill", cfg?.accessToken);
|
|
134
|
+
if (!detail || typeof detail.body_md !== "string") {
|
|
135
|
+
throw new FloomError("Invalid skill response.");
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
catch (err) {
|
|
139
|
+
spinner.stop();
|
|
140
|
+
throw err;
|
|
141
|
+
}
|
|
142
|
+
await mkdir(skillsDir(), { recursive: true, mode: 0o700 });
|
|
143
|
+
const target = skillPath(slug);
|
|
144
|
+
const remoteHash = sha256(detail.body_md);
|
|
145
|
+
const existing = await localHash(target);
|
|
146
|
+
let action;
|
|
147
|
+
if (existing === remoteHash) {
|
|
148
|
+
action = "unchanged";
|
|
149
|
+
}
|
|
150
|
+
else if (existing !== null) {
|
|
151
|
+
throw new FloomError("Local skill already exists with different content.", "Move or delete the local file, then run `floom add` again.");
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
try {
|
|
155
|
+
await writeInstallFile(target, detail.body_md);
|
|
156
|
+
}
|
|
157
|
+
catch (err) {
|
|
158
|
+
const code = err.code;
|
|
159
|
+
if (code === "EEXIST") {
|
|
160
|
+
throw new FloomError("Local skill already exists with different content.", "Move or delete the local file, then run `floom add` again.");
|
|
161
|
+
}
|
|
162
|
+
if (code === "ELOOP") {
|
|
163
|
+
throw new FloomError("Local path is a symbolic link.", "Move or delete the local path, then run `floom add` again.");
|
|
164
|
+
}
|
|
165
|
+
if (code === "ENOENT") {
|
|
166
|
+
throw new FloomError("Local path changed during install.", "Run `floom add` again.");
|
|
167
|
+
}
|
|
168
|
+
throw err;
|
|
169
|
+
}
|
|
170
|
+
action = "installed";
|
|
171
|
+
}
|
|
172
|
+
spinner.stop();
|
|
173
|
+
process.stdout.write(`\n${symbols.ok} [floom] ${action} ${c.bold(slug)}\n`);
|
|
174
|
+
process.stdout.write(` ${c.dim(target)}\n\n`);
|
|
175
|
+
}
|
package/dist/lib/api.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { friendlyHttp, friendlyNetwork, FloomError } from "../errors.js";
|
|
2
|
+
/**
|
|
3
|
+
* Shared HTTP helpers for CLI commands. Mirrors the mcp-sync/lib/api.ts
|
|
4
|
+
* patterns but throws FloomError instances so the CLI's printError gives
|
|
5
|
+
* users a clean message instead of a stack trace.
|
|
6
|
+
*/
|
|
7
|
+
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
8
|
+
async function floomFetch(url, action, opts = {}) {
|
|
9
|
+
const headers = {};
|
|
10
|
+
if (opts.token)
|
|
11
|
+
headers.authorization = `Bearer ${opts.token}`;
|
|
12
|
+
if (opts.body !== undefined)
|
|
13
|
+
headers["content-type"] = "application/json";
|
|
14
|
+
const controller = new AbortController();
|
|
15
|
+
const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
16
|
+
let res;
|
|
17
|
+
try {
|
|
18
|
+
res = await fetch(url, {
|
|
19
|
+
method: opts.method ?? "GET",
|
|
20
|
+
headers,
|
|
21
|
+
signal: controller.signal,
|
|
22
|
+
...(opts.body !== undefined ? { body: JSON.stringify(opts.body) } : {}),
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
catch (err) {
|
|
26
|
+
if (err.name === "AbortError") {
|
|
27
|
+
throw new FloomError(`Request timed out while trying to ${action}.`, "Try again in a moment.");
|
|
28
|
+
}
|
|
29
|
+
throw friendlyNetwork(err);
|
|
30
|
+
}
|
|
31
|
+
finally {
|
|
32
|
+
clearTimeout(timer);
|
|
33
|
+
}
|
|
34
|
+
if (!res.ok) {
|
|
35
|
+
throw friendlyHttp(res.status, action);
|
|
36
|
+
}
|
|
37
|
+
return res;
|
|
38
|
+
}
|
|
39
|
+
export async function getJson(url, action, token) {
|
|
40
|
+
const res = await floomFetch(url, action, token ? { token } : {});
|
|
41
|
+
return (await res.json());
|
|
42
|
+
}
|
|
43
|
+
export async function getText(url, action, token) {
|
|
44
|
+
const res = await floomFetch(url, action, token ? { token } : {});
|
|
45
|
+
return res.text();
|
|
46
|
+
}
|
|
47
|
+
export async function postJson(url, action, token, body) {
|
|
48
|
+
const res = await floomFetch(url, action, { method: "POST", token, body });
|
|
49
|
+
return (await res.json());
|
|
50
|
+
}
|
|
51
|
+
export async function deleteRequest(url, action, token) {
|
|
52
|
+
const res = await floomFetch(url, action, { method: "DELETE", token });
|
|
53
|
+
return (await res.json());
|
|
54
|
+
}
|
|
55
|
+
export async function putJson(url, action, token, body) {
|
|
56
|
+
const res = await floomFetch(url, action, { method: "PUT", token, body });
|
|
57
|
+
return (await res.json());
|
|
58
|
+
}
|