@floomhq/floom 1.0.45 → 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 +237 -0
- 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
|
|
@@ -111,6 +115,10 @@ function commandUsage() {
|
|
|
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")}
|
|
@@ -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,6 +711,103 @@ 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, "/");
|
|
593
813
|
if (normalized === "root" || normalized === "/" || normalized === ".")
|
|
@@ -944,6 +1164,17 @@ async function main() {
|
|
|
944
1164
|
});
|
|
945
1165
|
return;
|
|
946
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
|
+
}
|
|
947
1178
|
case "add":
|
|
948
1179
|
case "install": {
|
|
949
1180
|
const flags = parseAddArgs(rest);
|
|
@@ -979,6 +1210,9 @@ async function main() {
|
|
|
979
1210
|
await watch(flags.intervalSeconds, flags.target, flags.once);
|
|
980
1211
|
return;
|
|
981
1212
|
}
|
|
1213
|
+
case "daemon":
|
|
1214
|
+
await daemon(parseDaemonFlags(rest));
|
|
1215
|
+
return;
|
|
982
1216
|
case "delete":
|
|
983
1217
|
case "rm": {
|
|
984
1218
|
const flags = parseDeleteFlags(rest);
|
|
@@ -989,6 +1223,9 @@ async function main() {
|
|
|
989
1223
|
case "lib":
|
|
990
1224
|
await runLibrary(rest);
|
|
991
1225
|
return;
|
|
1226
|
+
case "audit":
|
|
1227
|
+
await auditSkills(parseAuditFlags(rest));
|
|
1228
|
+
return;
|
|
992
1229
|
case "move": {
|
|
993
1230
|
const flags = parseFolderTagFlags(rest);
|
|
994
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);
|