@floomhq/floom 1.0.45 → 1.0.47

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 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";
@@ -25,6 +28,13 @@ import { TARGET_HINT, isAgentTarget } from "./targets.js";
25
28
  const PKG = { name: "@floomhq/floom", version: CLI_VERSION };
26
29
  const V1_NOT_AVAILABLE = "Not available in Floom Version 1.";
27
30
  const CLI_COMMAND = "npx -y @floomhq/floom";
31
+ function exitOnBrokenPipe(err) {
32
+ if (err.code === "EPIPE")
33
+ process.exit(0);
34
+ throw err;
35
+ }
36
+ process.stdout.on("error", exitOnBrokenPipe);
37
+ process.stderr.on("error", exitOnBrokenPipe);
28
38
  function usage() {
29
39
  const out = `
30
40
  ${c.blue(" ________ ")}
@@ -94,6 +104,7 @@ function commandUsage() {
94
104
  ${c.dim("Alias: rm")}
95
105
  ${c.cyan("whoami")} Show the signed-in account
96
106
  ${c.cyan("logout")} Switch accounts or remove local credentials
107
+ ${c.cyan("feedback")} Send agent feedback to Floom
97
108
 
98
109
  ${c.dim("Agent setup")}
99
110
  ${c.cyan("setup")} Configure Claude Code or Codex instructions
@@ -111,6 +122,10 @@ function commandUsage() {
111
122
  ${c.dim(`Flags: --target ${TARGET_HINT} (default: claude)`)}
112
123
  ${c.cyan("watch")} Preview polling sync loop
113
124
  ${c.dim(`Flags: --push, --no-yolo, --target ${TARGET_HINT}`)}
125
+ ${c.cyan("daemon")} Install and inspect always-on Floom sync
126
+ ${c.dim(`Flags: install|status|logs|run, --target ${TARGET_HINT}|all`)}
127
+ ${c.cyan("audit skills")} Read-only skill quality and duplicate report
128
+ ${c.dim("Flags: --json, --fix-plan")}
114
129
 
115
130
  ${c.bold("Examples")}
116
131
  ${c.cyan("npx -y @floomhq/floom add")} ${c.dim("https://floom.dev/s/ffas93ud --setup")}
@@ -229,6 +244,62 @@ function doctorUsage() {
229
244
  ${c.cyan(`--target ${TARGET_HINT}`)} Agent target, default claude
230
245
  `);
231
246
  }
247
+ function daemonUsage() {
248
+ process.stdout.write(`
249
+ ${c.bold("Usage:")} ${c.cyan(`${CLI_COMMAND} daemon`)} ${c.dim("<command> [flags]")}
250
+
251
+ ${c.bold("Install and inspect always-on Floom sync.")}
252
+ ${c.cyan(`${CLI_COMMAND} daemon install --target all --interval 300 --timeout 180`)}
253
+ ${c.cyan(`${CLI_COMMAND} daemon status --json`)}
254
+ ${c.cyan(`${CLI_COMMAND} daemon logs --tail 100`)}
255
+ ${c.cyan(`${CLI_COMMAND} daemon run --foreground --target all`)}
256
+
257
+ ${c.bold("Commands")}
258
+ ${c.cyan("install")} Write systemd or launchd service definition
259
+ ${c.cyan("status")} Print latest daemon status
260
+ ${c.cyan("logs")} Print daemon log tail
261
+ ${c.cyan("run")} Run the daemon loop in the foreground
262
+
263
+ ${c.bold("Flags")}
264
+ ${c.cyan("--target <target|all>")} claude|codex|cursor|opencode|kimi|all
265
+ ${c.cyan("--interval <seconds>")} Poll interval, minimum 30, default 300
266
+ ${c.cyan("--timeout <seconds>")} Per-command timeout, minimum 30, default 180
267
+ ${c.cyan("--push / --no-push")} Push/adopt local changes, default push
268
+ ${c.cyan("--yolo / --no-yolo")} Auto-publish changed local skills, default yolo
269
+ ${c.cyan("--dry-run")} Print generated service file without writing
270
+ ${c.cyan("--json")} Machine-readable status output
271
+ `);
272
+ }
273
+ function auditUsage() {
274
+ process.stdout.write(`
275
+ ${c.bold("Usage:")} ${c.cyan(`${CLI_COMMAND} audit skills`)} ${c.dim("[flags]")}
276
+
277
+ ${c.bold("Find low-quality, duplicate, fixture, and malformed owned skills.")}
278
+ ${c.cyan(`${CLI_COMMAND} audit skills`)}
279
+ ${c.cyan(`${CLI_COMMAND} audit skills --json`)}
280
+ ${c.cyan(`${CLI_COMMAND} audit skills --fix-plan`)}
281
+
282
+ ${c.bold("Flags")}
283
+ ${c.cyan("--json")} Full machine-readable report
284
+ ${c.cyan("--fix-plan")} Include archive recommendations, without applying them
285
+ `);
286
+ }
287
+ function feedbackUsage() {
288
+ process.stdout.write(`
289
+ ${c.bold("Usage:")} ${c.cyan(`${CLI_COMMAND} feedback`)} ${c.dim("--kind <kind> --message <message> [flags]")}
290
+
291
+ ${c.bold("Send agent feedback to Floom.")}
292
+ ${c.cyan(`${CLI_COMMAND} feedback --kind bug --message "MCP sync missed a file"`)}
293
+ ${c.cyan(`${CLI_COMMAND} feedback --kind idea --message "Add team defaults" --target codex --skill support-tone`)}
294
+
295
+ ${c.bold("Flags")}
296
+ ${c.cyan("--kind <kind>")} Feedback type, for example bug, idea, friction, praise
297
+ ${c.cyan("--message <text>")} Required non-interactive feedback message
298
+ ${c.cyan("--target <target>")} Optional agent target
299
+ ${c.cyan("--skill <slug>")} Optional related skill slug
300
+ ${c.cyan("--json")} Machine-readable success or error
301
+ `);
302
+ }
232
303
  function isHelpArg(value) {
233
304
  return value === "--help" || value === "-h" || value === "help";
234
305
  }
@@ -247,9 +318,18 @@ function subcommandUsage(cmd) {
247
318
  case "doctor":
248
319
  doctorUsage();
249
320
  return true;
321
+ case "daemon":
322
+ daemonUsage();
323
+ return true;
250
324
  case "share":
251
325
  shareUsage();
252
326
  return true;
327
+ case "audit":
328
+ auditUsage();
329
+ return true;
330
+ case "feedback":
331
+ feedbackUsage();
332
+ return true;
253
333
  case "library":
254
334
  case "lib":
255
335
  libraryUsage();
@@ -458,6 +538,54 @@ function parseInfoFlags(argv) {
458
538
  }
459
539
  return out;
460
540
  }
541
+ function parseFeedbackFlags(argv) {
542
+ const out = { json: false };
543
+ for (let i = 0; i < argv.length; i++) {
544
+ const a = argv[i] ?? "";
545
+ if (a === "--json") {
546
+ out.json = true;
547
+ }
548
+ else if (a === "--kind" || a.startsWith("--kind=")) {
549
+ const { value, nextIndex } = readFlagValue(argv, i, "--kind");
550
+ out.kind = value.trim();
551
+ i = nextIndex;
552
+ }
553
+ else if (a === "--message" || a.startsWith("--message=")) {
554
+ const { value, nextIndex } = readFlagValue(argv, i, "--message");
555
+ out.message = value.trim();
556
+ i = nextIndex;
557
+ }
558
+ else if (a === "--target" || a.startsWith("--target=")) {
559
+ const { value, nextIndex } = readFlagValue(argv, i, "--target");
560
+ out.target = value.trim();
561
+ i = nextIndex;
562
+ }
563
+ else if (a === "--skill" || a.startsWith("--skill=")) {
564
+ const { value, nextIndex } = readFlagValue(argv, i, "--skill");
565
+ out.skill = value.trim();
566
+ i = nextIndex;
567
+ }
568
+ else if (a.startsWith("--")) {
569
+ throw new FloomError(`Unknown flag: ${a}`, `Try \`${CLI_COMMAND} feedback --kind bug --message "What happened"\`.`);
570
+ }
571
+ else {
572
+ throw new FloomError(`Unexpected argument: ${a}`, `Use \`${CLI_COMMAND} feedback --kind bug --message "What happened"\`.`);
573
+ }
574
+ }
575
+ if (!out.kind)
576
+ throw new FloomError("Missing --kind.", `Try \`${CLI_COMMAND} feedback --kind bug --message "What happened"\`.`);
577
+ if (!out.message)
578
+ throw new FloomError("Missing --message.", "Feedback is non-interactive; pass the message with --message.");
579
+ if (out.kind.length > 64)
580
+ throw new FloomError("Invalid --kind.", "Use 1-64 characters.");
581
+ if (out.message.length > 4000)
582
+ throw new FloomError("Invalid --message.", "Use 1-4000 characters.");
583
+ if (out.target !== undefined && out.target.length > 128)
584
+ throw new FloomError("Invalid --target.", "Use at most 128 characters.");
585
+ if (out.skill !== undefined && out.skill.length > 128)
586
+ throw new FloomError("Invalid --skill.", "Use at most 128 characters.");
587
+ return out;
588
+ }
461
589
  function parseAddArgs(argv) {
462
590
  let slug;
463
591
  let target;
@@ -550,6 +678,8 @@ function parseSetupFlags(argv) {
550
678
  out.dryRun = true;
551
679
  else if (a === "--yes" || a === "-y")
552
680
  out.yes = true;
681
+ else if (a === "--global")
682
+ out.global = true;
553
683
  else if (a === "--target" || a.startsWith("--target=")) {
554
684
  const { value, nextIndex } = readFlagValue(argv, i, "--target");
555
685
  out.target = parseTargetFlag(value);
@@ -588,6 +718,103 @@ function parseDoctorFlags(argv) {
588
718
  }
589
719
  return out;
590
720
  }
721
+ function parseAuditFlags(argv) {
722
+ const [subcommand, ...rest] = argv;
723
+ if (subcommand !== "skills") {
724
+ throw new FloomError("Unknown audit command.", `Try \`${CLI_COMMAND} audit skills --json\`.`);
725
+ }
726
+ const out = { json: false, fixPlan: false };
727
+ for (const a of rest) {
728
+ if (a === "--json")
729
+ out.json = true;
730
+ else if (a === "--fix-plan")
731
+ out.fixPlan = true;
732
+ else if (a.startsWith("--"))
733
+ throw new FloomError(`Unknown flag: ${a}`, `Try \`${CLI_COMMAND} audit skills --json\`.`);
734
+ else
735
+ throw new FloomError(`Unexpected argument: ${a}`, `Try \`${CLI_COMMAND} audit skills --json\`.`);
736
+ }
737
+ return out;
738
+ }
739
+ function parseDaemonFlags(argv) {
740
+ const [command, ...rest] = argv;
741
+ const allowedCommands = new Set(["install", "uninstall", "status", "logs", "restart", "run"]);
742
+ if (!command || !allowedCommands.has(command)) {
743
+ throw new FloomError("Missing daemon command.", `Try \`${CLI_COMMAND} daemon install --target all\`.`);
744
+ }
745
+ const out = {
746
+ command: command,
747
+ target: "all",
748
+ intervalSeconds: 300,
749
+ timeoutSeconds: 180,
750
+ push: true,
751
+ yolo: true,
752
+ foreground: false,
753
+ dryRun: false,
754
+ json: false,
755
+ tail: 100,
756
+ };
757
+ for (let i = 0; i < rest.length; i++) {
758
+ const a = rest[i] ?? "";
759
+ if (a === "--target" || a.startsWith("--target=")) {
760
+ const { value, nextIndex } = readFlagValue(rest, i, "--target");
761
+ out.target = parseDaemonTarget(value);
762
+ i = nextIndex;
763
+ }
764
+ else if (a === "--interval" || a.startsWith("--interval=")) {
765
+ const { value, nextIndex } = readFlagValue(rest, i, "--interval");
766
+ const parsed = Number(value);
767
+ if (!Number.isInteger(parsed))
768
+ throw new FloomError("Invalid --interval.", "Use an integer number of seconds.");
769
+ out.intervalSeconds = parsed;
770
+ i = nextIndex;
771
+ }
772
+ else if (a === "--timeout" || a.startsWith("--timeout=")) {
773
+ const { value, nextIndex } = readFlagValue(rest, i, "--timeout");
774
+ const parsed = Number(value);
775
+ if (!Number.isInteger(parsed))
776
+ throw new FloomError("Invalid --timeout.", "Use an integer number of seconds.");
777
+ out.timeoutSeconds = parsed;
778
+ i = nextIndex;
779
+ }
780
+ else if (a === "--push") {
781
+ out.push = true;
782
+ }
783
+ else if (a === "--no-push") {
784
+ out.push = false;
785
+ }
786
+ else if (a === "--yolo") {
787
+ out.yolo = true;
788
+ }
789
+ else if (a === "--no-yolo") {
790
+ out.yolo = false;
791
+ }
792
+ else if (a === "--foreground") {
793
+ out.foreground = true;
794
+ }
795
+ else if (a === "--dry-run") {
796
+ out.dryRun = true;
797
+ }
798
+ else if (a === "--json") {
799
+ out.json = true;
800
+ }
801
+ else if (a === "--tail" || a.startsWith("--tail=")) {
802
+ const { value, nextIndex } = readFlagValue(rest, i, "--tail");
803
+ const parsed = Number(value);
804
+ if (!Number.isInteger(parsed) || parsed < 1)
805
+ throw new FloomError("Invalid --tail.", "Use a positive integer.");
806
+ out.tail = parsed;
807
+ i = nextIndex;
808
+ }
809
+ else if (a.startsWith("--")) {
810
+ throw new FloomError(`Unknown flag: ${a}`, `Try \`${CLI_COMMAND} daemon --help\`.`);
811
+ }
812
+ else {
813
+ throw new FloomError(`Unexpected argument: ${a}`, `Try \`${CLI_COMMAND} daemon --help\`.`);
814
+ }
815
+ }
816
+ return normalizeDaemonOptions(out);
817
+ }
591
818
  function normalizeFolder(value) {
592
819
  const normalized = value.trim().replace(/\\/g, "/").replace(/\/+/g, "/");
593
820
  if (normalized === "root" || normalized === "/" || normalized === ".")
@@ -944,6 +1171,17 @@ async function main() {
944
1171
  });
945
1172
  return;
946
1173
  }
1174
+ case "feedback": {
1175
+ const flags = parseFeedbackFlags(rest);
1176
+ await feedback({
1177
+ kind: flags.kind ?? "",
1178
+ message: flags.message ?? "",
1179
+ ...(flags.target ? { target: flags.target } : {}),
1180
+ ...(flags.skill ? { skill: flags.skill } : {}),
1181
+ json: flags.json,
1182
+ });
1183
+ return;
1184
+ }
947
1185
  case "add":
948
1186
  case "install": {
949
1187
  const flags = parseAddArgs(rest);
@@ -979,6 +1217,9 @@ async function main() {
979
1217
  await watch(flags.intervalSeconds, flags.target, flags.once);
980
1218
  return;
981
1219
  }
1220
+ case "daemon":
1221
+ await daemon(parseDaemonFlags(rest));
1222
+ return;
982
1223
  case "delete":
983
1224
  case "rm": {
984
1225
  const flags = parseDeleteFlags(rest);
@@ -989,6 +1230,9 @@ async function main() {
989
1230
  case "lib":
990
1231
  await runLibrary(rest);
991
1232
  return;
1233
+ case "audit":
1234
+ await auditSkills(parseAuditFlags(rest));
1235
+ return;
992
1236
  case "move": {
993
1237
  const flags = parseFolderTagFlags(rest);
994
1238
  const slug = flags.rest[0];
package/dist/daemon.js ADDED
@@ -0,0 +1,413 @@
1
+ import { spawn } from "node:child_process";
2
+ import { access, 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 nativeManifestPath = join(CONFIG_DIR, "native-sync-manifests", `${target}.json`);
95
+ const syncResult = await runCommand([String(opts.timeoutSeconds), "sync", "--target", target], {
96
+ [targetSkillsDirEnv(target)]: cacheDir,
97
+ FLOOM_SYNC_MANIFEST_PATH: join(cacheDir, ".floom-cli-sync-manifest.json"),
98
+ });
99
+ const combined = `${syncResult.stdout}\n${syncResult.stderr}`;
100
+ let ok = syncResult.code === 0 && !syncResult.timedOut;
101
+ let error = syncResult.timedOut ? "sync timed out" : syncResult.code === 0 ? undefined : combined.trim() || "sync failed";
102
+ if (ok) {
103
+ const setupResult = await runCommand([String(opts.timeoutSeconds), "setup", "--target", target, "--yes", "--global"]);
104
+ if (setupResult.code !== 0 || setupResult.timedOut) {
105
+ ok = false;
106
+ error = setupResult.timedOut ? "instruction setup timed out" : `${setupResult.stdout}\n${setupResult.stderr}`.trim() || "instruction setup failed";
107
+ }
108
+ }
109
+ if (opts.push && ok) {
110
+ if (opts.yolo && !(await fileExists(nativeManifestPath))) {
111
+ const baselineResult = await runCommand([String(opts.timeoutSeconds), "watch", "--push", "--once", "--target", target, "--no-yolo"], { FLOOM_SYNC_MANIFEST_PATH: nativeManifestPath });
112
+ if (baselineResult.code !== 0 || baselineResult.timedOut) {
113
+ ok = false;
114
+ error = baselineResult.timedOut ? "native baseline timed out" : `${baselineResult.stdout}\n${baselineResult.stderr}`.trim() || "native baseline failed";
115
+ }
116
+ }
117
+ }
118
+ if (opts.push && ok) {
119
+ const pushArgs = ["watch", "--push", "--once", "--target", target];
120
+ if (!opts.yolo)
121
+ pushArgs.push("--no-yolo");
122
+ const pushResult = await runCommand([String(opts.timeoutSeconds), ...pushArgs], { FLOOM_SYNC_MANIFEST_PATH: nativeManifestPath });
123
+ if (pushResult.code !== 0 || pushResult.timedOut) {
124
+ ok = false;
125
+ error = pushResult.timedOut ? "push timed out" : `${pushResult.stdout}\n${pushResult.stderr}`.trim() || "push failed";
126
+ }
127
+ }
128
+ await appendLog([
129
+ `${new Date().toISOString()} target=${target} ok=${ok}`,
130
+ combined.trim(),
131
+ error && !combined.includes(error) ? error : "",
132
+ "",
133
+ ].filter(Boolean).join("\n"));
134
+ return {
135
+ ok,
136
+ ...parseSyncResult(combined),
137
+ ...(error ? { error } : {}),
138
+ duration_ms: Date.now() - started,
139
+ };
140
+ }
141
+ async function fileExists(path) {
142
+ try {
143
+ await access(path);
144
+ return true;
145
+ }
146
+ catch (err) {
147
+ if (err.code === "ENOENT")
148
+ return false;
149
+ throw err;
150
+ }
151
+ }
152
+ async function sleep(ms) {
153
+ await new Promise((resolve) => setTimeout(resolve, ms));
154
+ }
155
+ export async function runDaemonForeground(opts) {
156
+ const targets = targetsFor(opts.target);
157
+ process.stdout.write(`${symbols.bullet} Floom daemon running for ${targets.join(", ")} every ${opts.intervalSeconds}s.\n`);
158
+ for (;;) {
159
+ const now = new Date();
160
+ const status = {
161
+ running: true,
162
+ manager: "foreground",
163
+ service: "foreground",
164
+ pid: process.pid,
165
+ version: CLI_VERSION,
166
+ hostname: hostname(),
167
+ interval_seconds: opts.intervalSeconds,
168
+ timeout_seconds: opts.timeoutSeconds,
169
+ targets,
170
+ push: opts.push,
171
+ yolo: opts.yolo,
172
+ last_started_at: now.toISOString(),
173
+ last_run: {},
174
+ };
175
+ await writeStatus(status);
176
+ for (const target of targets) {
177
+ status.last_run[target] = await runTarget(target, opts);
178
+ await writeStatus(status);
179
+ }
180
+ const completed = new Date();
181
+ status.last_completed_at = completed.toISOString();
182
+ status.next_run_at = new Date(completed.getTime() + opts.intervalSeconds * 1000).toISOString();
183
+ await writeStatus(status);
184
+ await appendLog(`${completed.toISOString()} daemon cycle complete\n`);
185
+ if (!opts.foreground)
186
+ return;
187
+ await sleep(opts.intervalSeconds * 1000);
188
+ }
189
+ }
190
+ function serviceCommand(opts) {
191
+ const args = [
192
+ "daemon",
193
+ "run",
194
+ "--foreground",
195
+ "--target",
196
+ opts.target,
197
+ "--interval",
198
+ String(opts.intervalSeconds),
199
+ "--timeout",
200
+ String(opts.timeoutSeconds),
201
+ opts.push ? "--push" : "--no-push",
202
+ opts.yolo ? "--yolo" : "--no-yolo",
203
+ ];
204
+ return `exec ${cliInvocation()} ${args.map(shellQuote).join(" ")}`;
205
+ }
206
+ function systemdUnit(opts, user) {
207
+ return `[Unit]
208
+ Description=Floom always-on skill sync
209
+ After=network-online.target
210
+ Wants=network-online.target
211
+
212
+ [Service]
213
+ Type=simple
214
+ ExecStart=/bin/sh -lc ${shellQuote(serviceCommand(opts))}
215
+ Restart=always
216
+ RestartSec=15
217
+ Environment=NO_UPDATE_NOTIFIER=1
218
+ Environment=npm_config_update_notifier=false
219
+
220
+ [Install]
221
+ WantedBy=${user ? "default.target" : "multi-user.target"}
222
+ `;
223
+ }
224
+ function launchdPlist(opts) {
225
+ const command = serviceCommand(opts);
226
+ return `<?xml version="1.0" encoding="UTF-8"?>
227
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
228
+ <plist version="1.0">
229
+ <dict>
230
+ <key>Label</key>
231
+ <string>${LAUNCHD_LABEL}</string>
232
+ <key>ProgramArguments</key>
233
+ <array>
234
+ <string>/bin/sh</string>
235
+ <string>-lc</string>
236
+ <string>${escapePlist(command)}</string>
237
+ </array>
238
+ <key>RunAtLoad</key>
239
+ <true/>
240
+ <key>KeepAlive</key>
241
+ <true/>
242
+ <key>StandardOutPath</key>
243
+ <string>${LOG_PATH}</string>
244
+ <key>StandardErrorPath</key>
245
+ <string>${LOG_PATH}</string>
246
+ </dict>
247
+ </plist>
248
+ `;
249
+ }
250
+ function escapePlist(value) {
251
+ return value
252
+ .replace(/&/g, "&amp;")
253
+ .replace(/</g, "&lt;")
254
+ .replace(/>/g, "&gt;");
255
+ }
256
+ function manager() {
257
+ return platform() === "darwin" ? "launchd" : "systemd";
258
+ }
259
+ function systemdPath() {
260
+ const isRoot = typeof process.getuid === "function" && process.getuid() === 0;
261
+ if (isRoot)
262
+ return { path: `/etc/systemd/system/${SERVICE_NAME}`, user: false };
263
+ return { path: join(homedir(), ".config", "systemd", "user", SERVICE_NAME), user: true };
264
+ }
265
+ async function installDaemon(opts) {
266
+ if (manager() === "launchd") {
267
+ const plistPath = join(homedir(), "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
268
+ const body = launchdPlist(opts);
269
+ if (opts.dryRun) {
270
+ process.stdout.write(body);
271
+ return;
272
+ }
273
+ await mkdir(dirname(plistPath), { recursive: true });
274
+ await writeFile(plistPath, body, "utf8");
275
+ process.stdout.write(`${symbols.ok} Installed ${plistPath}\n`);
276
+ await runManagerCommand("launchctl", ["bootstrap", `gui/${process.getuid?.() ?? ""}`, plistPath], true);
277
+ await runManagerCommand("launchctl", ["kickstart", "-k", `gui/${process.getuid?.() ?? ""}/${LAUNCHD_LABEL}`], true);
278
+ return;
279
+ }
280
+ const target = systemdPath();
281
+ const body = systemdUnit(opts, target.user);
282
+ if (opts.dryRun) {
283
+ process.stdout.write(body);
284
+ return;
285
+ }
286
+ await mkdir(dirname(target.path), { recursive: true });
287
+ await writeFile(target.path, body, "utf8");
288
+ process.stdout.write(`${symbols.ok} Installed ${target.path}\n`);
289
+ await runSystemctl(["daemon-reload"], target.user, true);
290
+ await runSystemctl(["enable", "--now", SERVICE_NAME], target.user, true);
291
+ }
292
+ async function showStatus(opts) {
293
+ const status = await readStatus();
294
+ if (opts.json) {
295
+ process.stdout.write(`${JSON.stringify(status ?? { running: false }, null, 2)}\n`);
296
+ return;
297
+ }
298
+ if (!status) {
299
+ process.stdout.write(`${symbols.bullet} Floom daemon has no status yet.\n`);
300
+ return;
301
+ }
302
+ process.stdout.write(`${symbols.ok} Floom daemon ${status.running ? "status" : "last status"}\n`);
303
+ process.stdout.write(` ${c.dim("manager:")} ${status.manager}\n`);
304
+ process.stdout.write(` ${c.dim("targets:")} ${status.targets.join(", ")}\n`);
305
+ if (status.last_completed_at)
306
+ process.stdout.write(` ${c.dim("last completed:")} ${status.last_completed_at}\n`);
307
+ for (const [target, run] of Object.entries(status.last_run)) {
308
+ process.stdout.write(` ${run.ok ? symbols.ok : symbols.fail} ${target}: ${run.ok ? "ok" : run.error ?? "failed"}\n`);
309
+ }
310
+ }
311
+ async function showLogs(opts) {
312
+ const body = await readFile(LOG_PATH, "utf8").catch((err) => {
313
+ if (err.code === "ENOENT")
314
+ return "";
315
+ throw err;
316
+ });
317
+ const lines = body.split(/\r?\n/);
318
+ process.stdout.write(`${lines.slice(Math.max(0, lines.length - opts.tail)).join("\n")}\n`);
319
+ }
320
+ function execManager(command, args) {
321
+ return new Promise((resolve) => {
322
+ const child = spawn(command, args, { stdio: ["ignore", "ignore", "pipe"] });
323
+ let stderr = "";
324
+ child.stderr.setEncoding("utf8");
325
+ child.stderr.on("data", (chunk) => { stderr += chunk; });
326
+ child.on("error", (err) => resolve({ code: 127, stderr: err.message }));
327
+ child.on("close", (code) => resolve({ code: code ?? 1, stderr }));
328
+ });
329
+ }
330
+ async function runManagerCommand(command, args, soft) {
331
+ const result = await execManager(command, args);
332
+ if (result.code === 0)
333
+ return;
334
+ const rendered = `${command} ${args.join(" ")}`;
335
+ if (soft) {
336
+ process.stdout.write(` ${c.dim(`Run manually if needed: ${rendered}`)}\n`);
337
+ return;
338
+ }
339
+ throw new FloomError(`Failed to run ${rendered}.`, result.stderr.trim() || "Service manager command failed.");
340
+ }
341
+ async function runSystemctl(args, user, soft) {
342
+ await runManagerCommand("systemctl", user ? ["--user", ...args] : args, soft);
343
+ }
344
+ async function restartDaemon() {
345
+ if (manager() === "launchd") {
346
+ await runManagerCommand("launchctl", ["kickstart", "-k", `gui/${process.getuid?.() ?? ""}/${LAUNCHD_LABEL}`], false);
347
+ process.stdout.write(`${symbols.ok} Restarted ${LAUNCHD_LABEL}\n`);
348
+ return;
349
+ }
350
+ const target = systemdPath();
351
+ await runSystemctl(["restart", SERVICE_NAME], target.user, false);
352
+ process.stdout.write(`${symbols.ok} Restarted ${SERVICE_NAME}\n`);
353
+ }
354
+ async function uninstallDaemon() {
355
+ if (manager() === "launchd") {
356
+ const plistPath = join(homedir(), "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
357
+ await runManagerCommand("launchctl", ["bootout", `gui/${process.getuid?.() ?? ""}/${LAUNCHD_LABEL}`], true);
358
+ await unlink(plistPath).catch((err) => {
359
+ if (err.code !== "ENOENT")
360
+ throw err;
361
+ });
362
+ process.stdout.write(`${symbols.ok} Uninstalled ${LAUNCHD_LABEL}\n`);
363
+ return;
364
+ }
365
+ const target = systemdPath();
366
+ await runSystemctl(["disable", "--now", SERVICE_NAME], target.user, true);
367
+ await unlink(target.path).catch((err) => {
368
+ if (err.code !== "ENOENT")
369
+ throw err;
370
+ });
371
+ await runSystemctl(["daemon-reload"], target.user, true);
372
+ process.stdout.write(`${symbols.ok} Uninstalled ${SERVICE_NAME}\n`);
373
+ }
374
+ export async function daemon(opts) {
375
+ switch (opts.command) {
376
+ case "install":
377
+ await installDaemon(opts);
378
+ return;
379
+ case "status":
380
+ await showStatus(opts);
381
+ return;
382
+ case "logs":
383
+ await showLogs(opts);
384
+ return;
385
+ case "run":
386
+ await runDaemonForeground(opts);
387
+ return;
388
+ case "restart":
389
+ await restartDaemon();
390
+ return;
391
+ case "uninstall":
392
+ await uninstallDaemon();
393
+ return;
394
+ default:
395
+ throw new FloomError("Missing daemon command.", "Use install, status, logs, restart, uninstall, or run.");
396
+ }
397
+ }
398
+ export function normalizeDaemonOptions(opts) {
399
+ if (opts.intervalSeconds < MIN_INTERVAL_SECONDS) {
400
+ throw new FloomError("Invalid --interval.", `Use an integer number of seconds, minimum ${MIN_INTERVAL_SECONDS}.`);
401
+ }
402
+ if (opts.timeoutSeconds < MIN_TIMEOUT_SECONDS) {
403
+ throw new FloomError("Invalid --timeout.", `Use an integer number of seconds, minimum ${MIN_TIMEOUT_SECONDS}.`);
404
+ }
405
+ return opts;
406
+ }
407
+ export function parseDaemonTarget(value) {
408
+ if (value === "all")
409
+ return "all";
410
+ if (isAgentTarget(value))
411
+ return value;
412
+ throw new FloomError("Invalid --target.", "Use claude|codex|cursor|opencode|kimi|all.");
413
+ }
@@ -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 floomAgentInstructions(target) {
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
- process.stdout.write(`\n${symbols.ok} Floom instructions already present in ${c.bold(target.path)}\n\n`);
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", "10");
452
+ url.searchParams.set("limit", "100");
453
453
  url.searchParams.set("packages", "1");
454
454
  if (cursor)
455
455
  url.searchParams.set("cursor", cursor);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floomhq/floom",
3
- "version": "1.0.45",
3
+ "version": "1.0.47",
4
4
  "description": "Sync AI skills across agents and machines.",
5
5
  "license": "MIT",
6
6
  "type": "module",