@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/library.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import ora from "ora";
|
|
2
|
+
import { getApiUrl, readConfig } from "./config.js";
|
|
3
|
+
import { deleteRequest, getJson, postJson, putJson } from "./lib/api.js";
|
|
4
|
+
import { c, symbols } from "./ui.js";
|
|
5
|
+
import { FloomError } from "./errors.js";
|
|
6
|
+
function formatLibraryRow(lib) {
|
|
7
|
+
const name = lib.name ?? c.dim("(unnamed)");
|
|
8
|
+
const vis = c.dim(`[${lib.visibility}]`);
|
|
9
|
+
return ` ${c.cyan(lib.slug.padEnd(22))} ${name} ${vis}`;
|
|
10
|
+
}
|
|
11
|
+
export async function libraryList(opts = {}) {
|
|
12
|
+
const cfg = await readConfig();
|
|
13
|
+
const apiUrl = cfg?.apiUrl ?? getApiUrl();
|
|
14
|
+
const spinner = opts.json ? null : ora({ text: c.dim("Loading libraries..."), color: "yellow" }).start();
|
|
15
|
+
let result;
|
|
16
|
+
try {
|
|
17
|
+
result = await getJson(`${apiUrl}/api/v1/libraries`, "load libraries", cfg?.accessToken);
|
|
18
|
+
}
|
|
19
|
+
finally {
|
|
20
|
+
spinner?.stop();
|
|
21
|
+
}
|
|
22
|
+
if (opts.json) {
|
|
23
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
process.stdout.write(`\n${symbols.dot} ${c.bold("Public libraries")} ${c.dim(`(${result.libraries.length})`)}\n\n`);
|
|
27
|
+
if (result.libraries.length === 0) {
|
|
28
|
+
process.stdout.write(` ${c.dim("No public libraries yet.")}\n\n`);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
for (const lib of result.libraries)
|
|
32
|
+
process.stdout.write(`${formatLibraryRow(lib)}\n`);
|
|
33
|
+
process.stdout.write("\n");
|
|
34
|
+
}
|
|
35
|
+
export async function libraryCreate(opts) {
|
|
36
|
+
const cfg = await readConfig();
|
|
37
|
+
if (!cfg)
|
|
38
|
+
throw new FloomError("Not signed in.", "Run `floom login` first.");
|
|
39
|
+
const apiUrl = cfg.apiUrl ?? getApiUrl();
|
|
40
|
+
const result = await postJson(`${apiUrl}/api/v1/libraries`, "create library", cfg.accessToken, {
|
|
41
|
+
slug: opts.slug,
|
|
42
|
+
name: opts.name,
|
|
43
|
+
...(opts.description !== undefined ? { description: opts.description } : {}),
|
|
44
|
+
...(opts.visibility ? { visibility: opts.visibility } : {}),
|
|
45
|
+
});
|
|
46
|
+
process.stdout.write(`\n${symbols.ok} Library created: ${c.cyan(result.slug)}\n`);
|
|
47
|
+
process.stdout.write(` ${c.dim("View:")} ${apiUrl}/library/${result.slug}\n\n`);
|
|
48
|
+
}
|
|
49
|
+
export async function librarySubscribe(slug) {
|
|
50
|
+
const cfg = await readConfig();
|
|
51
|
+
if (!cfg)
|
|
52
|
+
throw new FloomError("Not signed in.", "Run `floom login` first.");
|
|
53
|
+
const apiUrl = cfg.apiUrl ?? getApiUrl();
|
|
54
|
+
await postJson(`${apiUrl}/api/v1/me/subscriptions`, "subscribe to library", cfg.accessToken, { library_slug: slug });
|
|
55
|
+
process.stdout.write(`\n${symbols.ok} Subscribed to ${c.cyan(slug)}\n`);
|
|
56
|
+
process.stdout.write(` ${c.dim("Skills will sync into ~/.claude/skills/" + slug + "/")}\n\n`);
|
|
57
|
+
}
|
|
58
|
+
export async function libraryUnsubscribe(slug) {
|
|
59
|
+
const cfg = await readConfig();
|
|
60
|
+
if (!cfg)
|
|
61
|
+
throw new FloomError("Not signed in.", "Run `floom login` first.");
|
|
62
|
+
const apiUrl = cfg.apiUrl ?? getApiUrl();
|
|
63
|
+
await deleteRequest(`${apiUrl}/api/v1/me/subscriptions/${encodeURIComponent(slug)}`, "unsubscribe from library", cfg.accessToken);
|
|
64
|
+
process.stdout.write(`\n${symbols.ok} Unsubscribed from ${c.cyan(slug)}\n\n`);
|
|
65
|
+
}
|
|
66
|
+
export async function moveSkill(opts) {
|
|
67
|
+
const cfg = await readConfig();
|
|
68
|
+
if (!cfg)
|
|
69
|
+
throw new FloomError("Not signed in.", "Run `floom login` first.");
|
|
70
|
+
const apiUrl = cfg.apiUrl ?? getApiUrl();
|
|
71
|
+
await putJson(`${apiUrl}/api/v1/me/skills/${encodeURIComponent(opts.slug)}/override`, "set skill override", cfg.accessToken, { folder: opts.folder, tags: opts.tags });
|
|
72
|
+
const folderText = opts.folder ?? c.dim("(root)");
|
|
73
|
+
const tagsText = opts.tags.length ? opts.tags.join(", ") : c.dim("(none)");
|
|
74
|
+
process.stdout.write(`\n${symbols.ok} Moved ${c.cyan(opts.slug)}\n`);
|
|
75
|
+
process.stdout.write(` ${c.dim("folder:")} ${folderText}\n`);
|
|
76
|
+
process.stdout.write(` ${c.dim("tags:")} ${tagsText}\n\n`);
|
|
77
|
+
}
|
package/dist/list.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
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 formatRow(s) {
|
|
7
|
+
const title = s.title ?? c.dim("(untitled)");
|
|
8
|
+
const visibility = c.dim(`[${s.visibility}]`);
|
|
9
|
+
const type = c.dim(`[${s.asset_type ?? "skill"}]`);
|
|
10
|
+
const version = s.version ? c.dim(`v${s.version}`) : "";
|
|
11
|
+
const updated = c.dim(formatRelative(s.updated_at));
|
|
12
|
+
return ` ${c.cyan(s.slug.padEnd(14))} ${title} ${type} ${visibility} ${version} ${updated}`;
|
|
13
|
+
}
|
|
14
|
+
function formatRelative(iso) {
|
|
15
|
+
const diff = Date.now() - new Date(iso).getTime();
|
|
16
|
+
if (Number.isNaN(diff))
|
|
17
|
+
return iso;
|
|
18
|
+
const sec = Math.floor(diff / 1000);
|
|
19
|
+
if (sec < 60)
|
|
20
|
+
return `${sec}s ago`;
|
|
21
|
+
const min = Math.floor(sec / 60);
|
|
22
|
+
if (min < 60)
|
|
23
|
+
return `${min}m ago`;
|
|
24
|
+
const hr = Math.floor(min / 60);
|
|
25
|
+
if (hr < 24)
|
|
26
|
+
return `${hr}h ago`;
|
|
27
|
+
const day = Math.floor(hr / 24);
|
|
28
|
+
if (day < 30)
|
|
29
|
+
return `${day}d ago`;
|
|
30
|
+
return new Date(iso).toISOString().slice(0, 10);
|
|
31
|
+
}
|
|
32
|
+
export async function list(opts) {
|
|
33
|
+
const cfg = await readConfig();
|
|
34
|
+
if (!cfg) {
|
|
35
|
+
throw new FloomError("Not signed in.", "Run `floom login` first.");
|
|
36
|
+
}
|
|
37
|
+
const apiUrl = cfg.apiUrl ?? getApiUrl();
|
|
38
|
+
const spinner = opts.json ? null : ora({ text: c.dim("Loading skills..."), color: "yellow" }).start();
|
|
39
|
+
let published = [];
|
|
40
|
+
try {
|
|
41
|
+
const mine = await getJson(`${apiUrl}/api/me/skills`, "load your skills", cfg.accessToken);
|
|
42
|
+
published = mine.skills ?? [];
|
|
43
|
+
}
|
|
44
|
+
finally {
|
|
45
|
+
spinner?.stop();
|
|
46
|
+
}
|
|
47
|
+
if (opts.json) {
|
|
48
|
+
process.stdout.write(`${JSON.stringify({ published }, null, 2)}\n`);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
process.stdout.write(`\n${symbols.dot} ${c.bold("Published")} ${c.dim(`(${published.length})`)}\n\n`);
|
|
52
|
+
if (published.length === 0) {
|
|
53
|
+
process.stdout.write(` ${c.dim("Nothing published yet. Try `floom publish skill.md`.")}\n`);
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
for (const s of published)
|
|
57
|
+
process.stdout.write(`${formatRow(s)}\n`);
|
|
58
|
+
}
|
|
59
|
+
process.stdout.write("\n");
|
|
60
|
+
}
|
package/dist/login.js
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import open from "open";
|
|
3
|
+
import ora from "ora";
|
|
4
|
+
import { getApiUrl, writeConfig } from "./config.js";
|
|
5
|
+
import { c, header, symbols } from "./ui.js";
|
|
6
|
+
import { FloomError, friendlyHttp, friendlyNetwork } from "./errors.js";
|
|
7
|
+
const DEFAULT_PORT = 7456;
|
|
8
|
+
const TIMEOUT_MS = 5 * 60 * 1000;
|
|
9
|
+
export async function login() {
|
|
10
|
+
const apiUrl = getApiUrl();
|
|
11
|
+
const port = await pickPort();
|
|
12
|
+
process.stdout.write(header());
|
|
13
|
+
process.stdout.write(`${symbols.arrow} Opening browser to sign in with Google...\n\n`);
|
|
14
|
+
const spinner = ora({
|
|
15
|
+
text: c.dim("Waiting for sign-in to complete..."),
|
|
16
|
+
color: "yellow",
|
|
17
|
+
}).start();
|
|
18
|
+
let tokens;
|
|
19
|
+
try {
|
|
20
|
+
tokens = await waitForCallback(port);
|
|
21
|
+
}
|
|
22
|
+
catch (err) {
|
|
23
|
+
spinner.stop();
|
|
24
|
+
if (err instanceof Error && /timed out/i.test(err.message)) {
|
|
25
|
+
throw new FloomError("No worries — try `floom login` again when ready.");
|
|
26
|
+
}
|
|
27
|
+
throw err;
|
|
28
|
+
}
|
|
29
|
+
spinner.text = c.dim("Verifying your account...");
|
|
30
|
+
const expiresIn = Number(tokens.expires_in ?? "3600");
|
|
31
|
+
const expiresAt = Math.floor(Date.now() / 1000) + (Number.isFinite(expiresIn) ? expiresIn : 3600);
|
|
32
|
+
let me;
|
|
33
|
+
try {
|
|
34
|
+
const meRes = await fetch(`${apiUrl}/api/me`, {
|
|
35
|
+
headers: { authorization: `Bearer ${tokens.access_token}` },
|
|
36
|
+
});
|
|
37
|
+
if (!meRes.ok) {
|
|
38
|
+
spinner.stop();
|
|
39
|
+
throw friendlyHttp(meRes.status, "verify your sign-in");
|
|
40
|
+
}
|
|
41
|
+
me = (await meRes.json());
|
|
42
|
+
}
|
|
43
|
+
catch (err) {
|
|
44
|
+
spinner.stop();
|
|
45
|
+
if (err instanceof FloomError)
|
|
46
|
+
throw err;
|
|
47
|
+
throw friendlyNetwork(err);
|
|
48
|
+
}
|
|
49
|
+
await writeConfig({
|
|
50
|
+
apiUrl,
|
|
51
|
+
accessToken: tokens.access_token,
|
|
52
|
+
refreshToken: tokens.refresh_token,
|
|
53
|
+
expiresAt,
|
|
54
|
+
email: me.email,
|
|
55
|
+
});
|
|
56
|
+
spinner.stop();
|
|
57
|
+
process.stdout.write(`${symbols.ok} Signed in as ${c.bold(me.email ?? me.id)}\n`);
|
|
58
|
+
process.stdout.write(` ${c.dim("Your token is saved at ~/.floom/config.json")}\n\n`);
|
|
59
|
+
}
|
|
60
|
+
/** Reserve a free port. Defaults to 7456 (must be in Supabase uri_allow_list). */
|
|
61
|
+
async function pickPort() {
|
|
62
|
+
// Supabase uri_allow_list whitelists 7456 explicitly. If it's busy, we fail
|
|
63
|
+
// loudly rather than silently using a port that won't be allowed.
|
|
64
|
+
return DEFAULT_PORT;
|
|
65
|
+
}
|
|
66
|
+
function waitForCallback(port) {
|
|
67
|
+
return new Promise((resolve, reject) => {
|
|
68
|
+
const apiUrl = getApiUrl();
|
|
69
|
+
let settled = false;
|
|
70
|
+
const server = createServer((req, res) => {
|
|
71
|
+
// CORS preflight from the browser bridge page.
|
|
72
|
+
const origin = req.headers.origin ?? "*";
|
|
73
|
+
if (req.method === "OPTIONS") {
|
|
74
|
+
res.writeHead(204, {
|
|
75
|
+
"access-control-allow-origin": origin,
|
|
76
|
+
"access-control-allow-methods": "POST, OPTIONS",
|
|
77
|
+
"access-control-allow-headers": "content-type",
|
|
78
|
+
"access-control-allow-private-network": "true",
|
|
79
|
+
"access-control-max-age": "600",
|
|
80
|
+
});
|
|
81
|
+
res.end();
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (req.method === "POST" && req.url === "/cli-callback") {
|
|
85
|
+
let body = "";
|
|
86
|
+
req.on("data", (chunk) => { body += chunk.toString("utf8"); });
|
|
87
|
+
req.on("end", () => {
|
|
88
|
+
try {
|
|
89
|
+
const data = parseCallbackBody(body, req.headers["content-type"]);
|
|
90
|
+
if (!data.access_token || !data.refresh_token) {
|
|
91
|
+
res.writeHead(400, {
|
|
92
|
+
"access-control-allow-origin": origin,
|
|
93
|
+
"access-control-allow-private-network": "true",
|
|
94
|
+
"content-type": "text/html; charset=utf-8",
|
|
95
|
+
});
|
|
96
|
+
res.end(localCallbackPage("Missing tokens from OAuth response."));
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
res.writeHead(200, {
|
|
100
|
+
"access-control-allow-origin": origin,
|
|
101
|
+
"access-control-allow-private-network": "true",
|
|
102
|
+
"content-type": "text/html; charset=utf-8",
|
|
103
|
+
});
|
|
104
|
+
res.end(localCallbackPage("Signed in. You can close this tab."));
|
|
105
|
+
settled = true;
|
|
106
|
+
cleanup();
|
|
107
|
+
resolve(data);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
res.writeHead(400, { "content-type": "text/html; charset=utf-8", "access-control-allow-origin": origin });
|
|
111
|
+
res.end(localCallbackPage("Invalid OAuth response."));
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
res.writeHead(404, { "content-type": "text/plain" });
|
|
117
|
+
res.end("Not found");
|
|
118
|
+
});
|
|
119
|
+
const timer = setTimeout(() => {
|
|
120
|
+
if (settled)
|
|
121
|
+
return;
|
|
122
|
+
settled = true;
|
|
123
|
+
cleanup();
|
|
124
|
+
reject(new Error("Login timed out after 5 minutes."));
|
|
125
|
+
}, TIMEOUT_MS);
|
|
126
|
+
function cleanup() {
|
|
127
|
+
clearTimeout(timer);
|
|
128
|
+
server.close();
|
|
129
|
+
}
|
|
130
|
+
server.on("error", (err) => {
|
|
131
|
+
if (settled)
|
|
132
|
+
return;
|
|
133
|
+
settled = true;
|
|
134
|
+
clearTimeout(timer);
|
|
135
|
+
reject(new FloomError(`Local auth server failed on port ${port}.`, `Is port ${port} already in use? (${err.message})`));
|
|
136
|
+
});
|
|
137
|
+
server.listen(port, "127.0.0.1", () => {
|
|
138
|
+
const target = `${apiUrl}/auth/cli?port=${port}`;
|
|
139
|
+
open(target).catch((e) => {
|
|
140
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
141
|
+
process.stdout.write(c.yellow(`Could not auto-open browser (${msg}).\n`) +
|
|
142
|
+
c.dim(`Open this URL manually: ${target}\n`));
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
function parseCallbackBody(body, contentType) {
|
|
148
|
+
const type = Array.isArray(contentType) ? contentType.join(";") : contentType ?? "";
|
|
149
|
+
if (type.includes("application/x-www-form-urlencoded")) {
|
|
150
|
+
const params = new URLSearchParams(body);
|
|
151
|
+
const parsed = {};
|
|
152
|
+
for (const key of ["access_token", "refresh_token", "expires_in", "token_type"]) {
|
|
153
|
+
const value = params.get(key);
|
|
154
|
+
if (value)
|
|
155
|
+
parsed[key] = value;
|
|
156
|
+
}
|
|
157
|
+
return parsed;
|
|
158
|
+
}
|
|
159
|
+
return JSON.parse(body);
|
|
160
|
+
}
|
|
161
|
+
function localCallbackPage(message) {
|
|
162
|
+
return `<!doctype html><html><head><meta charset="utf-8"><title>Floom CLI sign-in</title></head><body style="font-family:system-ui,sans-serif;margin:48px"><h1>${message}</h1><p>Return to your terminal to continue.</p></body></html>`;
|
|
163
|
+
}
|
package/dist/mcp.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { c } from "./ui.js";
|
|
2
|
+
export function printMcpSetup() {
|
|
3
|
+
const snippet = `## Floom
|
|
4
|
+
- Your installed skills live in ~/.claude/skills/ and auto-sync from Floom on each session
|
|
5
|
+
- To install a skill: ask me "install floom skill <slug>"
|
|
6
|
+
- To publish: ask me "publish this as a floom skill"`;
|
|
7
|
+
process.stdout.write(`\n${c.bold("Floom MCP setup")}\n\n`);
|
|
8
|
+
process.stdout.write(`${c.dim("Pick your tool:")}\n\n`);
|
|
9
|
+
process.stdout.write(` ${c.bold("Claude Code")}\n`);
|
|
10
|
+
process.stdout.write(` ${c.cyan("claude mcp add floom -- npx -y @floomhq/floom-mcp-sync")}\n\n`);
|
|
11
|
+
process.stdout.write(` ${c.bold("Codex CLI")}\n`);
|
|
12
|
+
process.stdout.write(` ${c.cyan("codex mcp add floom -- npx -y @floomhq/floom-mcp-sync")}\n\n`);
|
|
13
|
+
process.stdout.write(` ${c.bold("Gemini CLI")}\n`);
|
|
14
|
+
process.stdout.write(` ${c.cyan("gemini mcp add -s user floom npx -y @floomhq/floom-mcp-sync")}\n\n`);
|
|
15
|
+
process.stdout.write(` ${c.bold("Kimi CLI")}\n`);
|
|
16
|
+
process.stdout.write(` ${c.cyan("kimi mcp add --transport stdio floom -- npx -y @floomhq/floom-mcp-sync")}\n\n`);
|
|
17
|
+
process.stdout.write(` ${c.bold("OpenCode")}\n`);
|
|
18
|
+
process.stdout.write(` ${c.dim("Edit ~/.config/opencode/opencode.json - see guide.")}\n\n`);
|
|
19
|
+
process.stdout.write(`${c.dim("Full guide:")} ${c.cyan("https://floom.dev/docs/mcp")}\n\n`);
|
|
20
|
+
process.stdout.write(`${c.dim("Recommended CLAUDE.md snippet:")}\n\n`);
|
|
21
|
+
process.stdout.write(`${snippet}\n\n`);
|
|
22
|
+
}
|
package/dist/publish.js
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { basename, resolve } from "node:path";
|
|
3
|
+
import ora from "ora";
|
|
4
|
+
import clipboard from "clipboardy";
|
|
5
|
+
import { getApiUrl, getWebUrl, readConfig } from "./config.js";
|
|
6
|
+
import { c, symbols } from "./ui.js";
|
|
7
|
+
import { FloomError, friendlyHttp, friendlyNetwork } from "./errors.js";
|
|
8
|
+
const ASSET_TYPES = new Set(["knowledge", "instruction", "workflow", "skill"]);
|
|
9
|
+
const INSTALL_TARGETS = new Set([
|
|
10
|
+
"claude_skill",
|
|
11
|
+
"memory",
|
|
12
|
+
"rule",
|
|
13
|
+
"codex_instruction",
|
|
14
|
+
"opencode_instruction",
|
|
15
|
+
"cursor_rule",
|
|
16
|
+
"other",
|
|
17
|
+
]);
|
|
18
|
+
const VERSION_RE = /^[A-Za-z0-9][A-Za-z0-9._+-]{0,63}$/;
|
|
19
|
+
function parseFrontmatter(input) {
|
|
20
|
+
const trimmed = input.replace(/^/, "");
|
|
21
|
+
if (!trimmed.startsWith("---"))
|
|
22
|
+
return { meta: {}, body: input };
|
|
23
|
+
const end = trimmed.indexOf("\n---", 3);
|
|
24
|
+
if (end === -1) {
|
|
25
|
+
return {
|
|
26
|
+
meta: {},
|
|
27
|
+
body: input,
|
|
28
|
+
error: { message: "Frontmatter opens with `---` but never closes.", line: 1 },
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
const headerBlock = trimmed.slice(3, end).trim();
|
|
32
|
+
const rest = trimmed.slice(end + 4).replace(/^\r?\n/, "");
|
|
33
|
+
const meta = {};
|
|
34
|
+
const lines = headerBlock.split(/\r?\n/);
|
|
35
|
+
for (let i = 0; i < lines.length; i++) {
|
|
36
|
+
const rawLine = lines[i] ?? "";
|
|
37
|
+
const line = rawLine.trim();
|
|
38
|
+
if (!line || line.startsWith("#"))
|
|
39
|
+
continue;
|
|
40
|
+
const colon = line.indexOf(":");
|
|
41
|
+
if (colon === -1) {
|
|
42
|
+
return { meta, body: rest, error: { message: `Couldn't parse line: \`${rawLine}\``, line: i + 2 } };
|
|
43
|
+
}
|
|
44
|
+
const key = line.slice(0, colon).trim().toLowerCase();
|
|
45
|
+
let value = line.slice(colon + 1).trim();
|
|
46
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
47
|
+
value = value.slice(1, -1);
|
|
48
|
+
}
|
|
49
|
+
if (key === "title"
|
|
50
|
+
|| key === "description"
|
|
51
|
+
|| key === "version"
|
|
52
|
+
|| key === "slug"
|
|
53
|
+
|| key === "type"
|
|
54
|
+
|| key === "asset_type"
|
|
55
|
+
|| key === "installs_as"
|
|
56
|
+
|| key === "installs-as") {
|
|
57
|
+
if (key === "installs-as") {
|
|
58
|
+
meta.installs_as = value;
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
meta[key] = value;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return { meta, body: rest };
|
|
66
|
+
}
|
|
67
|
+
function parseAssetType(value, source) {
|
|
68
|
+
if (!value)
|
|
69
|
+
return undefined;
|
|
70
|
+
if (ASSET_TYPES.has(value))
|
|
71
|
+
return value;
|
|
72
|
+
throw new FloomError(`Invalid ${source}: ${value}`, "Use one of: knowledge, instruction, workflow, skill.");
|
|
73
|
+
}
|
|
74
|
+
function parseInstallsAs(value, source) {
|
|
75
|
+
if (!value)
|
|
76
|
+
return undefined;
|
|
77
|
+
if (INSTALL_TARGETS.has(value))
|
|
78
|
+
return value;
|
|
79
|
+
throw new FloomError(`Invalid ${source}: ${value}`, "Use one of: claude_skill, memory, rule, codex_instruction, opencode_instruction, cursor_rule, other.");
|
|
80
|
+
}
|
|
81
|
+
function parseVersion(value, source) {
|
|
82
|
+
if (!value)
|
|
83
|
+
return undefined;
|
|
84
|
+
if (VERSION_RE.test(value))
|
|
85
|
+
return value;
|
|
86
|
+
throw new FloomError(`Invalid ${source}: ${value}`, "Use 1-64 characters: letters, numbers, dots, underscores, plus, or hyphen.");
|
|
87
|
+
}
|
|
88
|
+
export async function publish(opts) {
|
|
89
|
+
const cfg = await readConfig();
|
|
90
|
+
if (!cfg) {
|
|
91
|
+
throw new FloomError("Not signed in.", "Run `floom login` first.");
|
|
92
|
+
}
|
|
93
|
+
const filePath = resolve(process.cwd(), opts.file);
|
|
94
|
+
let raw;
|
|
95
|
+
try {
|
|
96
|
+
raw = await readFile(filePath, "utf8");
|
|
97
|
+
}
|
|
98
|
+
catch (e) {
|
|
99
|
+
const code = e.code;
|
|
100
|
+
if (code === "ENOENT") {
|
|
101
|
+
throw new FloomError(`File not found: ${opts.file}`);
|
|
102
|
+
}
|
|
103
|
+
if (code === "EISDIR") {
|
|
104
|
+
throw new FloomError(`That's a directory, not a file: ${opts.file}`);
|
|
105
|
+
}
|
|
106
|
+
throw new FloomError(`Couldn't read ${opts.file}: ${e.message}`);
|
|
107
|
+
}
|
|
108
|
+
if (!raw.trim()) {
|
|
109
|
+
throw new FloomError(`File is empty: ${opts.file}`);
|
|
110
|
+
}
|
|
111
|
+
const { meta, error: fmError } = parseFrontmatter(raw);
|
|
112
|
+
if (fmError) {
|
|
113
|
+
throw new FloomError(`Couldn't parse frontmatter — check your YAML.`, `Line ${fmError.line}: ${fmError.message}`);
|
|
114
|
+
}
|
|
115
|
+
const assetType = opts.assetType ?? parseAssetType(meta.asset_type ?? meta.type, "frontmatter type") ?? "skill";
|
|
116
|
+
const installsAs = opts.installsAs ?? parseInstallsAs(meta.installs_as, "frontmatter installs_as") ?? null;
|
|
117
|
+
const version = opts.version ?? parseVersion(meta.version, "frontmatter version") ?? null;
|
|
118
|
+
// Later version: detect already-published when --update is missing.
|
|
119
|
+
// The current API does not return a duplicate-skill code, so we leave
|
|
120
|
+
// the actual dedupe to the server and surface the friendly hint on 409.
|
|
121
|
+
const spinner = ora({
|
|
122
|
+
text: c.dim(`Publishing ${meta.title ? c.bold(meta.title) : opts.file}...`),
|
|
123
|
+
color: "yellow",
|
|
124
|
+
}).start();
|
|
125
|
+
const apiUrl = cfg.apiUrl ?? getApiUrl();
|
|
126
|
+
let res;
|
|
127
|
+
try {
|
|
128
|
+
res = await fetch(`${apiUrl}/api/skills`, {
|
|
129
|
+
method: "POST",
|
|
130
|
+
headers: {
|
|
131
|
+
authorization: `Bearer ${cfg.accessToken}`,
|
|
132
|
+
"content-type": "application/json",
|
|
133
|
+
},
|
|
134
|
+
body: JSON.stringify({
|
|
135
|
+
title: meta.title ?? null,
|
|
136
|
+
description: meta.description ?? null,
|
|
137
|
+
body_md: raw,
|
|
138
|
+
visibility: opts.visibility,
|
|
139
|
+
asset_type: assetType,
|
|
140
|
+
source: "markdown",
|
|
141
|
+
installs_as: installsAs,
|
|
142
|
+
version,
|
|
143
|
+
original_filename: basename(filePath),
|
|
144
|
+
published_via: "cli",
|
|
145
|
+
shared_with_emails: opts.sharedWithEmails,
|
|
146
|
+
}),
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
catch (err) {
|
|
150
|
+
spinner.stop();
|
|
151
|
+
throw friendlyNetwork(err);
|
|
152
|
+
}
|
|
153
|
+
if (res.status === 409) {
|
|
154
|
+
spinner.stop();
|
|
155
|
+
throw new FloomError("Skill already published.", "Use `--update` to publish a new version (coming in a future release).");
|
|
156
|
+
}
|
|
157
|
+
if (!res.ok) {
|
|
158
|
+
spinner.stop();
|
|
159
|
+
throw friendlyHttp(res.status, "publish your skill");
|
|
160
|
+
}
|
|
161
|
+
const data = (await res.json());
|
|
162
|
+
// Build a humans-friendly URL: strip the trailing `.md` if the API returned one.
|
|
163
|
+
const webBase = getWebUrl();
|
|
164
|
+
const humanUrl = data.url
|
|
165
|
+
? data.url.replace(/\.md$/, "")
|
|
166
|
+
: `${webBase}/s/${data.slug}`;
|
|
167
|
+
spinner.stop();
|
|
168
|
+
const versionTag = version ? c.dim(` (v${version})`) : "";
|
|
169
|
+
const titleLabel = data.title ? `"${data.title}"` : opts.file;
|
|
170
|
+
process.stdout.write(`\n${symbols.ok} Published ${c.bold(titleLabel)}${versionTag}\n\n`);
|
|
171
|
+
process.stdout.write(` ${c.cyan(humanUrl)}\n\n`);
|
|
172
|
+
if (data.visibility === "shared" && data.shared_with_emails?.length) {
|
|
173
|
+
process.stdout.write(` ${c.dim(`Shared with ${data.shared_with_emails.join(", ")}`)}\n\n`);
|
|
174
|
+
}
|
|
175
|
+
let copied = false;
|
|
176
|
+
try {
|
|
177
|
+
await clipboard.write(humanUrl);
|
|
178
|
+
copied = true;
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
copied = false;
|
|
182
|
+
}
|
|
183
|
+
if (copied) {
|
|
184
|
+
process.stdout.write(` ${c.dim("Copied to clipboard. Share it anywhere.")}\n\n`);
|
|
185
|
+
}
|
|
186
|
+
else {
|
|
187
|
+
process.stdout.write(` ${c.dim("Share it anywhere.")}\n\n`);
|
|
188
|
+
}
|
|
189
|
+
}
|
package/dist/share.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import ora from "ora";
|
|
2
|
+
import { getApiUrl, readConfig } from "./config.js";
|
|
3
|
+
import { friendlyHttp, friendlyNetwork, FloomError } from "./errors.js";
|
|
4
|
+
import { c, symbols } from "./ui.js";
|
|
5
|
+
function slugFromInput(input) {
|
|
6
|
+
const trimmed = input.trim();
|
|
7
|
+
try {
|
|
8
|
+
const url = new URL(trimmed);
|
|
9
|
+
const last = url.pathname.split("/").filter(Boolean).at(-1) ?? "";
|
|
10
|
+
return last.replace(/\.(md|json)$/i, "");
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return trimmed.replace(/\.(md|json)$/i, "");
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
async function readJson(res) {
|
|
17
|
+
return (await res.json());
|
|
18
|
+
}
|
|
19
|
+
export async function share(opts) {
|
|
20
|
+
const cfg = await readConfig();
|
|
21
|
+
if (!cfg) {
|
|
22
|
+
throw new FloomError("Not signed in.", "Run `floom login` first.");
|
|
23
|
+
}
|
|
24
|
+
const slug = slugFromInput(opts.slug);
|
|
25
|
+
if (!slug) {
|
|
26
|
+
throw new FloomError("Missing skill slug.", "Try: `floom share <slug> --list`");
|
|
27
|
+
}
|
|
28
|
+
const apiUrl = cfg.apiUrl ?? getApiUrl();
|
|
29
|
+
const endpoint = `${apiUrl}/api/skills/${encodeURIComponent(slug)}/share`;
|
|
30
|
+
const spinner = ora({ text: c.dim("Updating sharing..."), color: "yellow" }).start();
|
|
31
|
+
let res;
|
|
32
|
+
try {
|
|
33
|
+
if (opts.kind === "list") {
|
|
34
|
+
spinner.text = c.dim("Loading sharing...");
|
|
35
|
+
res = await fetch(endpoint, {
|
|
36
|
+
headers: { authorization: `Bearer ${cfg.accessToken}` },
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
res = await fetch(endpoint, {
|
|
41
|
+
method: "PATCH",
|
|
42
|
+
headers: {
|
|
43
|
+
authorization: `Bearer ${cfg.accessToken}`,
|
|
44
|
+
"content-type": "application/json",
|
|
45
|
+
},
|
|
46
|
+
body: JSON.stringify({ add: opts.add, remove: opts.remove }),
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
spinner.stop();
|
|
52
|
+
throw friendlyNetwork(err);
|
|
53
|
+
}
|
|
54
|
+
if (!res.ok) {
|
|
55
|
+
spinner.stop();
|
|
56
|
+
throw friendlyHttp(res.status, "update sharing");
|
|
57
|
+
}
|
|
58
|
+
const data = await readJson(res);
|
|
59
|
+
spinner.stop();
|
|
60
|
+
const emails = data.shared_with_emails;
|
|
61
|
+
process.stdout.write(`\n${symbols.ok} Sharing for ${c.bold(data.slug)}\n\n`);
|
|
62
|
+
if (emails.length === 0) {
|
|
63
|
+
process.stdout.write(` ${c.dim("No emails added.")}\n\n`);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
for (const email of emails) {
|
|
67
|
+
process.stdout.write(` ${email}\n`);
|
|
68
|
+
}
|
|
69
|
+
process.stdout.write("\n");
|
|
70
|
+
}
|