@floomhq/floom 1.0.44 → 1.0.46
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/dist/audit.js +168 -0
- package/dist/cli.js +254 -4
- package/dist/daemon.js +392 -0
- package/dist/feedback.js +34 -0
- package/dist/list.js +1 -0
- package/dist/publish.js +2 -1
- package/dist/setup.js +79 -9
- package/dist/sync.js +1 -1
- package/package.json +1 -1
package/dist/audit.js
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import ora from "ora";
|
|
2
|
+
import { readConfig, resolveApiUrl } from "./config.js";
|
|
3
|
+
import { getJson } from "./lib/api.js";
|
|
4
|
+
import { FloomError } from "./errors.js";
|
|
5
|
+
import { c, symbols } from "./ui.js";
|
|
6
|
+
const FIXTURE_RE = /\b(?:cli lifecycle audit fixture|launch gate|temp skill|test fixture|audit fixture)\b/i;
|
|
7
|
+
function contentHash(skill) {
|
|
8
|
+
return skill.content_sha256 ?? skill.content_hash ?? undefined;
|
|
9
|
+
}
|
|
10
|
+
function normalized(value) {
|
|
11
|
+
return (value ?? "").trim().replace(/\s+/g, " ").toLowerCase();
|
|
12
|
+
}
|
|
13
|
+
function riskFor(score, reasons) {
|
|
14
|
+
if (score >= 90 || reasons.includes("security_scan_failure"))
|
|
15
|
+
return "critical";
|
|
16
|
+
if (score >= 70)
|
|
17
|
+
return "high";
|
|
18
|
+
if (score >= 40)
|
|
19
|
+
return "medium";
|
|
20
|
+
return "low";
|
|
21
|
+
}
|
|
22
|
+
function scoreSkill(skill, duplicateGroup) {
|
|
23
|
+
const reasons = [];
|
|
24
|
+
let score = 0;
|
|
25
|
+
const title = normalized(skill.title);
|
|
26
|
+
const description = normalized(skill.description);
|
|
27
|
+
const body = skill.body_md ?? "";
|
|
28
|
+
const slug = skill.slug;
|
|
29
|
+
if (!title) {
|
|
30
|
+
score += 20;
|
|
31
|
+
reasons.push("blank_title");
|
|
32
|
+
}
|
|
33
|
+
if (!description) {
|
|
34
|
+
score += 15;
|
|
35
|
+
reasons.push("blank_description");
|
|
36
|
+
}
|
|
37
|
+
if (duplicateGroup) {
|
|
38
|
+
score += 35;
|
|
39
|
+
reasons.push("duplicate_content_hash");
|
|
40
|
+
}
|
|
41
|
+
if (FIXTURE_RE.test(`${skill.title ?? ""} ${skill.description ?? ""} ${slug}`)) {
|
|
42
|
+
score += 25;
|
|
43
|
+
reasons.push("launch_or_test_fixture");
|
|
44
|
+
}
|
|
45
|
+
if (title && title === normalized(slug)) {
|
|
46
|
+
score += 20;
|
|
47
|
+
reasons.push("slug_only_title");
|
|
48
|
+
}
|
|
49
|
+
if (body.trim().length > 0 && body.trim().length < 80) {
|
|
50
|
+
score += 35;
|
|
51
|
+
reasons.push("near_empty_body");
|
|
52
|
+
}
|
|
53
|
+
if (!/^---\s*\n/.test(body.trimStart())) {
|
|
54
|
+
score += 30;
|
|
55
|
+
reasons.push("missing_yaml_frontmatter");
|
|
56
|
+
}
|
|
57
|
+
if (/\b(?:api[_-]?key|secret|token)\b/i.test(body) && /\b(?:sk-|AIza|BEGIN PRIVATE KEY)\b/.test(body)) {
|
|
58
|
+
score += 50;
|
|
59
|
+
reasons.push("possible_secret");
|
|
60
|
+
}
|
|
61
|
+
if (score === 0)
|
|
62
|
+
return null;
|
|
63
|
+
const risk = riskFor(score, reasons);
|
|
64
|
+
const hash = contentHash(skill);
|
|
65
|
+
return {
|
|
66
|
+
slug,
|
|
67
|
+
title: skill.title?.trim() || "",
|
|
68
|
+
risk,
|
|
69
|
+
score,
|
|
70
|
+
reasons,
|
|
71
|
+
...(hash ? { content_hash: hash } : {}),
|
|
72
|
+
...(duplicateGroup ? { duplicate_group: duplicateGroup } : {}),
|
|
73
|
+
recommended_action: score >= 70 ? "archive" : "review",
|
|
74
|
+
safe: !reasons.includes("possible_secret"),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
async function loadOwnedSkills() {
|
|
78
|
+
const cfg = await readConfig();
|
|
79
|
+
if (!cfg)
|
|
80
|
+
throw new FloomError("Not signed in.", "Run `npx -y @floomhq/floom login` first.");
|
|
81
|
+
const apiUrl = resolveApiUrl(cfg);
|
|
82
|
+
const skills = [];
|
|
83
|
+
let cursor;
|
|
84
|
+
const seenCursors = new Set();
|
|
85
|
+
for (let page = 0; page < 1000; page += 1) {
|
|
86
|
+
const url = new URL(`${apiUrl}/api/v1/me/skills`);
|
|
87
|
+
url.searchParams.set("limit", "100");
|
|
88
|
+
url.searchParams.set("scope", "owned");
|
|
89
|
+
if (cursor)
|
|
90
|
+
url.searchParams.set("cursor", cursor);
|
|
91
|
+
const mine = await getJson(url.toString(), "audit your skills", cfg.accessToken);
|
|
92
|
+
skills.push(...(mine.skills ?? []));
|
|
93
|
+
if (!mine.next_cursor)
|
|
94
|
+
break;
|
|
95
|
+
if (seenCursors.has(mine.next_cursor))
|
|
96
|
+
throw new FloomError("Invalid skills response.");
|
|
97
|
+
seenCursors.add(mine.next_cursor);
|
|
98
|
+
cursor = mine.next_cursor;
|
|
99
|
+
}
|
|
100
|
+
return skills;
|
|
101
|
+
}
|
|
102
|
+
function duplicateGroups(skills) {
|
|
103
|
+
const byHash = new Map();
|
|
104
|
+
for (const skill of skills) {
|
|
105
|
+
const hash = contentHash(skill);
|
|
106
|
+
if (!hash)
|
|
107
|
+
continue;
|
|
108
|
+
const group = byHash.get(hash) ?? [];
|
|
109
|
+
group.push(skill);
|
|
110
|
+
byHash.set(hash, group);
|
|
111
|
+
}
|
|
112
|
+
const out = new Map();
|
|
113
|
+
let i = 1;
|
|
114
|
+
for (const [hash, group] of byHash.entries()) {
|
|
115
|
+
if (group.length < 2)
|
|
116
|
+
continue;
|
|
117
|
+
const id = `dup_${i}`;
|
|
118
|
+
i += 1;
|
|
119
|
+
for (const skill of group)
|
|
120
|
+
out.set(`${hash}\0${skill.slug}`, id);
|
|
121
|
+
}
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
function summarize(skills, findings) {
|
|
125
|
+
return {
|
|
126
|
+
total: skills.length,
|
|
127
|
+
findings: findings.length,
|
|
128
|
+
critical: findings.filter((f) => f.risk === "critical").length,
|
|
129
|
+
high: findings.filter((f) => f.risk === "high").length,
|
|
130
|
+
medium: findings.filter((f) => f.risk === "medium").length,
|
|
131
|
+
low: findings.filter((f) => f.risk === "low").length,
|
|
132
|
+
blank_titles: skills.filter((s) => !normalized(s.title)).length,
|
|
133
|
+
blank_descriptions: skills.filter((s) => !normalized(s.description)).length,
|
|
134
|
+
duplicate_hash_groups: new Set(findings.map((f) => f.duplicate_group).filter(Boolean)).size,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
export async function auditSkills(opts) {
|
|
138
|
+
const spinner = opts.json ? null : ora({ text: c.dim("Auditing skills..."), color: "yellow" }).start();
|
|
139
|
+
let skills;
|
|
140
|
+
try {
|
|
141
|
+
skills = await loadOwnedSkills();
|
|
142
|
+
}
|
|
143
|
+
finally {
|
|
144
|
+
spinner?.stop();
|
|
145
|
+
}
|
|
146
|
+
const duplicates = duplicateGroups(skills);
|
|
147
|
+
const findings = skills
|
|
148
|
+
.map((skill) => scoreSkill(skill, contentHash(skill) ? duplicates.get(`${contentHash(skill)}\0${skill.slug}`) : undefined))
|
|
149
|
+
.filter((finding) => finding !== null)
|
|
150
|
+
.sort((a, b) => b.score - a.score || a.slug.localeCompare(b.slug));
|
|
151
|
+
const summary = summarize(skills, findings);
|
|
152
|
+
const archivePlan = findings
|
|
153
|
+
.filter((finding) => finding.recommended_action === "archive")
|
|
154
|
+
.map((finding) => ({ slug: finding.slug, reasons: finding.reasons, score: finding.score }));
|
|
155
|
+
if (opts.json || opts.fixPlan) {
|
|
156
|
+
process.stdout.write(`${JSON.stringify({ summary, findings, ...(opts.fixPlan ? { archive_plan: archivePlan } : {}) }, null, 2)}\n`);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
process.stdout.write(`\n${symbols.dot} ${c.bold("Skill audit")} ${c.dim(`(${summary.total} owned skills)`)}\n\n`);
|
|
160
|
+
process.stdout.write(` findings: ${summary.findings} critical: ${summary.critical} high: ${summary.high} medium: ${summary.medium} low: ${summary.low}\n`);
|
|
161
|
+
process.stdout.write(` blank titles: ${summary.blank_titles} blank descriptions: ${summary.blank_descriptions} duplicate groups: ${summary.duplicate_hash_groups}\n\n`);
|
|
162
|
+
for (const finding of findings.slice(0, 25)) {
|
|
163
|
+
process.stdout.write(` ${c.cyan(finding.slug.padEnd(14))} ${finding.risk.padEnd(8)} ${String(finding.score).padStart(3)} ${finding.reasons.join(", ")}\n`);
|
|
164
|
+
}
|
|
165
|
+
if (findings.length > 25)
|
|
166
|
+
process.stdout.write(` ${c.dim(`... ${findings.length - 25} more. Run with --json for full output.`)}\n`);
|
|
167
|
+
process.stdout.write("\n");
|
|
168
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -12,11 +12,14 @@ import { deleteSkill } from "./delete.js";
|
|
|
12
12
|
import { doctor } from "./doctor.js";
|
|
13
13
|
import { sync } from "./sync.js";
|
|
14
14
|
import { watchPush } from "./push-watch.js";
|
|
15
|
+
import { daemon, normalizeDaemonOptions, parseDaemonTarget } from "./daemon.js";
|
|
15
16
|
import { printMcpSetup } from "./mcp.js";
|
|
16
17
|
import { setupAgent } from "./setup.js";
|
|
17
18
|
import { search } from "./search.js";
|
|
18
19
|
import { scanSkill } from "./scan.js";
|
|
20
|
+
import { auditSkills } from "./audit.js";
|
|
19
21
|
import { share } from "./share.js";
|
|
22
|
+
import { feedback } from "./feedback.js";
|
|
20
23
|
import { libraryAddSkill, libraryCreate, libraryList, libraryRemoveSkill, librarySubscribe, libraryUnsubscribe, moveSkill, } from "./library.js";
|
|
21
24
|
import { c, symbols } from "./ui.js";
|
|
22
25
|
import { printError, FloomError } from "./errors.js";
|
|
@@ -94,6 +97,7 @@ function commandUsage() {
|
|
|
94
97
|
${c.dim("Alias: rm")}
|
|
95
98
|
${c.cyan("whoami")} Show the signed-in account
|
|
96
99
|
${c.cyan("logout")} Switch accounts or remove local credentials
|
|
100
|
+
${c.cyan("feedback")} Send agent feedback to Floom
|
|
97
101
|
|
|
98
102
|
${c.dim("Agent setup")}
|
|
99
103
|
${c.cyan("setup")} Configure Claude Code or Codex instructions
|
|
@@ -105,12 +109,16 @@ function commandUsage() {
|
|
|
105
109
|
${c.dim("Advanced")}
|
|
106
110
|
${c.cyan("library")} Create, browse, and subscribe to libraries
|
|
107
111
|
${c.dim("Alias: lib")}
|
|
108
|
-
${c.cyan("move")} ${c.dim("<slug> --folder <path>")} Place a saved skill in a
|
|
112
|
+
${c.cyan("move")} ${c.dim("<slug> --folder <path>")} Place a saved skill in a relative folder
|
|
109
113
|
${c.cyan("mcp")} Print optional MCP setup guidance
|
|
110
114
|
${c.cyan("sync")} Preview pull of published, saved, and library skills
|
|
111
115
|
${c.dim(`Flags: --target ${TARGET_HINT} (default: claude)`)}
|
|
112
116
|
${c.cyan("watch")} Preview polling sync loop
|
|
113
117
|
${c.dim(`Flags: --push, --no-yolo, --target ${TARGET_HINT}`)}
|
|
118
|
+
${c.cyan("daemon")} Install and inspect always-on Floom sync
|
|
119
|
+
${c.dim(`Flags: install|status|logs|run, --target ${TARGET_HINT}|all`)}
|
|
120
|
+
${c.cyan("audit skills")} Read-only skill quality and duplicate report
|
|
121
|
+
${c.dim("Flags: --json, --fix-plan")}
|
|
114
122
|
|
|
115
123
|
${c.bold("Examples")}
|
|
116
124
|
${c.cyan("npx -y @floomhq/floom add")} ${c.dim("https://floom.dev/s/ffas93ud --setup")}
|
|
@@ -163,13 +171,13 @@ function moveUsage() {
|
|
|
163
171
|
process.stdout.write(`
|
|
164
172
|
${c.bold("Usage:")} ${c.cyan(`${CLI_COMMAND} move`)} ${c.dim("<slug> --folder <path> [--tag <tag>]")}
|
|
165
173
|
|
|
166
|
-
${c.bold("Place a saved or subscribed skill in a
|
|
174
|
+
${c.bold("Place a saved or subscribed skill in a portable library folder.")}
|
|
167
175
|
${c.cyan(`${CLI_COMMAND} move support-tone --folder support/tone`)}
|
|
168
176
|
${c.cyan(`${CLI_COMMAND} move support-tone --root`)}
|
|
169
177
|
${c.cyan(`${CLI_COMMAND} move support-tone --folder support --tags support,tone`)}
|
|
170
178
|
|
|
171
179
|
${c.bold("Flags")}
|
|
172
|
-
${c.cyan("--folder <path>")}
|
|
180
|
+
${c.cyan("--folder <path>")} Relative folder path for synced installs
|
|
173
181
|
${c.cyan("--root")} Put the skill at the root
|
|
174
182
|
${c.cyan("--tag <tag>")} Add one tag, repeatable
|
|
175
183
|
${c.cyan("--tags a,b")} Add comma-separated tags
|
|
@@ -229,6 +237,62 @@ function doctorUsage() {
|
|
|
229
237
|
${c.cyan(`--target ${TARGET_HINT}`)} Agent target, default claude
|
|
230
238
|
`);
|
|
231
239
|
}
|
|
240
|
+
function daemonUsage() {
|
|
241
|
+
process.stdout.write(`
|
|
242
|
+
${c.bold("Usage:")} ${c.cyan(`${CLI_COMMAND} daemon`)} ${c.dim("<command> [flags]")}
|
|
243
|
+
|
|
244
|
+
${c.bold("Install and inspect always-on Floom sync.")}
|
|
245
|
+
${c.cyan(`${CLI_COMMAND} daemon install --target all --interval 300 --timeout 180`)}
|
|
246
|
+
${c.cyan(`${CLI_COMMAND} daemon status --json`)}
|
|
247
|
+
${c.cyan(`${CLI_COMMAND} daemon logs --tail 100`)}
|
|
248
|
+
${c.cyan(`${CLI_COMMAND} daemon run --foreground --target all`)}
|
|
249
|
+
|
|
250
|
+
${c.bold("Commands")}
|
|
251
|
+
${c.cyan("install")} Write systemd or launchd service definition
|
|
252
|
+
${c.cyan("status")} Print latest daemon status
|
|
253
|
+
${c.cyan("logs")} Print daemon log tail
|
|
254
|
+
${c.cyan("run")} Run the daemon loop in the foreground
|
|
255
|
+
|
|
256
|
+
${c.bold("Flags")}
|
|
257
|
+
${c.cyan("--target <target|all>")} claude|codex|cursor|opencode|kimi|all
|
|
258
|
+
${c.cyan("--interval <seconds>")} Poll interval, minimum 30, default 300
|
|
259
|
+
${c.cyan("--timeout <seconds>")} Per-command timeout, minimum 30, default 180
|
|
260
|
+
${c.cyan("--push / --no-push")} Push/adopt local changes, default push
|
|
261
|
+
${c.cyan("--yolo / --no-yolo")} Auto-publish changed local skills, default yolo
|
|
262
|
+
${c.cyan("--dry-run")} Print generated service file without writing
|
|
263
|
+
${c.cyan("--json")} Machine-readable status output
|
|
264
|
+
`);
|
|
265
|
+
}
|
|
266
|
+
function auditUsage() {
|
|
267
|
+
process.stdout.write(`
|
|
268
|
+
${c.bold("Usage:")} ${c.cyan(`${CLI_COMMAND} audit skills`)} ${c.dim("[flags]")}
|
|
269
|
+
|
|
270
|
+
${c.bold("Find low-quality, duplicate, fixture, and malformed owned skills.")}
|
|
271
|
+
${c.cyan(`${CLI_COMMAND} audit skills`)}
|
|
272
|
+
${c.cyan(`${CLI_COMMAND} audit skills --json`)}
|
|
273
|
+
${c.cyan(`${CLI_COMMAND} audit skills --fix-plan`)}
|
|
274
|
+
|
|
275
|
+
${c.bold("Flags")}
|
|
276
|
+
${c.cyan("--json")} Full machine-readable report
|
|
277
|
+
${c.cyan("--fix-plan")} Include archive recommendations, without applying them
|
|
278
|
+
`);
|
|
279
|
+
}
|
|
280
|
+
function feedbackUsage() {
|
|
281
|
+
process.stdout.write(`
|
|
282
|
+
${c.bold("Usage:")} ${c.cyan(`${CLI_COMMAND} feedback`)} ${c.dim("--kind <kind> --message <message> [flags]")}
|
|
283
|
+
|
|
284
|
+
${c.bold("Send agent feedback to Floom.")}
|
|
285
|
+
${c.cyan(`${CLI_COMMAND} feedback --kind bug --message "MCP sync missed a file"`)}
|
|
286
|
+
${c.cyan(`${CLI_COMMAND} feedback --kind idea --message "Add team defaults" --target codex --skill support-tone`)}
|
|
287
|
+
|
|
288
|
+
${c.bold("Flags")}
|
|
289
|
+
${c.cyan("--kind <kind>")} Feedback type, for example bug, idea, friction, praise
|
|
290
|
+
${c.cyan("--message <text>")} Required non-interactive feedback message
|
|
291
|
+
${c.cyan("--target <target>")} Optional agent target
|
|
292
|
+
${c.cyan("--skill <slug>")} Optional related skill slug
|
|
293
|
+
${c.cyan("--json")} Machine-readable success or error
|
|
294
|
+
`);
|
|
295
|
+
}
|
|
232
296
|
function isHelpArg(value) {
|
|
233
297
|
return value === "--help" || value === "-h" || value === "help";
|
|
234
298
|
}
|
|
@@ -247,9 +311,18 @@ function subcommandUsage(cmd) {
|
|
|
247
311
|
case "doctor":
|
|
248
312
|
doctorUsage();
|
|
249
313
|
return true;
|
|
314
|
+
case "daemon":
|
|
315
|
+
daemonUsage();
|
|
316
|
+
return true;
|
|
250
317
|
case "share":
|
|
251
318
|
shareUsage();
|
|
252
319
|
return true;
|
|
320
|
+
case "audit":
|
|
321
|
+
auditUsage();
|
|
322
|
+
return true;
|
|
323
|
+
case "feedback":
|
|
324
|
+
feedbackUsage();
|
|
325
|
+
return true;
|
|
253
326
|
case "library":
|
|
254
327
|
case "lib":
|
|
255
328
|
libraryUsage();
|
|
@@ -458,6 +531,54 @@ function parseInfoFlags(argv) {
|
|
|
458
531
|
}
|
|
459
532
|
return out;
|
|
460
533
|
}
|
|
534
|
+
function parseFeedbackFlags(argv) {
|
|
535
|
+
const out = { json: false };
|
|
536
|
+
for (let i = 0; i < argv.length; i++) {
|
|
537
|
+
const a = argv[i] ?? "";
|
|
538
|
+
if (a === "--json") {
|
|
539
|
+
out.json = true;
|
|
540
|
+
}
|
|
541
|
+
else if (a === "--kind" || a.startsWith("--kind=")) {
|
|
542
|
+
const { value, nextIndex } = readFlagValue(argv, i, "--kind");
|
|
543
|
+
out.kind = value.trim();
|
|
544
|
+
i = nextIndex;
|
|
545
|
+
}
|
|
546
|
+
else if (a === "--message" || a.startsWith("--message=")) {
|
|
547
|
+
const { value, nextIndex } = readFlagValue(argv, i, "--message");
|
|
548
|
+
out.message = value.trim();
|
|
549
|
+
i = nextIndex;
|
|
550
|
+
}
|
|
551
|
+
else if (a === "--target" || a.startsWith("--target=")) {
|
|
552
|
+
const { value, nextIndex } = readFlagValue(argv, i, "--target");
|
|
553
|
+
out.target = value.trim();
|
|
554
|
+
i = nextIndex;
|
|
555
|
+
}
|
|
556
|
+
else if (a === "--skill" || a.startsWith("--skill=")) {
|
|
557
|
+
const { value, nextIndex } = readFlagValue(argv, i, "--skill");
|
|
558
|
+
out.skill = value.trim();
|
|
559
|
+
i = nextIndex;
|
|
560
|
+
}
|
|
561
|
+
else if (a.startsWith("--")) {
|
|
562
|
+
throw new FloomError(`Unknown flag: ${a}`, `Try \`${CLI_COMMAND} feedback --kind bug --message "What happened"\`.`);
|
|
563
|
+
}
|
|
564
|
+
else {
|
|
565
|
+
throw new FloomError(`Unexpected argument: ${a}`, `Use \`${CLI_COMMAND} feedback --kind bug --message "What happened"\`.`);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
if (!out.kind)
|
|
569
|
+
throw new FloomError("Missing --kind.", `Try \`${CLI_COMMAND} feedback --kind bug --message "What happened"\`.`);
|
|
570
|
+
if (!out.message)
|
|
571
|
+
throw new FloomError("Missing --message.", "Feedback is non-interactive; pass the message with --message.");
|
|
572
|
+
if (out.kind.length > 64)
|
|
573
|
+
throw new FloomError("Invalid --kind.", "Use 1-64 characters.");
|
|
574
|
+
if (out.message.length > 4000)
|
|
575
|
+
throw new FloomError("Invalid --message.", "Use 1-4000 characters.");
|
|
576
|
+
if (out.target !== undefined && out.target.length > 128)
|
|
577
|
+
throw new FloomError("Invalid --target.", "Use at most 128 characters.");
|
|
578
|
+
if (out.skill !== undefined && out.skill.length > 128)
|
|
579
|
+
throw new FloomError("Invalid --skill.", "Use at most 128 characters.");
|
|
580
|
+
return out;
|
|
581
|
+
}
|
|
461
582
|
function parseAddArgs(argv) {
|
|
462
583
|
let slug;
|
|
463
584
|
let target;
|
|
@@ -550,6 +671,8 @@ function parseSetupFlags(argv) {
|
|
|
550
671
|
out.dryRun = true;
|
|
551
672
|
else if (a === "--yes" || a === "-y")
|
|
552
673
|
out.yes = true;
|
|
674
|
+
else if (a === "--global")
|
|
675
|
+
out.global = true;
|
|
553
676
|
else if (a === "--target" || a.startsWith("--target=")) {
|
|
554
677
|
const { value, nextIndex } = readFlagValue(argv, i, "--target");
|
|
555
678
|
out.target = parseTargetFlag(value);
|
|
@@ -588,8 +711,118 @@ function parseDoctorFlags(argv) {
|
|
|
588
711
|
}
|
|
589
712
|
return out;
|
|
590
713
|
}
|
|
714
|
+
function parseAuditFlags(argv) {
|
|
715
|
+
const [subcommand, ...rest] = argv;
|
|
716
|
+
if (subcommand !== "skills") {
|
|
717
|
+
throw new FloomError("Unknown audit command.", `Try \`${CLI_COMMAND} audit skills --json\`.`);
|
|
718
|
+
}
|
|
719
|
+
const out = { json: false, fixPlan: false };
|
|
720
|
+
for (const a of rest) {
|
|
721
|
+
if (a === "--json")
|
|
722
|
+
out.json = true;
|
|
723
|
+
else if (a === "--fix-plan")
|
|
724
|
+
out.fixPlan = true;
|
|
725
|
+
else if (a.startsWith("--"))
|
|
726
|
+
throw new FloomError(`Unknown flag: ${a}`, `Try \`${CLI_COMMAND} audit skills --json\`.`);
|
|
727
|
+
else
|
|
728
|
+
throw new FloomError(`Unexpected argument: ${a}`, `Try \`${CLI_COMMAND} audit skills --json\`.`);
|
|
729
|
+
}
|
|
730
|
+
return out;
|
|
731
|
+
}
|
|
732
|
+
function parseDaemonFlags(argv) {
|
|
733
|
+
const [command, ...rest] = argv;
|
|
734
|
+
const allowedCommands = new Set(["install", "uninstall", "status", "logs", "restart", "run"]);
|
|
735
|
+
if (!command || !allowedCommands.has(command)) {
|
|
736
|
+
throw new FloomError("Missing daemon command.", `Try \`${CLI_COMMAND} daemon install --target all\`.`);
|
|
737
|
+
}
|
|
738
|
+
const out = {
|
|
739
|
+
command: command,
|
|
740
|
+
target: "all",
|
|
741
|
+
intervalSeconds: 300,
|
|
742
|
+
timeoutSeconds: 180,
|
|
743
|
+
push: true,
|
|
744
|
+
yolo: true,
|
|
745
|
+
foreground: false,
|
|
746
|
+
dryRun: false,
|
|
747
|
+
json: false,
|
|
748
|
+
tail: 100,
|
|
749
|
+
};
|
|
750
|
+
for (let i = 0; i < rest.length; i++) {
|
|
751
|
+
const a = rest[i] ?? "";
|
|
752
|
+
if (a === "--target" || a.startsWith("--target=")) {
|
|
753
|
+
const { value, nextIndex } = readFlagValue(rest, i, "--target");
|
|
754
|
+
out.target = parseDaemonTarget(value);
|
|
755
|
+
i = nextIndex;
|
|
756
|
+
}
|
|
757
|
+
else if (a === "--interval" || a.startsWith("--interval=")) {
|
|
758
|
+
const { value, nextIndex } = readFlagValue(rest, i, "--interval");
|
|
759
|
+
const parsed = Number(value);
|
|
760
|
+
if (!Number.isInteger(parsed))
|
|
761
|
+
throw new FloomError("Invalid --interval.", "Use an integer number of seconds.");
|
|
762
|
+
out.intervalSeconds = parsed;
|
|
763
|
+
i = nextIndex;
|
|
764
|
+
}
|
|
765
|
+
else if (a === "--timeout" || a.startsWith("--timeout=")) {
|
|
766
|
+
const { value, nextIndex } = readFlagValue(rest, i, "--timeout");
|
|
767
|
+
const parsed = Number(value);
|
|
768
|
+
if (!Number.isInteger(parsed))
|
|
769
|
+
throw new FloomError("Invalid --timeout.", "Use an integer number of seconds.");
|
|
770
|
+
out.timeoutSeconds = parsed;
|
|
771
|
+
i = nextIndex;
|
|
772
|
+
}
|
|
773
|
+
else if (a === "--push") {
|
|
774
|
+
out.push = true;
|
|
775
|
+
}
|
|
776
|
+
else if (a === "--no-push") {
|
|
777
|
+
out.push = false;
|
|
778
|
+
}
|
|
779
|
+
else if (a === "--yolo") {
|
|
780
|
+
out.yolo = true;
|
|
781
|
+
}
|
|
782
|
+
else if (a === "--no-yolo") {
|
|
783
|
+
out.yolo = false;
|
|
784
|
+
}
|
|
785
|
+
else if (a === "--foreground") {
|
|
786
|
+
out.foreground = true;
|
|
787
|
+
}
|
|
788
|
+
else if (a === "--dry-run") {
|
|
789
|
+
out.dryRun = true;
|
|
790
|
+
}
|
|
791
|
+
else if (a === "--json") {
|
|
792
|
+
out.json = true;
|
|
793
|
+
}
|
|
794
|
+
else if (a === "--tail" || a.startsWith("--tail=")) {
|
|
795
|
+
const { value, nextIndex } = readFlagValue(rest, i, "--tail");
|
|
796
|
+
const parsed = Number(value);
|
|
797
|
+
if (!Number.isInteger(parsed) || parsed < 1)
|
|
798
|
+
throw new FloomError("Invalid --tail.", "Use a positive integer.");
|
|
799
|
+
out.tail = parsed;
|
|
800
|
+
i = nextIndex;
|
|
801
|
+
}
|
|
802
|
+
else if (a.startsWith("--")) {
|
|
803
|
+
throw new FloomError(`Unknown flag: ${a}`, `Try \`${CLI_COMMAND} daemon --help\`.`);
|
|
804
|
+
}
|
|
805
|
+
else {
|
|
806
|
+
throw new FloomError(`Unexpected argument: ${a}`, `Try \`${CLI_COMMAND} daemon --help\`.`);
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
return normalizeDaemonOptions(out);
|
|
810
|
+
}
|
|
591
811
|
function normalizeFolder(value) {
|
|
592
|
-
|
|
812
|
+
const normalized = value.trim().replace(/\\/g, "/").replace(/\/+/g, "/");
|
|
813
|
+
if (normalized === "root" || normalized === "/" || normalized === ".")
|
|
814
|
+
return null;
|
|
815
|
+
if (normalized.startsWith("/")) {
|
|
816
|
+
throw new FloomError("Invalid --folder: use a relative sync folder.", "Floom folders are portable library paths like `support/tone`, not absolute filesystem paths like `/tmp/floom-move-target`.");
|
|
817
|
+
}
|
|
818
|
+
if (normalized === ".." ||
|
|
819
|
+
normalized.startsWith("../") ||
|
|
820
|
+
normalized.includes("/../") ||
|
|
821
|
+
normalized.endsWith("/..")) {
|
|
822
|
+
throw new FloomError("Invalid --folder: path traversal is not allowed.", "Use a relative sync folder like `support/tone`, or use `--root`.");
|
|
823
|
+
}
|
|
824
|
+
const relative = normalized.startsWith("./") ? normalized.slice(2) : normalized;
|
|
825
|
+
return relative || null;
|
|
593
826
|
}
|
|
594
827
|
function parseFolderTagFlags(argv) {
|
|
595
828
|
const out = { tags: [], rest: [] };
|
|
@@ -931,6 +1164,17 @@ async function main() {
|
|
|
931
1164
|
});
|
|
932
1165
|
return;
|
|
933
1166
|
}
|
|
1167
|
+
case "feedback": {
|
|
1168
|
+
const flags = parseFeedbackFlags(rest);
|
|
1169
|
+
await feedback({
|
|
1170
|
+
kind: flags.kind ?? "",
|
|
1171
|
+
message: flags.message ?? "",
|
|
1172
|
+
...(flags.target ? { target: flags.target } : {}),
|
|
1173
|
+
...(flags.skill ? { skill: flags.skill } : {}),
|
|
1174
|
+
json: flags.json,
|
|
1175
|
+
});
|
|
1176
|
+
return;
|
|
1177
|
+
}
|
|
934
1178
|
case "add":
|
|
935
1179
|
case "install": {
|
|
936
1180
|
const flags = parseAddArgs(rest);
|
|
@@ -966,6 +1210,9 @@ async function main() {
|
|
|
966
1210
|
await watch(flags.intervalSeconds, flags.target, flags.once);
|
|
967
1211
|
return;
|
|
968
1212
|
}
|
|
1213
|
+
case "daemon":
|
|
1214
|
+
await daemon(parseDaemonFlags(rest));
|
|
1215
|
+
return;
|
|
969
1216
|
case "delete":
|
|
970
1217
|
case "rm": {
|
|
971
1218
|
const flags = parseDeleteFlags(rest);
|
|
@@ -976,6 +1223,9 @@ async function main() {
|
|
|
976
1223
|
case "lib":
|
|
977
1224
|
await runLibrary(rest);
|
|
978
1225
|
return;
|
|
1226
|
+
case "audit":
|
|
1227
|
+
await auditSkills(parseAuditFlags(rest));
|
|
1228
|
+
return;
|
|
979
1229
|
case "move": {
|
|
980
1230
|
const flags = parseFolderTagFlags(rest);
|
|
981
1231
|
const slug = flags.rest[0];
|
package/dist/daemon.js
ADDED
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import { homedir, hostname, platform } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { CONFIG_DIR } from "./config.js";
|
|
6
|
+
import { CLI_VERSION } from "./version.js";
|
|
7
|
+
import { FloomError } from "./errors.js";
|
|
8
|
+
import { c, symbols } from "./ui.js";
|
|
9
|
+
import { AGENT_TARGETS, isAgentTarget, targetSkillsDirEnv } from "./targets.js";
|
|
10
|
+
const SERVICE_NAME = "floom-sync.service";
|
|
11
|
+
const LAUNCHD_LABEL = "dev.floom.sync";
|
|
12
|
+
const LOG_PATH = join(CONFIG_DIR, "daemon.log");
|
|
13
|
+
const STATUS_PATH = join(CONFIG_DIR, "daemon-status.json");
|
|
14
|
+
const MIN_INTERVAL_SECONDS = 30;
|
|
15
|
+
const MIN_TIMEOUT_SECONDS = 30;
|
|
16
|
+
function targetsFor(value) {
|
|
17
|
+
return value === "all" ? [...AGENT_TARGETS] : [value];
|
|
18
|
+
}
|
|
19
|
+
function cliInvocation() {
|
|
20
|
+
const node = JSON.stringify(process.execPath);
|
|
21
|
+
const entry = JSON.stringify(process.argv[1] ?? "floom");
|
|
22
|
+
return `${node} ${entry}`;
|
|
23
|
+
}
|
|
24
|
+
function shellQuote(value) {
|
|
25
|
+
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
26
|
+
}
|
|
27
|
+
function runCommand(args, extraEnv = {}) {
|
|
28
|
+
const timeoutMs = Math.max(MIN_TIMEOUT_SECONDS, Number(args[0]) || MIN_TIMEOUT_SECONDS) * 1000;
|
|
29
|
+
const commandArgs = args.slice(1);
|
|
30
|
+
return new Promise((resolve) => {
|
|
31
|
+
const child = spawn(process.execPath, [process.argv[1] ?? "", ...commandArgs], {
|
|
32
|
+
env: {
|
|
33
|
+
...process.env,
|
|
34
|
+
...extraEnv,
|
|
35
|
+
NO_UPDATE_NOTIFIER: "1",
|
|
36
|
+
npm_config_update_notifier: "false",
|
|
37
|
+
},
|
|
38
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
39
|
+
});
|
|
40
|
+
let stdout = "";
|
|
41
|
+
let stderr = "";
|
|
42
|
+
let timedOut = false;
|
|
43
|
+
const timer = setTimeout(() => {
|
|
44
|
+
timedOut = true;
|
|
45
|
+
child.kill("SIGTERM");
|
|
46
|
+
setTimeout(() => child.kill("SIGKILL"), 5000).unref();
|
|
47
|
+
}, timeoutMs);
|
|
48
|
+
child.stdout.setEncoding("utf8");
|
|
49
|
+
child.stderr.setEncoding("utf8");
|
|
50
|
+
child.stdout.on("data", (chunk) => { stdout += chunk; });
|
|
51
|
+
child.stderr.on("data", (chunk) => { stderr += chunk; });
|
|
52
|
+
child.on("close", (code) => {
|
|
53
|
+
clearTimeout(timer);
|
|
54
|
+
resolve({ code: code ?? 1, stdout, stderr, timedOut });
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
async function appendLog(message) {
|
|
59
|
+
await mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
60
|
+
const existing = await readFile(LOG_PATH, "utf8").catch((err) => {
|
|
61
|
+
if (err.code === "ENOENT")
|
|
62
|
+
return "";
|
|
63
|
+
throw err;
|
|
64
|
+
});
|
|
65
|
+
await writeFile(LOG_PATH, `${existing}${message}`, { mode: 0o600 });
|
|
66
|
+
}
|
|
67
|
+
async function writeStatus(status) {
|
|
68
|
+
await mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
69
|
+
await writeFile(STATUS_PATH, `${JSON.stringify(status, null, 2)}\n`, { mode: 0o600 });
|
|
70
|
+
}
|
|
71
|
+
async function readStatus() {
|
|
72
|
+
try {
|
|
73
|
+
return JSON.parse(await readFile(STATUS_PATH, "utf8"));
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
if (err.code === "ENOENT")
|
|
77
|
+
return null;
|
|
78
|
+
throw err;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function parseSyncResult(output) {
|
|
82
|
+
const match = output.match(/synced\s+(\d+)\s+skills\s+\((\d+)\s+unchanged,\s+(\d+)\s+updated(?:,\s+(\d+)\s+conflicts?\s+skipped)?/i);
|
|
83
|
+
if (!match)
|
|
84
|
+
return {};
|
|
85
|
+
return {
|
|
86
|
+
synced: Number(match[1]),
|
|
87
|
+
updated: Number(match[3]),
|
|
88
|
+
conflicts: Number(match[4] ?? "0"),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
async function runTarget(target, opts) {
|
|
92
|
+
const started = Date.now();
|
|
93
|
+
const cacheDir = join(CONFIG_DIR, "skill-cache", target);
|
|
94
|
+
const syncResult = await runCommand([String(opts.timeoutSeconds), "sync", "--target", target], {
|
|
95
|
+
[targetSkillsDirEnv(target)]: cacheDir,
|
|
96
|
+
FLOOM_SYNC_MANIFEST_PATH: join(cacheDir, ".floom-cli-sync-manifest.json"),
|
|
97
|
+
});
|
|
98
|
+
const combined = `${syncResult.stdout}\n${syncResult.stderr}`;
|
|
99
|
+
let ok = syncResult.code === 0 && !syncResult.timedOut;
|
|
100
|
+
let error = syncResult.timedOut ? "sync timed out" : syncResult.code === 0 ? undefined : combined.trim() || "sync failed";
|
|
101
|
+
if (ok) {
|
|
102
|
+
const setupResult = await runCommand([String(opts.timeoutSeconds), "setup", "--target", target, "--yes", "--global"]);
|
|
103
|
+
if (setupResult.code !== 0 || setupResult.timedOut) {
|
|
104
|
+
ok = false;
|
|
105
|
+
error = setupResult.timedOut ? "instruction setup timed out" : `${setupResult.stdout}\n${setupResult.stderr}`.trim() || "instruction setup failed";
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (opts.push && ok) {
|
|
109
|
+
const pushArgs = ["watch", "--push", "--once", "--target", target];
|
|
110
|
+
if (!opts.yolo)
|
|
111
|
+
pushArgs.push("--no-yolo");
|
|
112
|
+
const pushResult = await runCommand([String(opts.timeoutSeconds), ...pushArgs], { FLOOM_SYNC_MANIFEST_PATH: join(CONFIG_DIR, "native-sync-manifests", `${target}.json`) });
|
|
113
|
+
if (pushResult.code !== 0 || pushResult.timedOut) {
|
|
114
|
+
ok = false;
|
|
115
|
+
error = pushResult.timedOut ? "push timed out" : `${pushResult.stdout}\n${pushResult.stderr}`.trim() || "push failed";
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
await appendLog([
|
|
119
|
+
`${new Date().toISOString()} target=${target} ok=${ok}`,
|
|
120
|
+
combined.trim(),
|
|
121
|
+
error && !combined.includes(error) ? error : "",
|
|
122
|
+
"",
|
|
123
|
+
].filter(Boolean).join("\n"));
|
|
124
|
+
return {
|
|
125
|
+
ok,
|
|
126
|
+
...parseSyncResult(combined),
|
|
127
|
+
...(error ? { error } : {}),
|
|
128
|
+
duration_ms: Date.now() - started,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
async function sleep(ms) {
|
|
132
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
133
|
+
}
|
|
134
|
+
export async function runDaemonForeground(opts) {
|
|
135
|
+
const targets = targetsFor(opts.target);
|
|
136
|
+
process.stdout.write(`${symbols.bullet} Floom daemon running for ${targets.join(", ")} every ${opts.intervalSeconds}s.\n`);
|
|
137
|
+
for (;;) {
|
|
138
|
+
const now = new Date();
|
|
139
|
+
const status = {
|
|
140
|
+
running: true,
|
|
141
|
+
manager: "foreground",
|
|
142
|
+
service: "foreground",
|
|
143
|
+
pid: process.pid,
|
|
144
|
+
version: CLI_VERSION,
|
|
145
|
+
hostname: hostname(),
|
|
146
|
+
interval_seconds: opts.intervalSeconds,
|
|
147
|
+
timeout_seconds: opts.timeoutSeconds,
|
|
148
|
+
targets,
|
|
149
|
+
push: opts.push,
|
|
150
|
+
yolo: opts.yolo,
|
|
151
|
+
last_started_at: now.toISOString(),
|
|
152
|
+
last_run: {},
|
|
153
|
+
};
|
|
154
|
+
await writeStatus(status);
|
|
155
|
+
for (const target of targets) {
|
|
156
|
+
status.last_run[target] = await runTarget(target, opts);
|
|
157
|
+
await writeStatus(status);
|
|
158
|
+
}
|
|
159
|
+
const completed = new Date();
|
|
160
|
+
status.last_completed_at = completed.toISOString();
|
|
161
|
+
status.next_run_at = new Date(completed.getTime() + opts.intervalSeconds * 1000).toISOString();
|
|
162
|
+
await writeStatus(status);
|
|
163
|
+
await appendLog(`${completed.toISOString()} daemon cycle complete\n`);
|
|
164
|
+
if (!opts.foreground)
|
|
165
|
+
return;
|
|
166
|
+
await sleep(opts.intervalSeconds * 1000);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
function serviceCommand(opts) {
|
|
170
|
+
const args = [
|
|
171
|
+
"daemon",
|
|
172
|
+
"run",
|
|
173
|
+
"--foreground",
|
|
174
|
+
"--target",
|
|
175
|
+
opts.target,
|
|
176
|
+
"--interval",
|
|
177
|
+
String(opts.intervalSeconds),
|
|
178
|
+
"--timeout",
|
|
179
|
+
String(opts.timeoutSeconds),
|
|
180
|
+
opts.push ? "--push" : "--no-push",
|
|
181
|
+
opts.yolo ? "--yolo" : "--no-yolo",
|
|
182
|
+
];
|
|
183
|
+
return `exec ${cliInvocation()} ${args.map(shellQuote).join(" ")}`;
|
|
184
|
+
}
|
|
185
|
+
function systemdUnit(opts, user) {
|
|
186
|
+
return `[Unit]
|
|
187
|
+
Description=Floom always-on skill sync
|
|
188
|
+
After=network-online.target
|
|
189
|
+
Wants=network-online.target
|
|
190
|
+
|
|
191
|
+
[Service]
|
|
192
|
+
Type=simple
|
|
193
|
+
ExecStart=/bin/sh -lc ${shellQuote(serviceCommand(opts))}
|
|
194
|
+
Restart=always
|
|
195
|
+
RestartSec=15
|
|
196
|
+
Environment=NO_UPDATE_NOTIFIER=1
|
|
197
|
+
Environment=npm_config_update_notifier=false
|
|
198
|
+
|
|
199
|
+
[Install]
|
|
200
|
+
WantedBy=${user ? "default.target" : "multi-user.target"}
|
|
201
|
+
`;
|
|
202
|
+
}
|
|
203
|
+
function launchdPlist(opts) {
|
|
204
|
+
const command = serviceCommand(opts);
|
|
205
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
206
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
207
|
+
<plist version="1.0">
|
|
208
|
+
<dict>
|
|
209
|
+
<key>Label</key>
|
|
210
|
+
<string>${LAUNCHD_LABEL}</string>
|
|
211
|
+
<key>ProgramArguments</key>
|
|
212
|
+
<array>
|
|
213
|
+
<string>/bin/sh</string>
|
|
214
|
+
<string>-lc</string>
|
|
215
|
+
<string>${escapePlist(command)}</string>
|
|
216
|
+
</array>
|
|
217
|
+
<key>RunAtLoad</key>
|
|
218
|
+
<true/>
|
|
219
|
+
<key>KeepAlive</key>
|
|
220
|
+
<true/>
|
|
221
|
+
<key>StandardOutPath</key>
|
|
222
|
+
<string>${LOG_PATH}</string>
|
|
223
|
+
<key>StandardErrorPath</key>
|
|
224
|
+
<string>${LOG_PATH}</string>
|
|
225
|
+
</dict>
|
|
226
|
+
</plist>
|
|
227
|
+
`;
|
|
228
|
+
}
|
|
229
|
+
function escapePlist(value) {
|
|
230
|
+
return value
|
|
231
|
+
.replace(/&/g, "&")
|
|
232
|
+
.replace(/</g, "<")
|
|
233
|
+
.replace(/>/g, ">");
|
|
234
|
+
}
|
|
235
|
+
function manager() {
|
|
236
|
+
return platform() === "darwin" ? "launchd" : "systemd";
|
|
237
|
+
}
|
|
238
|
+
function systemdPath() {
|
|
239
|
+
const isRoot = typeof process.getuid === "function" && process.getuid() === 0;
|
|
240
|
+
if (isRoot)
|
|
241
|
+
return { path: `/etc/systemd/system/${SERVICE_NAME}`, user: false };
|
|
242
|
+
return { path: join(homedir(), ".config", "systemd", "user", SERVICE_NAME), user: true };
|
|
243
|
+
}
|
|
244
|
+
async function installDaemon(opts) {
|
|
245
|
+
if (manager() === "launchd") {
|
|
246
|
+
const plistPath = join(homedir(), "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
|
|
247
|
+
const body = launchdPlist(opts);
|
|
248
|
+
if (opts.dryRun) {
|
|
249
|
+
process.stdout.write(body);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
await mkdir(dirname(plistPath), { recursive: true });
|
|
253
|
+
await writeFile(plistPath, body, "utf8");
|
|
254
|
+
process.stdout.write(`${symbols.ok} Installed ${plistPath}\n`);
|
|
255
|
+
await runManagerCommand("launchctl", ["bootstrap", `gui/${process.getuid?.() ?? ""}`, plistPath], true);
|
|
256
|
+
await runManagerCommand("launchctl", ["kickstart", "-k", `gui/${process.getuid?.() ?? ""}/${LAUNCHD_LABEL}`], true);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
const target = systemdPath();
|
|
260
|
+
const body = systemdUnit(opts, target.user);
|
|
261
|
+
if (opts.dryRun) {
|
|
262
|
+
process.stdout.write(body);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
await mkdir(dirname(target.path), { recursive: true });
|
|
266
|
+
await writeFile(target.path, body, "utf8");
|
|
267
|
+
process.stdout.write(`${symbols.ok} Installed ${target.path}\n`);
|
|
268
|
+
await runSystemctl(["daemon-reload"], target.user, true);
|
|
269
|
+
await runSystemctl(["enable", "--now", SERVICE_NAME], target.user, true);
|
|
270
|
+
}
|
|
271
|
+
async function showStatus(opts) {
|
|
272
|
+
const status = await readStatus();
|
|
273
|
+
if (opts.json) {
|
|
274
|
+
process.stdout.write(`${JSON.stringify(status ?? { running: false }, null, 2)}\n`);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
if (!status) {
|
|
278
|
+
process.stdout.write(`${symbols.bullet} Floom daemon has no status yet.\n`);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
process.stdout.write(`${symbols.ok} Floom daemon ${status.running ? "status" : "last status"}\n`);
|
|
282
|
+
process.stdout.write(` ${c.dim("manager:")} ${status.manager}\n`);
|
|
283
|
+
process.stdout.write(` ${c.dim("targets:")} ${status.targets.join(", ")}\n`);
|
|
284
|
+
if (status.last_completed_at)
|
|
285
|
+
process.stdout.write(` ${c.dim("last completed:")} ${status.last_completed_at}\n`);
|
|
286
|
+
for (const [target, run] of Object.entries(status.last_run)) {
|
|
287
|
+
process.stdout.write(` ${run.ok ? symbols.ok : symbols.fail} ${target}: ${run.ok ? "ok" : run.error ?? "failed"}\n`);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
async function showLogs(opts) {
|
|
291
|
+
const body = await readFile(LOG_PATH, "utf8").catch((err) => {
|
|
292
|
+
if (err.code === "ENOENT")
|
|
293
|
+
return "";
|
|
294
|
+
throw err;
|
|
295
|
+
});
|
|
296
|
+
const lines = body.split(/\r?\n/);
|
|
297
|
+
process.stdout.write(`${lines.slice(Math.max(0, lines.length - opts.tail)).join("\n")}\n`);
|
|
298
|
+
}
|
|
299
|
+
function execManager(command, args) {
|
|
300
|
+
return new Promise((resolve) => {
|
|
301
|
+
const child = spawn(command, args, { stdio: ["ignore", "ignore", "pipe"] });
|
|
302
|
+
let stderr = "";
|
|
303
|
+
child.stderr.setEncoding("utf8");
|
|
304
|
+
child.stderr.on("data", (chunk) => { stderr += chunk; });
|
|
305
|
+
child.on("error", (err) => resolve({ code: 127, stderr: err.message }));
|
|
306
|
+
child.on("close", (code) => resolve({ code: code ?? 1, stderr }));
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
async function runManagerCommand(command, args, soft) {
|
|
310
|
+
const result = await execManager(command, args);
|
|
311
|
+
if (result.code === 0)
|
|
312
|
+
return;
|
|
313
|
+
const rendered = `${command} ${args.join(" ")}`;
|
|
314
|
+
if (soft) {
|
|
315
|
+
process.stdout.write(` ${c.dim(`Run manually if needed: ${rendered}`)}\n`);
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
throw new FloomError(`Failed to run ${rendered}.`, result.stderr.trim() || "Service manager command failed.");
|
|
319
|
+
}
|
|
320
|
+
async function runSystemctl(args, user, soft) {
|
|
321
|
+
await runManagerCommand("systemctl", user ? ["--user", ...args] : args, soft);
|
|
322
|
+
}
|
|
323
|
+
async function restartDaemon() {
|
|
324
|
+
if (manager() === "launchd") {
|
|
325
|
+
await runManagerCommand("launchctl", ["kickstart", "-k", `gui/${process.getuid?.() ?? ""}/${LAUNCHD_LABEL}`], false);
|
|
326
|
+
process.stdout.write(`${symbols.ok} Restarted ${LAUNCHD_LABEL}\n`);
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
const target = systemdPath();
|
|
330
|
+
await runSystemctl(["restart", SERVICE_NAME], target.user, false);
|
|
331
|
+
process.stdout.write(`${symbols.ok} Restarted ${SERVICE_NAME}\n`);
|
|
332
|
+
}
|
|
333
|
+
async function uninstallDaemon() {
|
|
334
|
+
if (manager() === "launchd") {
|
|
335
|
+
const plistPath = join(homedir(), "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
|
|
336
|
+
await runManagerCommand("launchctl", ["bootout", `gui/${process.getuid?.() ?? ""}/${LAUNCHD_LABEL}`], true);
|
|
337
|
+
await unlink(plistPath).catch((err) => {
|
|
338
|
+
if (err.code !== "ENOENT")
|
|
339
|
+
throw err;
|
|
340
|
+
});
|
|
341
|
+
process.stdout.write(`${symbols.ok} Uninstalled ${LAUNCHD_LABEL}\n`);
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
const target = systemdPath();
|
|
345
|
+
await runSystemctl(["disable", "--now", SERVICE_NAME], target.user, true);
|
|
346
|
+
await unlink(target.path).catch((err) => {
|
|
347
|
+
if (err.code !== "ENOENT")
|
|
348
|
+
throw err;
|
|
349
|
+
});
|
|
350
|
+
await runSystemctl(["daemon-reload"], target.user, true);
|
|
351
|
+
process.stdout.write(`${symbols.ok} Uninstalled ${SERVICE_NAME}\n`);
|
|
352
|
+
}
|
|
353
|
+
export async function daemon(opts) {
|
|
354
|
+
switch (opts.command) {
|
|
355
|
+
case "install":
|
|
356
|
+
await installDaemon(opts);
|
|
357
|
+
return;
|
|
358
|
+
case "status":
|
|
359
|
+
await showStatus(opts);
|
|
360
|
+
return;
|
|
361
|
+
case "logs":
|
|
362
|
+
await showLogs(opts);
|
|
363
|
+
return;
|
|
364
|
+
case "run":
|
|
365
|
+
await runDaemonForeground(opts);
|
|
366
|
+
return;
|
|
367
|
+
case "restart":
|
|
368
|
+
await restartDaemon();
|
|
369
|
+
return;
|
|
370
|
+
case "uninstall":
|
|
371
|
+
await uninstallDaemon();
|
|
372
|
+
return;
|
|
373
|
+
default:
|
|
374
|
+
throw new FloomError("Missing daemon command.", "Use install, status, logs, restart, uninstall, or run.");
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
export function normalizeDaemonOptions(opts) {
|
|
378
|
+
if (opts.intervalSeconds < MIN_INTERVAL_SECONDS) {
|
|
379
|
+
throw new FloomError("Invalid --interval.", `Use an integer number of seconds, minimum ${MIN_INTERVAL_SECONDS}.`);
|
|
380
|
+
}
|
|
381
|
+
if (opts.timeoutSeconds < MIN_TIMEOUT_SECONDS) {
|
|
382
|
+
throw new FloomError("Invalid --timeout.", `Use an integer number of seconds, minimum ${MIN_TIMEOUT_SECONDS}.`);
|
|
383
|
+
}
|
|
384
|
+
return opts;
|
|
385
|
+
}
|
|
386
|
+
export function parseDaemonTarget(value) {
|
|
387
|
+
if (value === "all")
|
|
388
|
+
return "all";
|
|
389
|
+
if (isAgentTarget(value))
|
|
390
|
+
return value;
|
|
391
|
+
throw new FloomError("Invalid --target.", "Use claude|codex|cursor|opencode|kimi|all.");
|
|
392
|
+
}
|
package/dist/feedback.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import ora from "ora";
|
|
2
|
+
import { readConfig, resolveApiUrl } from "./config.js";
|
|
3
|
+
import { floomFetch } from "./lib/api.js";
|
|
4
|
+
import { c, symbols } from "./ui.js";
|
|
5
|
+
export async function feedback(opts) {
|
|
6
|
+
const cfg = await readConfig();
|
|
7
|
+
const apiUrl = resolveApiUrl(cfg);
|
|
8
|
+
const payload = {
|
|
9
|
+
event_name: "feedback.submitted",
|
|
10
|
+
source: "cli",
|
|
11
|
+
props: {
|
|
12
|
+
kind: opts.kind,
|
|
13
|
+
message: opts.message,
|
|
14
|
+
...(opts.target ? { target: opts.target } : {}),
|
|
15
|
+
...(opts.skill ? { skill: opts.skill } : {}),
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
const spinner = opts.json ? null : ora({ text: c.dim("Sending feedback..."), color: "yellow" }).start();
|
|
19
|
+
try {
|
|
20
|
+
await floomFetch(`${apiUrl}/api/v1/events`, "send feedback", {
|
|
21
|
+
method: "POST",
|
|
22
|
+
...(cfg?.accessToken ? { token: cfg.accessToken } : {}),
|
|
23
|
+
body: payload,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
finally {
|
|
27
|
+
spinner?.stop();
|
|
28
|
+
}
|
|
29
|
+
if (opts.json) {
|
|
30
|
+
process.stdout.write(`${JSON.stringify({ ok: true }, null, 2)}\n`);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
process.stdout.write(`\n${symbols.ok} Feedback sent\n\n`);
|
|
34
|
+
}
|
package/dist/list.js
CHANGED
|
@@ -47,6 +47,7 @@ export async function list(opts) {
|
|
|
47
47
|
for (let page = 0; page < 1000; page += 1) {
|
|
48
48
|
const url = new URL(`${apiUrl}/api/v1/me/skills`);
|
|
49
49
|
url.searchParams.set("limit", "100");
|
|
50
|
+
url.searchParams.set("scope", "owned");
|
|
50
51
|
if (cursor)
|
|
51
52
|
url.searchParams.set("cursor", cursor);
|
|
52
53
|
const mine = await getJson(url.toString(), "load your skills", cfg.accessToken);
|
package/dist/publish.js
CHANGED
|
@@ -20,6 +20,7 @@ const INSTALL_TARGETS = new Set([
|
|
|
20
20
|
]);
|
|
21
21
|
const VERSION_RE = /^[A-Za-z0-9][A-Za-z0-9._+-]{0,63}$/;
|
|
22
22
|
const SLUG_RE = /^[A-Za-z0-9_-]{1,128}$/;
|
|
23
|
+
const YAML_MAX_ALIAS_COUNT = 50;
|
|
23
24
|
function parseFrontmatter(input) {
|
|
24
25
|
const trimmed = input.replace(/^/, "");
|
|
25
26
|
if (!trimmed.startsWith("---"))
|
|
@@ -37,7 +38,7 @@ function parseFrontmatter(input) {
|
|
|
37
38
|
const meta = {};
|
|
38
39
|
let parsed;
|
|
39
40
|
try {
|
|
40
|
-
parsed = headerBlock ? parseYaml(headerBlock) : {};
|
|
41
|
+
parsed = headerBlock ? parseYaml(headerBlock, { maxAliasCount: YAML_MAX_ALIAS_COUNT }) : {};
|
|
41
42
|
}
|
|
42
43
|
catch (err) {
|
|
43
44
|
return {
|
package/dist/setup.js
CHANGED
|
@@ -6,15 +6,36 @@ 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
8
|
import { targetLabel } from "./targets.js";
|
|
9
|
+
import { readConfig, resolveApiUrl } from "./config.js";
|
|
10
|
+
import { getJson } from "./lib/api.js";
|
|
9
11
|
const START_MARKER = "<!-- FLOOM SETUP START -->";
|
|
10
12
|
const END_MARKER = "<!-- FLOOM SETUP END -->";
|
|
13
|
+
const CLOUD_START_MARKER = "<!-- FLOOM CLOUD INSTRUCTIONS START -->";
|
|
14
|
+
const CLOUD_END_MARKER = "<!-- FLOOM CLOUD INSTRUCTIONS END -->";
|
|
11
15
|
const CLI_COMMAND = "npx -y @floomhq/floom";
|
|
12
16
|
const INSTRUCTION_FILES = {
|
|
13
17
|
claude: { filename: "CLAUDE.md" },
|
|
14
18
|
codex: { filename: "AGENTS.md" },
|
|
15
19
|
};
|
|
16
|
-
function
|
|
20
|
+
function cloudInstructionBody(target, profile) {
|
|
21
|
+
if (!profile)
|
|
22
|
+
return "";
|
|
23
|
+
const base = profile.body_md.trim();
|
|
24
|
+
const override = profile.target_overrides?.[target]?.trim();
|
|
25
|
+
const body = [base, override].filter(Boolean).join("\n\n");
|
|
26
|
+
if (!body)
|
|
27
|
+
return "";
|
|
28
|
+
return [
|
|
29
|
+
CLOUD_START_MARKER,
|
|
30
|
+
`<!-- Floom profile version ${profile.version}; updated ${profile.updated_at} -->`,
|
|
31
|
+
"",
|
|
32
|
+
body,
|
|
33
|
+
CLOUD_END_MARKER,
|
|
34
|
+
].join("\n");
|
|
35
|
+
}
|
|
36
|
+
function floomAgentInstructions(target, profile) {
|
|
17
37
|
const addCommand = `${CLI_COMMAND} add <slug-or-url> --target ${target}`;
|
|
38
|
+
const cloudBody = cloudInstructionBody(target, profile);
|
|
18
39
|
return `${START_MARKER}
|
|
19
40
|
## Floom
|
|
20
41
|
|
|
@@ -23,6 +44,7 @@ function floomAgentInstructions(target) {
|
|
|
23
44
|
- Add shared skills with \`${addCommand}\`; public and unlisted links do not require a Floom account.
|
|
24
45
|
- Use the installed \`floom-find-skills\` router skill, Floom search, or MCP lookup before loading local files. Do not enumerate every cached skill into model context.
|
|
25
46
|
- \`${CLI_COMMAND} sync --target ${target}\`, \`${CLI_COMMAND} watch --push --target ${target}\`, and \`@floomhq/floom-mcp-sync\` keep saved, published, and subscribed library skills current in the Floom cache; review conflicts before relying on synced output.
|
|
47
|
+
${cloudBody ? `\n${cloudBody}` : ""}
|
|
26
48
|
${END_MARKER}`;
|
|
27
49
|
}
|
|
28
50
|
async function fileExists(path) {
|
|
@@ -86,6 +108,20 @@ async function detectTarget(opts) {
|
|
|
86
108
|
};
|
|
87
109
|
}
|
|
88
110
|
if (agent) {
|
|
111
|
+
if (opts.global && agent === "claude") {
|
|
112
|
+
return {
|
|
113
|
+
agent,
|
|
114
|
+
label: targetLabel(agent),
|
|
115
|
+
path: join(homedir(), ".claude", "CLAUDE.md"),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
if (opts.global && agent === "codex") {
|
|
119
|
+
return {
|
|
120
|
+
agent,
|
|
121
|
+
label: targetLabel(agent),
|
|
122
|
+
path: join(process.env.CODEX_HOME ?? join(homedir(), ".codex"), "AGENTS.md"),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
89
125
|
if (agent === "cursor") {
|
|
90
126
|
return {
|
|
91
127
|
agent,
|
|
@@ -125,7 +161,7 @@ async function detectTarget(opts) {
|
|
|
125
161
|
return { agent: "codex", label: targetLabel("codex"), path: codex };
|
|
126
162
|
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\`.`);
|
|
127
163
|
}
|
|
128
|
-
function renderPreview(target, existing) {
|
|
164
|
+
function renderPreview(target, existing, profile) {
|
|
129
165
|
const action = existing === null ? "create" : "append";
|
|
130
166
|
return [
|
|
131
167
|
"",
|
|
@@ -133,14 +169,14 @@ function renderPreview(target, existing) {
|
|
|
133
169
|
` ${c.dim("Target:")} ${target.path}`,
|
|
134
170
|
` ${c.dim("Action:")} ${action}`,
|
|
135
171
|
"",
|
|
136
|
-
floomAgentInstructions(target.agent),
|
|
172
|
+
floomAgentInstructions(target.agent, profile),
|
|
137
173
|
"",
|
|
138
174
|
`${c.dim("MCP setup guidance:")} run ${c.cyan(`${CLI_COMMAND} mcp`)} to print local agent commands.`,
|
|
139
175
|
"",
|
|
140
176
|
].join("\n");
|
|
141
177
|
}
|
|
142
|
-
function renderInstructions(target, existing) {
|
|
143
|
-
const body = floomAgentInstructions(target);
|
|
178
|
+
function renderInstructions(target, existing, profile) {
|
|
179
|
+
const body = floomAgentInstructions(target, profile);
|
|
144
180
|
if (target === "cursor" && existing === null) {
|
|
145
181
|
return [
|
|
146
182
|
"---",
|
|
@@ -154,6 +190,29 @@ function renderInstructions(target, existing) {
|
|
|
154
190
|
}
|
|
155
191
|
return body;
|
|
156
192
|
}
|
|
193
|
+
function replaceExistingBlock(existing, instructions) {
|
|
194
|
+
const start = existing.indexOf(START_MARKER);
|
|
195
|
+
const end = existing.indexOf(END_MARKER, start);
|
|
196
|
+
if (start < 0 || end < 0)
|
|
197
|
+
return null;
|
|
198
|
+
const afterEnd = end + END_MARKER.length;
|
|
199
|
+
const prefix = existing.slice(0, start).replace(/\s*$/, "");
|
|
200
|
+
const suffix = existing.slice(afterEnd).replace(/^\s*/, "").replace(/\s*$/, "");
|
|
201
|
+
return [...(prefix ? [prefix] : []), instructions, ...(suffix ? [suffix] : [])].join("\n\n").replace(/\s*$/, "\n");
|
|
202
|
+
}
|
|
203
|
+
async function loadCloudProfile() {
|
|
204
|
+
const cfg = await readConfig();
|
|
205
|
+
if (!cfg)
|
|
206
|
+
return null;
|
|
207
|
+
try {
|
|
208
|
+
const apiUrl = resolveApiUrl(cfg);
|
|
209
|
+
const payload = await getJson(`${apiUrl}/api/v1/me/instructions`, "load cloud instruction profile", cfg.accessToken);
|
|
210
|
+
return payload.profile;
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
157
216
|
async function ensureOpencodeInstructionReference(instructionPath) {
|
|
158
217
|
const configPath = join(homedir(), ".config", "opencode", "opencode.json");
|
|
159
218
|
const reference = `{file:${instructionPath.replace(homedir(), "~")}}`;
|
|
@@ -191,16 +250,28 @@ async function confirmWrite(target, existing) {
|
|
|
191
250
|
export async function setupAgent(opts) {
|
|
192
251
|
const target = await detectTarget(opts);
|
|
193
252
|
const existing = await readIfExists(target.path);
|
|
253
|
+
const profile = opts.global ? await loadCloudProfile() : null;
|
|
254
|
+
const instructions = renderInstructions(target.agent, existing, profile);
|
|
194
255
|
if (existing?.includes(START_MARKER) && existing.includes(END_MARKER)) {
|
|
195
|
-
|
|
256
|
+
const replaced = replaceExistingBlock(existing, instructions);
|
|
257
|
+
if (replaced === null || replaced === existing) {
|
|
258
|
+
process.stdout.write(`\n${symbols.ok} Floom instructions already present in ${c.bold(target.path)}\n\n`);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
if (opts.dryRun) {
|
|
262
|
+
process.stdout.write(renderPreview(target, existing, profile));
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
await writeFile(target.path, replaced, "utf8");
|
|
266
|
+
process.stdout.write(`\n${symbols.ok} Updated Floom instructions in ${c.bold(target.path)}\n\n`);
|
|
196
267
|
return;
|
|
197
268
|
}
|
|
198
269
|
if (opts.dryRun) {
|
|
199
|
-
process.stdout.write(renderPreview(target, existing));
|
|
270
|
+
process.stdout.write(renderPreview(target, existing, profile));
|
|
200
271
|
return;
|
|
201
272
|
}
|
|
202
273
|
if (!opts.yes) {
|
|
203
|
-
process.stdout.write(renderPreview(target, existing));
|
|
274
|
+
process.stdout.write(renderPreview(target, existing, profile));
|
|
204
275
|
const ok = await confirmWrite(target, existing);
|
|
205
276
|
if (!ok) {
|
|
206
277
|
process.stdout.write(`\n${c.dim("Cancelled. Nothing was written.")}\n\n`);
|
|
@@ -208,7 +279,6 @@ export async function setupAgent(opts) {
|
|
|
208
279
|
}
|
|
209
280
|
}
|
|
210
281
|
await mkdir(dirname(target.path), { recursive: true });
|
|
211
|
-
const instructions = renderInstructions(target.agent, existing);
|
|
212
282
|
const next = existing === null
|
|
213
283
|
? `${instructions}\n`
|
|
214
284
|
: `${existing.replace(/\s*$/, "")}\n\n${instructions}\n`;
|
package/dist/sync.js
CHANGED
|
@@ -449,7 +449,7 @@ async function loadSyncPayload(apiUrl, token) {
|
|
|
449
449
|
const seenCursors = new Set();
|
|
450
450
|
for (let page = 0; page < 1000; page += 1) {
|
|
451
451
|
const url = new URL(`${apiUrl}/api/v1/me/skills`);
|
|
452
|
-
url.searchParams.set("limit", "
|
|
452
|
+
url.searchParams.set("limit", "100");
|
|
453
453
|
url.searchParams.set("packages", "1");
|
|
454
454
|
if (cursor)
|
|
455
455
|
url.searchParams.set("cursor", cursor);
|