@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 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, "&amp;")
232
+ .replace(/</g, "&lt;")
233
+ .replace(/>/g, "&gt;");
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
+ }
@@ -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.46",
4
4
  "description": "Sync AI skills across agents and machines.",
5
5
  "license": "MIT",
6
6
  "type": "module",