@floomhq/floom 1.0.50 → 1.0.52

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/README.md CHANGED
@@ -12,6 +12,8 @@ floom share my-skill --add teammate@example.com
12
12
  floom search review
13
13
  floom add awesome-skill --setup
14
14
  floom setup --target claude --dry-run
15
+ floom daemon install --target all
16
+ floom status
15
17
  floom list
16
18
  floom library list
17
19
  ```
@@ -27,14 +29,19 @@ The package is designed for `npx -y @floomhq/floom ...`. Global installs expose
27
29
  - `npx -y @floomhq/floom publish <path>` — upload a skill folder or Markdown file. Folder packages use `<slug>/SKILL.md` plus optional `references/`, `examples/`, `scripts/`, and `assets/`. Optional `--public` / `--private` / `--unlisted`, `--type knowledge|instruction|workflow|skill`, `--installs-as <target>`, and `--skill-version <label>`.
28
30
  - `npx -y @floomhq/floom share <slug>` — email-share one of your skills. Optional `--add <email>`, `--remove <email>`, and `--list`.
29
31
  - `npx -y @floomhq/floom list` — show your published skills. Optional `--json`.
30
- - `npx -y @floomhq/floom add <url-or-slug>` — fetch a skill into `~/.claude/skills/<slug>/SKILL.md` with supporting package files. Optional `--target claude|codex`, `--setup` to connect the agent, and `--force` to replace an existing local copy.
32
+ - `npx -y @floomhq/floom add <url-or-slug>` — fetch a skill into the selected agent skills root with supporting package files. Optional `--target claude|codex|cursor|opencode|kimi`, `--setup` to connect the agent, and `--force` to replace an existing local copy.
31
33
  - `npx -y @floomhq/floom info <url-or-slug>` — show skill metadata. Optional `--json`.
32
34
  - `npx -y @floomhq/floom search <query>` — search public skills and starter libraries. Optional `--library <slug>`, `--type knowledge|instruction|workflow|skill`, and `--json`.
33
- - `npx -y @floomhq/floom setup` — add Floom usage guidance to `CLAUDE.md` or `AGENTS.md`. Optional `--target claude|codex`, `--dry-run`, `--yes`.
35
+ - `npx -y @floomhq/floom setup` — add Floom usage guidance to the selected harness instructions file. Optional `--target claude|codex|cursor|opencode|kimi`, `--dry-run`, `--yes`.
34
36
  - `npx -y @floomhq/floom connect` — alias for setup.
35
37
  - `npx -y @floomhq/floom mcp` — print MCP setup commands for supported agent CLIs.
36
- - `npx -y @floomhq/floom sync` — preview: pull your published, saved, and subscribed library skills into Claude or Codex. Optional `--target claude|codex`.
37
- - `npx -y @floomhq/floom watch` — preview: run sync repeatedly. Optional `--interval <seconds>`; minimum `10`.
38
+ - `npx -y @floomhq/floom sync` — pull your published, saved, and subscribed library skills into one target. Optional `--target claude|codex|cursor|opencode|kimi`.
39
+ - `npx -y @floomhq/floom watch` — run one target sync loop. Optional `--target claude|codex|cursor|opencode|kimi`, `--push`, `--no-yolo`, `--interval <seconds>`; minimum `10`.
40
+ - `npx -y @floomhq/floom daemon install --target all` — install always-on sync for Claude, Codex, Cursor, OpenCode, and Kimi.
41
+ - `npx -y @floomhq/floom daemon status --json` — show the latest daemon cycle and per-target result.
42
+ - `npx -y @floomhq/floom status --json` — reconcile cloud, cache, native projection, and daemon counts.
43
+ - `npx -y @floomhq/floom feedback --kind <kind> --message <text>` — send bounded agent feedback to Floom.
44
+ - `npx -y @floomhq/floom audit skills --json` — report malformed, duplicate, and suspicious owned skills.
38
45
  - `npx -y @floomhq/floom library list` — list public starter libraries. Optional `--json`.
39
46
  - `npx -y @floomhq/floom library create <slug> --name <name>` — create a personal or starter library. Optional `--public` / `--private` / `--unlisted`.
40
47
  - `npx -y @floomhq/floom library add <library> <skill> [--folder <path>] [--tags a,b]` — add a skill to a library.
@@ -75,7 +82,7 @@ version: 0.1.0
75
82
 
76
83
  Override the API host with `FLOOM_API_URL` (defaults to `https://floom.dev`).
77
84
 
78
- `floom sync` and `floom watch` are v1 preview commands for published, saved, and subscribed library skills. They store a machine-local manifest at `~/.floom/sync-manifest.json`.
79
- The manifest records hashes for files Floom previously wrote. Sync writes missing files only.
80
- Remote updates, existing untracked files, and locally edited tracked files are skipped as conflicts.
85
+ `floom sync`, `floom watch`, and `floom daemon` handle published, saved, and subscribed library skills. They store a machine-local manifest at `~/.floom/sync-manifest.json`.
86
+ The manifest records hashes for files Floom previously wrote. Sync writes missing files and applies cloud updates when the local file still matches the last Floom-tracked hash.
87
+ Existing untracked files and locally edited tracked files are preserved as conflicts.
81
88
  Symlinks are never followed. To replace a local skill manually, run `floom add <url-or-slug> --force`.
package/dist/audit.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import ora from "ora";
2
+ import { readFile } from "node:fs/promises";
2
3
  import { readConfig, resolveApiUrl } from "./config.js";
3
- import { getJson } from "./lib/api.js";
4
+ import { getJson, postJson } from "./lib/api.js";
4
5
  import { FloomError } from "./errors.js";
5
6
  import { c, symbols } from "./ui.js";
6
7
  const FIXTURE_RE = /\b(?:cli lifecycle audit fixture|launch gate|temp skill|test fixture|audit fixture)\b/i;
@@ -99,6 +100,44 @@ async function loadOwnedSkills() {
99
100
  }
100
101
  return skills;
101
102
  }
103
+ function archiveSlugsFromPlan(raw) {
104
+ const value = raw && typeof raw === "object" ? raw : {};
105
+ const plan = value.archive_plan;
106
+ if (!Array.isArray(plan))
107
+ throw new FloomError("Invalid archive plan.", "Pass a JSON file from `floom audit skills --fix-plan`.");
108
+ const slugs = plan.map((item) => {
109
+ if (typeof item === "string")
110
+ return item;
111
+ if (item && typeof item === "object" && typeof item.slug === "string") {
112
+ return item.slug;
113
+ }
114
+ return "";
115
+ }).filter(Boolean);
116
+ if (slugs.length === 0)
117
+ throw new FloomError("Archive plan has no slugs.");
118
+ return [...new Set(slugs)];
119
+ }
120
+ async function applyArchivePlan(planPath, yes, json) {
121
+ const cfg = await readConfig();
122
+ if (!cfg)
123
+ throw new FloomError("Not signed in.", "Run `npx -y @floomhq/floom login` first.");
124
+ const raw = JSON.parse(await readFile(planPath, "utf8"));
125
+ const slugs = archiveSlugsFromPlan(raw);
126
+ const payload = await postJson(`${resolveApiUrl(cfg)}/api/v1/me/skills/archive`, yes ? "archive skills" : "preview skill archive", cfg.accessToken, { slugs, dry_run: !yes });
127
+ if (json) {
128
+ process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
129
+ return;
130
+ }
131
+ const action = payload.dry_run ? "Archive preview" : "Archive applied";
132
+ process.stdout.write(`\n${symbols.ok} ${action}: ${payload.matched.length}/${payload.requested} matched\n`);
133
+ if (payload.archived)
134
+ process.stdout.write(` archived: ${payload.archived.length}\n`);
135
+ if (payload.missing.length > 0)
136
+ process.stdout.write(` missing or not owned: ${payload.missing.join(", ")}\n`);
137
+ if (payload.dry_run)
138
+ process.stdout.write(` ${c.dim("Re-run with --yes to archive matched skills.")}\n`);
139
+ process.stdout.write("\n");
140
+ }
102
141
  function duplicateGroups(skills) {
103
142
  const byHash = new Map();
104
143
  for (const skill of skills) {
@@ -135,7 +174,11 @@ function summarize(skills, findings) {
135
174
  };
136
175
  }
137
176
  export async function auditSkills(opts) {
138
- const spinner = opts.json ? null : ora({ text: c.dim("Auditing skills..."), color: "yellow" }).start();
177
+ if (opts.archivePlan) {
178
+ await applyArchivePlan(opts.archivePlan, opts.yes, opts.json);
179
+ return;
180
+ }
181
+ const spinner = opts.json || opts.fixPlan ? null : ora({ text: c.dim("Auditing skills..."), color: "yellow" }).start();
139
182
  let skills;
140
183
  try {
141
184
  skills = await loadOwnedSkills();
package/dist/cli.js CHANGED
@@ -20,6 +20,7 @@ import { scanSkill } from "./scan.js";
20
20
  import { auditSkills } from "./audit.js";
21
21
  import { share } from "./share.js";
22
22
  import { feedback } from "./feedback.js";
23
+ import { status } from "./status.js";
23
24
  import { libraryAddSkill, libraryCreate, libraryList, libraryRemoveSkill, librarySubscribe, libraryUnsubscribe, moveSkill, } from "./library.js";
24
25
  import { c, symbols } from "./ui.js";
25
26
  import { printError, FloomError } from "./errors.js";
@@ -114,6 +115,8 @@ function commandUsage() {
114
115
  ${c.dim(`Flags: --target ${TARGET_HINT} (default: claude)`)}
115
116
 
116
117
  ${c.dim("Advanced")}
118
+ ${c.cyan("status")} Show cloud, cache, native projection, and daemon counts
119
+ ${c.dim("Flags: --json")}
117
120
  ${c.cyan("library")} Create, browse, and subscribe to libraries
118
121
  ${c.dim("Alias: lib")}
119
122
  ${c.cyan("move")} ${c.dim("<slug> --folder <path>")} Place a saved skill in a relative folder
@@ -278,10 +281,14 @@ function auditUsage() {
278
281
  ${c.cyan(`${CLI_COMMAND} audit skills`)}
279
282
  ${c.cyan(`${CLI_COMMAND} audit skills --json`)}
280
283
  ${c.cyan(`${CLI_COMMAND} audit skills --fix-plan`)}
284
+ ${c.cyan(`${CLI_COMMAND} audit skills --archive-plan plan.json`)} ${c.dim("(dry-run)")}
285
+ ${c.cyan(`${CLI_COMMAND} audit skills --archive-plan plan.json --yes`)}
281
286
 
282
287
  ${c.bold("Flags")}
283
288
  ${c.cyan("--json")} Full machine-readable report
284
289
  ${c.cyan("--fix-plan")} Include archive recommendations, without applying them
290
+ ${c.cyan("--archive-plan <file>")} Preview or apply a reviewed archive plan
291
+ ${c.cyan("--yes")} Apply the archive plan instead of dry-run preview
285
292
  `);
286
293
  }
287
294
  function feedbackUsage() {
@@ -718,17 +725,37 @@ function parseDoctorFlags(argv) {
718
725
  }
719
726
  return out;
720
727
  }
728
+ function parseStatusFlags(argv) {
729
+ const out = { json: false };
730
+ for (const a of argv) {
731
+ if (a === "--json")
732
+ out.json = true;
733
+ else if (a.startsWith("--"))
734
+ throw new FloomError(`Unknown flag: ${a}`, `Try \`${CLI_COMMAND} status --json\`.`);
735
+ else
736
+ throw new FloomError(`Unexpected argument: ${a}`, `Try \`${CLI_COMMAND} status --json\`.`);
737
+ }
738
+ return out;
739
+ }
721
740
  function parseAuditFlags(argv) {
722
741
  const [subcommand, ...rest] = argv;
723
742
  if (subcommand !== "skills") {
724
743
  throw new FloomError("Unknown audit command.", `Try \`${CLI_COMMAND} audit skills --json\`.`);
725
744
  }
726
- const out = { json: false, fixPlan: false };
727
- for (const a of rest) {
745
+ const out = { json: false, fixPlan: false, yes: false };
746
+ for (let i = 0; i < rest.length; i++) {
747
+ const a = rest[i] ?? "";
728
748
  if (a === "--json")
729
749
  out.json = true;
730
750
  else if (a === "--fix-plan")
731
751
  out.fixPlan = true;
752
+ else if (a === "--archive-plan" || a.startsWith("--archive-plan=")) {
753
+ const { value, nextIndex } = readFlagValue(rest, i, "--archive-plan");
754
+ out.archivePlan = value;
755
+ i = nextIndex;
756
+ }
757
+ else if (a === "--yes")
758
+ out.yes = true;
732
759
  else if (a.startsWith("--"))
733
760
  throw new FloomError(`Unknown flag: ${a}`, `Try \`${CLI_COMMAND} audit skills --json\`.`);
734
761
  else
@@ -1182,6 +1209,9 @@ async function main() {
1182
1209
  });
1183
1210
  return;
1184
1211
  }
1212
+ case "status":
1213
+ await status(parseStatusFlags(rest));
1214
+ return;
1185
1215
  case "add":
1186
1216
  case "install": {
1187
1217
  const flags = parseAddArgs(rest);
package/dist/daemon.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
- import { access, mkdir, readFile, unlink, writeFile } from "node:fs/promises";
2
+ import { access, appendFile, mkdir, readFile, unlink, writeFile } from "node:fs/promises";
3
3
  import { homedir, hostname, platform } from "node:os";
4
4
  import { dirname, join } from "node:path";
5
5
  import { CONFIG_DIR } from "./config.js";
@@ -17,9 +17,10 @@ function targetsFor(value) {
17
17
  return value === "all" ? [...AGENT_TARGETS] : [value];
18
18
  }
19
19
  function cliInvocation() {
20
- const node = JSON.stringify(process.execPath);
21
- const entry = JSON.stringify(process.argv[1] ?? "floom");
22
- return `${node} ${entry}`;
20
+ const override = process.env.FLOOM_DAEMON_CLI_COMMAND?.trim();
21
+ if (override)
22
+ return override;
23
+ return `npx -y @floomhq/floom@${CLI_VERSION}`;
23
24
  }
24
25
  function shellQuote(value) {
25
26
  return `'${value.replace(/'/g, "'\\''")}'`;
@@ -57,12 +58,7 @@ function runCommand(args, extraEnv = {}) {
57
58
  }
58
59
  async function appendLog(message) {
59
60
  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 });
61
+ await appendFile(LOG_PATH, message, { mode: 0o600 });
66
62
  }
67
63
  async function writeStatus(status) {
68
64
  await mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 });
@@ -78,6 +74,30 @@ async function readStatus() {
78
74
  throw err;
79
75
  }
80
76
  }
77
+ function pidIsAlive(pid) {
78
+ if (!pid || pid < 1)
79
+ return false;
80
+ try {
81
+ process.kill(pid, 0);
82
+ return true;
83
+ }
84
+ catch {
85
+ return false;
86
+ }
87
+ }
88
+ function statusIsLive(status) {
89
+ if (!status?.running)
90
+ return false;
91
+ if (!pidIsAlive(status.pid))
92
+ return false;
93
+ if (!status.next_run_at || !status.interval_seconds)
94
+ return true;
95
+ const nextRun = Date.parse(status.next_run_at);
96
+ if (!Number.isFinite(nextRun))
97
+ return false;
98
+ const graceMs = Math.max(status.interval_seconds * 2000, 120_000);
99
+ return nextRun + graceMs >= Date.now();
100
+ }
81
101
  function parseSyncResult(output) {
82
102
  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
103
  if (!match)
@@ -291,15 +311,16 @@ async function installDaemon(opts) {
291
311
  }
292
312
  async function showStatus(opts) {
293
313
  const status = await readStatus();
314
+ const live = statusIsLive(status);
294
315
  if (opts.json) {
295
- process.stdout.write(`${JSON.stringify(status ?? { running: false }, null, 2)}\n`);
316
+ process.stdout.write(`${JSON.stringify(status ? { ...status, running: live } : { running: false }, null, 2)}\n`);
296
317
  return;
297
318
  }
298
319
  if (!status) {
299
320
  process.stdout.write(`${symbols.bullet} Floom daemon has no status yet.\n`);
300
321
  return;
301
322
  }
302
- process.stdout.write(`${symbols.ok} Floom daemon ${status.running ? "status" : "last status"}\n`);
323
+ process.stdout.write(`${live ? symbols.ok : symbols.bullet} Floom daemon ${live ? "status" : "last status"}\n`);
303
324
  process.stdout.write(` ${c.dim("manager:")} ${status.manager}\n`);
304
325
  process.stdout.write(` ${c.dim("targets:")} ${status.targets.join(", ")}\n`);
305
326
  if (status.last_completed_at)
package/dist/setup.js CHANGED
@@ -140,7 +140,7 @@ async function detectTarget(opts) {
140
140
  return {
141
141
  agent,
142
142
  label: targetLabel(agent),
143
- path: join(homedir(), ".kimi", "agents", "federico-system.md"),
143
+ path: join(homedir(), ".kimi", "agents", "floom-system.md"),
144
144
  };
145
145
  }
146
146
  const existing = await findUp(INSTRUCTION_FILES[agent].filename);
package/dist/status.js ADDED
@@ -0,0 +1,181 @@
1
+ import { constants } from "node:fs";
2
+ import { open, readdir } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { CONFIG_DIR, readConfig, resolveApiUrl } from "./config.js";
5
+ import { floomFetch } from "./lib/api.js";
6
+ import { AGENT_TARGETS, targetSkillsDir } from "./targets.js";
7
+ import { c, symbols } from "./ui.js";
8
+ const STATUS_CLOUD_TIMEOUT_MS = 8_000;
9
+ async function readJsonFile(path) {
10
+ try {
11
+ const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
12
+ try {
13
+ return JSON.parse(await handle.readFile("utf8"));
14
+ }
15
+ finally {
16
+ await handle.close();
17
+ }
18
+ }
19
+ catch (err) {
20
+ const code = err.code;
21
+ if (code === "ENOENT" || code === "ELOOP" || err instanceof SyntaxError)
22
+ return null;
23
+ throw err;
24
+ }
25
+ }
26
+ async function countDirectSkillPackages(root) {
27
+ let count = 0;
28
+ let entries;
29
+ try {
30
+ entries = await readdir(root, { withFileTypes: true });
31
+ }
32
+ catch (err) {
33
+ if (err.code === "ENOENT")
34
+ return 0;
35
+ throw err;
36
+ }
37
+ if (entries.some((entry) => entry.isFile() && entry.name === "SKILL.md"))
38
+ count += 1;
39
+ for (const entry of entries) {
40
+ if (!entry.isDirectory() || entry.name.startsWith("."))
41
+ continue;
42
+ try {
43
+ const child = await readdir(join(root, entry.name), { withFileTypes: true });
44
+ if (child.some((item) => item.isFile() && item.name === "SKILL.md"))
45
+ count += 1;
46
+ }
47
+ catch (err) {
48
+ if (err.code !== "ENOENT")
49
+ throw err;
50
+ }
51
+ }
52
+ return count;
53
+ }
54
+ function manifestCounts(manifest) {
55
+ const files = manifest?.files && typeof manifest.files === "object" ? Object.values(manifest.files) : [];
56
+ return {
57
+ files: files.length,
58
+ skills: new Set(files.map((entry) => entry.slug).filter((slug) => typeof slug === "string" && slug.length > 0)).size,
59
+ };
60
+ }
61
+ async function countCloudVisible() {
62
+ const cfg = await readConfig();
63
+ if (!cfg)
64
+ return { signedIn: false, total: null };
65
+ const apiUrl = resolveApiUrl(cfg);
66
+ try {
67
+ const url = new URL(`${apiUrl}/api/v1/me/skills`);
68
+ url.searchParams.set("limit", "100");
69
+ const res = await floomFetch(url.toString(), "load your skills", {
70
+ token: cfg.accessToken,
71
+ timeoutMs: STATUS_CLOUD_TIMEOUT_MS,
72
+ rateLimitRetries: 0,
73
+ });
74
+ const payload = (await res.json());
75
+ const sampleCount = Array.isArray(payload.skills) ? payload.skills.length : 0;
76
+ const hasMore = Boolean(payload.next_cursor);
77
+ return {
78
+ signedIn: true,
79
+ total: hasMore ? null : sampleCount,
80
+ sampleCount,
81
+ hasMore,
82
+ };
83
+ }
84
+ catch (err) {
85
+ return {
86
+ signedIn: true,
87
+ total: null,
88
+ error: err instanceof Error ? err.message : String(err),
89
+ };
90
+ }
91
+ }
92
+ async function buildStatus() {
93
+ const cloud = await countCloudVisible();
94
+ const daemonRaw = await readJsonFile(join(CONFIG_DIR, "daemon-status.json"));
95
+ const daemonRunning = daemonIsLive(daemonRaw);
96
+ const targets = {};
97
+ for (const target of AGENT_TARGETS) {
98
+ const cacheRoot = join(CONFIG_DIR, "skill-cache", target);
99
+ const nativeRoot = targetSkillsDir(target);
100
+ const cacheManifest = manifestCounts(await readJsonFile(join(cacheRoot, ".floom-cli-sync-manifest.json")));
101
+ const nativeManifest = manifestCounts(await readJsonFile(join(CONFIG_DIR, "native-sync-manifests", `${target}.json`)));
102
+ targets[target] = {
103
+ cache_packages: cacheManifest.skills || await countDirectSkillPackages(cacheRoot),
104
+ cache_manifest_files: cacheManifest.files,
105
+ cache_manifest_skills: cacheManifest.skills,
106
+ native_root: nativeRoot,
107
+ native_packages: nativeManifest.skills || await countDirectSkillPackages(nativeRoot),
108
+ native_manifest_files: nativeManifest.files,
109
+ native_manifest_skills: nativeManifest.skills,
110
+ ...(daemonRaw?.last_run?.[target] ? { daemon: daemonRaw.last_run[target] } : {}),
111
+ };
112
+ }
113
+ return {
114
+ cloud: {
115
+ signed_in: cloud.signedIn,
116
+ visible_total: cloud.total,
117
+ ...(cloud.sampleCount !== undefined ? { visible_sample_count: cloud.sampleCount } : {}),
118
+ ...(cloud.hasMore !== undefined ? { visible_has_more: cloud.hasMore } : {}),
119
+ ...(cloud.error ? { error: cloud.error } : {}),
120
+ },
121
+ daemon: {
122
+ running: daemonRunning,
123
+ ...(daemonRaw?.version ? { version: daemonRaw.version } : {}),
124
+ ...(daemonRaw?.hostname ? { hostname: daemonRaw.hostname } : {}),
125
+ ...(daemonRaw?.targets ? { targets: daemonRaw.targets } : {}),
126
+ ...(daemonRaw?.last_completed_at ? { last_completed_at: daemonRaw.last_completed_at } : {}),
127
+ ...(daemonRaw?.next_run_at ? { next_run_at: daemonRaw.next_run_at } : {}),
128
+ },
129
+ targets,
130
+ notes: [
131
+ "Floom cache is the synced local package store used by MCP/search/router flows.",
132
+ "Native harness roots stay small where the harness would otherwise preload thousands of skills.",
133
+ "Instruction files tell agents to use Floom search/MCP instead of enumerating every cached package.",
134
+ ],
135
+ };
136
+ }
137
+ function daemonIsLive(status) {
138
+ if (!status?.running)
139
+ return false;
140
+ if (!status.pid || status.pid < 1)
141
+ return false;
142
+ try {
143
+ process.kill(status.pid, 0);
144
+ }
145
+ catch {
146
+ return false;
147
+ }
148
+ if (!status.next_run_at || !status.interval_seconds)
149
+ return true;
150
+ const nextRun = Date.parse(status.next_run_at);
151
+ if (!Number.isFinite(nextRun))
152
+ return false;
153
+ const graceMs = Math.max(status.interval_seconds * 2000, 120_000);
154
+ return nextRun + graceMs >= Date.now();
155
+ }
156
+ export async function status(opts) {
157
+ const payload = await buildStatus();
158
+ if (opts.json) {
159
+ process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
160
+ return;
161
+ }
162
+ process.stdout.write(`\n${symbols.dot} ${c.bold("Floom status")}\n\n`);
163
+ process.stdout.write(` ${c.dim("cloud signed in:")} ${payload.cloud.signed_in ? "yes" : "no"}\n`);
164
+ const cloudVisible = payload.cloud.visible_total ?? (payload.cloud.visible_has_more ? `${payload.cloud.visible_sample_count ?? "?"}+` : "unknown");
165
+ process.stdout.write(` ${c.dim("cloud visible skills:")} ${cloudVisible}\n`);
166
+ if (payload.cloud.error)
167
+ process.stdout.write(` ${c.dim("cloud status:")} ${payload.cloud.error}\n`);
168
+ process.stdout.write(` ${c.dim("daemon:")} ${payload.daemon.running ? "running" : "not running"}${payload.daemon.version ? ` (${payload.daemon.version})` : ""}\n`);
169
+ if (payload.daemon.last_completed_at)
170
+ process.stdout.write(` ${c.dim("last completed:")} ${payload.daemon.last_completed_at}\n`);
171
+ process.stdout.write("\n");
172
+ for (const target of AGENT_TARGETS) {
173
+ const row = payload.targets[target];
174
+ const daemon = row.daemon ? ` daemon=${row.daemon.ok ? "ok" : "error"} synced=${row.daemon.synced ?? "?"} conflicts=${row.daemon.conflicts ?? "?"}` : "";
175
+ process.stdout.write(` ${c.cyan(target.padEnd(8))} cache=${row.cache_packages} native=${row.native_packages} nativeTracked=${row.native_manifest_skills}${daemon}\n`);
176
+ }
177
+ process.stdout.write("\n");
178
+ for (const note of payload.notes)
179
+ process.stdout.write(` ${c.dim(`- ${note}`)}\n`);
180
+ process.stdout.write("\n");
181
+ }
package/dist/sync.js CHANGED
@@ -153,6 +153,7 @@ function syncPackageFiles(target, body, files) {
153
153
  async function planPackageSync(root, files, manifest) {
154
154
  let missing = 0;
155
155
  let unchanged = 0;
156
+ let remoteChanged = 0;
156
157
  let firstMissingTarget = null;
157
158
  for (const file of files) {
158
159
  const targetKey = manifestKey(root, file.target);
@@ -186,12 +187,16 @@ async function planPackageSync(root, files, manifest) {
186
187
  }
187
188
  if (state.hash !== tracked.hash)
188
189
  return { kind: "conflict", target: file.target, reason: "local file changed since the last Floom sync" };
189
- if (state.hash !== file.hash)
190
- return { kind: "conflict", target: file.target, reason: "remote skill changed; move or delete the local file to accept the Floom version" };
190
+ if (state.hash !== file.hash) {
191
+ remoteChanged += 1;
192
+ continue;
193
+ }
191
194
  unchanged += 1;
192
195
  }
193
196
  if (unchanged === files.length)
194
197
  return { kind: "unchanged" };
198
+ if (missing === 0 && remoteChanged > 0)
199
+ return { kind: "update" };
195
200
  if (missing === files.length)
196
201
  return { kind: "write" };
197
202
  return {
@@ -200,6 +205,25 @@ async function planPackageSync(root, files, manifest) {
200
205
  reason: "local package is only partially installed",
201
206
  };
202
207
  }
208
+ async function overwriteTrackedFile(root, target, body, expectedHash) {
209
+ const parent = await openSafeParentDirectory(root, target, false);
210
+ let handle = null;
211
+ try {
212
+ handle = await open(childCreatePath(parent, dirname(target), basename(target)), constants.O_RDWR | constants.O_NOFOLLOW);
213
+ const stat = await handle.stat();
214
+ if (!stat.isFile())
215
+ throw conflictError("path is blocked by an existing local file or directory", "ENOTDIR");
216
+ const currentHash = sha256(await handle.readFile());
217
+ if (currentHash !== expectedHash)
218
+ throw conflictError("local file changed since the last Floom sync", "EEXIST");
219
+ await handle.truncate(0);
220
+ await writeAll(handle, body);
221
+ }
222
+ finally {
223
+ await handle?.close();
224
+ await parent.close();
225
+ }
226
+ }
203
227
  async function ensureSafeParentDirectory(root, target) {
204
228
  const resolvedRoot = resolve(root);
205
229
  const resolvedParent = resolve(dirname(target));
@@ -348,8 +372,19 @@ export async function sync(opts = {}) {
348
372
  continue;
349
373
  }
350
374
  try {
351
- for (const file of packageFiles)
352
- await writeSyncedFile(root, file.target, file.bytes);
375
+ if (plan.kind === "update") {
376
+ for (const file of packageFiles) {
377
+ const targetKey = manifestKey(root, file.target);
378
+ const tracked = manifest.files[targetKey];
379
+ if (!tracked)
380
+ throw conflictError("existing file is not tracked by Floom sync", "EEXIST");
381
+ await overwriteTrackedFile(root, file.target, file.bytes, tracked.hash);
382
+ }
383
+ }
384
+ else {
385
+ for (const file of packageFiles)
386
+ await writeSyncedFile(root, file.target, file.bytes);
387
+ }
353
388
  }
354
389
  catch (err) {
355
390
  const code = err.code;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floomhq/floom",
3
- "version": "1.0.50",
3
+ "version": "1.0.52",
4
4
  "description": "Sync AI skills across agents and machines.",
5
5
  "license": "MIT",
6
6
  "type": "module",