@floomhq/floom 3.0.2 → 3.0.3

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 ADDED
@@ -0,0 +1,168 @@
1
+ # @floomhq/floom
2
+
3
+ The Floom CLI: one shared skill library, synced across every AI agent.
4
+
5
+ Docs: <https://floom.dev/docs>
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm i -g @floomhq/floom
11
+ ```
12
+
13
+ Pinned no-install form for automation:
14
+
15
+ ```bash
16
+ npx -y @floomhq/floom@3.0.2 --help
17
+ ```
18
+
19
+ ## Login
20
+
21
+ ```bash
22
+ floom login
23
+ ```
24
+
25
+ The login command starts a browser device flow. Sign in at floom.dev with
26
+ Google or email, approve the device code, and the CLI stores the session at
27
+ `~/.floom/auth.json`.
28
+
29
+ Check the active account:
30
+
31
+ ```bash
32
+ floom account
33
+ ```
34
+
35
+ ## First Skill
36
+
37
+ ```bash
38
+ floom push ~/.claude/skills/my-skill
39
+ ```
40
+
41
+ The folder must contain `SKILL.md`. Each push creates a Library version.
42
+
43
+ Create a starter skill when the folder does not exist yet:
44
+
45
+ ```bash
46
+ floom new my-skill --agent claude
47
+ ```
48
+
49
+ ## Bulk Sync
50
+
51
+ ```bash
52
+ floom sync
53
+ ```
54
+
55
+ Zero-arg `floom sync` detects installed agents and supported scopes, reviews
56
+ local-to-Library and Library-to-local differences, and applies the selected
57
+ plan. Use `--dry-run` to preview the plan without changing files or Library
58
+ records.
59
+
60
+ Library-to-local only:
61
+
62
+ ```bash
63
+ floom pull
64
+ floom pull --agent claude,codex
65
+ ```
66
+
67
+ Local-to-Library only:
68
+
69
+ ```bash
70
+ floom push
71
+ ```
72
+
73
+ Use the installed `floom` command for interactive terminal work. Use the pinned
74
+ `npx -y @floomhq/floom@3.0.2 <command>` form for automation and agent-run
75
+ commands that need reproducible CLI behavior.
76
+
77
+ ## Releasing
78
+
79
+ Releases publish to npm and bump the Homebrew tap formula automatically via
80
+ `.github/workflows/publish-cli.yml`, triggered by a `cli-v*` tag.
81
+
82
+ ### Hard pre-publish smoke gate
83
+
84
+ Every `npm publish` is gated by `packages/cli/scripts/pre-publish-smoke.sh`,
85
+ which packs the tarball, installs it into an isolated `$HOME`, and drives
86
+ every subcommand the way a real user would. If anything fails, the workflow
87
+ fails BEFORE `npm publish` runs — no broken CLI can reach npm `latest`.
88
+
89
+ Background: in 2026-05 we shipped `3.0.0` → `3.0.1` → `3.0.2` within hours
90
+ because `3.0.0` hit npm `latest` with three P0s that code review missed:
91
+ `--help` ran the destructive body, `--help | head` threw an unhandled EPIPE,
92
+ and unknown commands silently rendered the dashboard. Only an out-of-band
93
+ real-terminal smoke caught them. This script is that smoke, baked into the
94
+ publish pipeline.
95
+
96
+ ### Before pushing a `cli-v*` tag
97
+
98
+ Run the local pre-tag check from the repo root:
99
+
100
+ ```bash
101
+ ./scripts/pre-tag-check.sh
102
+ ```
103
+
104
+ It:
105
+
106
+ 1. Confirms the working tree is clean.
107
+ 2. Rebuilds the CLI from a clean `dist/`.
108
+ 3. Runs the CLI unit test suite (`pnpm --filter @floomhq/floom test`).
109
+ 4. Runs the full pre-publish smoke (same script the workflow runs).
110
+
111
+ If everything passes, the script prints the exact `git tag` + `git push origin`
112
+ commands. You then push the tag and the workflow takes over.
113
+
114
+ If anything fails, fix the issue first. Do not push the tag.
115
+
116
+ ### Smoke-only workflow run
117
+
118
+ To validate the smoke script itself without publishing (e.g. after editing
119
+ `scripts/pre-publish-smoke.sh`):
120
+
121
+ ```
122
+ GitHub → Actions → Publish @floomhq/floom → Run workflow
123
+ smoke_only: true
124
+ ```
125
+
126
+ The workflow will build, run the smoke, and stop. No `npm publish`, no
127
+ Homebrew dispatch.
128
+
129
+ ### Manual publish (rare)
130
+
131
+ To publish without a tag (e.g. retag an existing version under a different
132
+ dist-tag), use `workflow_dispatch` with `smoke_only: false` and the desired
133
+ `tag` input. The smoke gate still runs first.
134
+
135
+ ## What the pre-publish smoke checks
136
+
137
+ See `scripts/pre-publish-smoke.sh` for the full assertion list. Summary:
138
+
139
+ | Stage | Asserts |
140
+ |------|---------|
141
+ | 1 | Tarball builds, packs, installs cleanly into isolated `$HOME`; installed `floom --version` matches `package.json` |
142
+ | 2 | **Every** subcommand `--help` exits 0, starts with `Usage: floom`, writes no `~/.floom/auth.json`, prints no "Signed (in|out)" / "Waiting for browser" (P0 #1 from 3.0.0) |
143
+ | 3 | `floom --help \| head -3` and `floom push --help \| head -1` print no EPIPE / "Unhandled error event" (P0 #2 from 3.0.0) |
144
+ | 4 | `floom <unknown>` exits non-zero with "unknown command" + "did you mean" hint (P0 #3 from 3.0.0) |
145
+ | 5 | Bare `floom --help` exits 0 with grouped help |
146
+ | 6 | Unauth `whoami` / `account` / `logout` exit 0 idempotent; unauth `status` / `sync --json` / `push --json` exit non-zero with no stack trace |
147
+ | 7 | `floom logout extra-positional` is rejected with non-zero exit AND `~/.floom/auth.json` is preserved (no destructive body) |
148
+ | 8 | **Agent detection** picks up fake `.claude/`, `.codex/`, `.cursor/` marker dirs in the isolated `$HOME` (verified by parsing `floom doctor --json`); bare `floom sync` / `floom pull` with planted agents do not crash unauth (clean `signedIn:false` envelope). Note: fan-out *behaviour itself* requires real auth, so it is exercised at the **unit-test layer** by `src/commands/sync.fanout.test.ts`, which the publish workflow runs as a separate hard gate before the smoke. |
149
+
150
+ If any assertion fails, the publish job fails. The smoke script exits non-zero
151
+ on the first failure and prints a summary of every failed check.
152
+
153
+ ## Files
154
+
155
+ ```
156
+ packages/cli/
157
+ ├── src/ # TypeScript source
158
+ │ ├── index.ts # CLI entry point (commander)
159
+ │ ├── commands/ # one file per subcommand
160
+ │ ├── lib/ # shared helpers
161
+ │ └── cli-smoke.test.ts # unit-level smoke (P0 lock-ins)
162
+ ├── scripts/
163
+ │ ├── build-bundle.mjs # esbuild → dist/index.js
164
+ │ ├── verify-package.mjs # pre-pack file allowlist check
165
+ │ └── pre-publish-smoke.sh # E2E smoke driven against tarball
166
+ ├── package.json
167
+ └── CHANGELOG.md
168
+ ```
package/dist/index.js CHANGED
@@ -3253,7 +3253,7 @@ async function getMachineIdentity() {
3253
3253
  }
3254
3254
 
3255
3255
  // src/version.ts
3256
- var VERSION = "3.0.2";
3256
+ var VERSION = "3.0.3";
3257
3257
 
3258
3258
  // src/api-client.ts
3259
3259
  var DEFAULT_TIMEOUT_MS = 2e4;
@@ -3721,17 +3721,35 @@ async function loginCommand() {
3721
3721
  }
3722
3722
 
3723
3723
  // src/commands/logout.ts
3724
- async function logoutCommand() {
3725
- const auth = await readAuth();
3724
+ async function logoutCommand(opts = {}, deps = {}) {
3725
+ const readAuth2 = deps.readAuth ?? readAuth;
3726
+ const clearAuth2 = deps.clearAuth ?? clearAuth;
3727
+ const api2 = deps.api ?? api;
3728
+ const auth = await readAuth2();
3729
+ let remoteRevokeFailed = false;
3726
3730
  if (auth) {
3727
3731
  try {
3728
- await api("/cli/session/revoke", { method: "POST", authRequired: true });
3732
+ await api2("/cli/session/revoke", {
3733
+ method: "POST",
3734
+ authRequired: true,
3735
+ query: opts.all ? { scope: "all" } : void 0
3736
+ });
3729
3737
  } catch (error) {
3730
- log.warn(`Remote session revoke failed: ${error.message}`);
3738
+ if (opts.all) {
3739
+ remoteRevokeFailed = true;
3740
+ process.exitCode = 1;
3741
+ log.err(
3742
+ "Local session cleared. Other devices are still authenticated.\nSign in at https://floom.dev/settings to revoke other sessions from the web."
3743
+ );
3744
+ } else {
3745
+ log.warn(`Remote session revoke failed: ${error.message}`);
3746
+ }
3731
3747
  }
3732
3748
  }
3733
- await clearAuth();
3734
- log.ok("Signed out of Floom on this machine.");
3749
+ await clearAuth2();
3750
+ log.ok(
3751
+ remoteRevokeFailed ? "Local sign-out only \u2014 remote revoke failed." : opts.all ? "Signed out of Floom CLI on all machines." : "Signed out of Floom on this machine."
3752
+ );
3735
3753
  log.blank();
3736
3754
  log.info("Your local skill files in ~/.claude/skills, ~/.codex/skills, etc. were not changed.");
3737
3755
  printNext([{ command: "floom login", description: "sign in again" }]);
@@ -5674,6 +5692,9 @@ async function pullCommand(skillArg, rawOpts = {}) {
5674
5692
  }
5675
5693
  }
5676
5694
 
5695
+ // src/commands/sync.ts
5696
+ import chalk5 from "chalk";
5697
+
5677
5698
  // src/commands/sync-runner.ts
5678
5699
  init_src();
5679
5700
  import { cp as cp2, mkdtemp, readdir as readdir4, rm as rm2, stat as stat6 } from "node:fs/promises";
@@ -5845,7 +5866,9 @@ async function runSyncForTarget(options = {}, deps = {}) {
5845
5866
  process.exitCode = 1;
5846
5867
  return { ok: false, pushFailures, hasConflicts: false };
5847
5868
  }
5848
- log.ok(`Sync complete. Pulled ${plan.pull.length} skills. Pushed ${pushed}/${plan.push.length} skills.`);
5869
+ if (!options.quietSuccess) {
5870
+ log.ok(`Sync complete. Pulled ${plan.pull.length} skills. Pushed ${pushed}/${plan.push.length} skills.`);
5871
+ }
5849
5872
  return { ok: true, pushFailures: [], hasConflicts: false };
5850
5873
  }
5851
5874
 
@@ -5877,6 +5900,9 @@ function buildApplyJson(workspaceName, applied, opts) {
5877
5900
  next: ["floom status"]
5878
5901
  };
5879
5902
  }
5903
+ function formatAgentLabel(plan) {
5904
+ return `${AGENT_LABELS[plan.agent]}${plan.scope === "project" ? " (project)" : ""}`;
5905
+ }
5880
5906
  async function planForAgent(agent, scope, skillsDir, statusFn = statusLibrary) {
5881
5907
  try {
5882
5908
  const status = await statusFn(agent, { installDir: skillsDir });
@@ -6073,7 +6099,7 @@ async function syncCommand(rawOpts = {}, deps = {}) {
6073
6099
  const applied = [];
6074
6100
  for (const p of toSync) {
6075
6101
  try {
6076
- const result = await runSyncFn({ target: p.agent, yes: true, installDir: p.skillsDir });
6102
+ const result = await runSyncFn({ target: p.agent, yes: true, installDir: p.skillsDir, quietSuccess: true });
6077
6103
  const entry = appliedFromResult(p, result);
6078
6104
  if (!entry.ok) failed = true;
6079
6105
  applied.push(entry);
@@ -6088,6 +6114,32 @@ async function syncCommand(rawOpts = {}, deps = {}) {
6088
6114
  if (failed || hasSkipped) process.exitCode = 1;
6089
6115
  return;
6090
6116
  }
6117
+ if (failed) {
6118
+ const failedCount = applied.filter((a) => !a.ok).length;
6119
+ const succeededCount = applied.filter((a) => a.ok).length;
6120
+ log.blank();
6121
+ log.info(`Sync completed with ${failedCount} target(s) failing (${succeededCount} succeeded).`);
6122
+ for (const entry of applied) {
6123
+ const marker = entry.ok ? chalk5.green("\u2713") : chalk5.red("\u2717");
6124
+ log.info(` ${marker} ${formatAgentLabel(entry.plan)} ${entry.message}`);
6125
+ }
6126
+ log.blank();
6127
+ printNext([{ command: "floom status" }]);
6128
+ if (hasSkipped) {
6129
+ for (const p of plans) {
6130
+ for (const slug of p.conflicts) {
6131
+ log.warn(`${AGENT_LABELS[p.agent]} skipped ${slug}, changed in two places`);
6132
+ }
6133
+ }
6134
+ }
6135
+ process.exitCode = 1;
6136
+ return;
6137
+ }
6138
+ if (!hasSkipped) {
6139
+ const pulled = applied.reduce((n, a) => n + a.plan.pull.length, 0);
6140
+ const pushed = applied.reduce((n, a) => n + a.plan.push.length, 0);
6141
+ log.ok(`Sync complete. Pulled ${pulled} skill${pulled === 1 ? "" : "s"}. Pushed ${pushed}/${pushed} skill${pushed === 1 ? "" : "s"}.`);
6142
+ }
6091
6143
  for (const p of plans) {
6092
6144
  for (const slug of p.conflicts) {
6093
6145
  log.warn(`${AGENT_LABELS[p.agent]} skipped ${slug}, changed in two places`);
@@ -6104,7 +6156,7 @@ async function syncCommand(rawOpts = {}, deps = {}) {
6104
6156
  log.info("Backups of any replaced skills are in each agent's .floom/backups/ folder (run floom restore --list to see them).");
6105
6157
  printNext([{ command: "floom status" }]);
6106
6158
  }
6107
- if (failed || hasSkipped) process.exitCode = 1;
6159
+ if (hasSkipped) process.exitCode = 1;
6108
6160
  } finally {
6109
6161
  cleanup.dispose();
6110
6162
  }
@@ -6112,20 +6164,20 @@ async function syncCommand(rawOpts = {}, deps = {}) {
6112
6164
 
6113
6165
  // src/commands/status.ts
6114
6166
  import { resolve as resolve7 } from "node:path";
6115
- import chalk5 from "chalk";
6167
+ import chalk6 from "chalk";
6116
6168
  init_runtime();
6117
6169
  var AGENT_TIMEOUT_MS = 1e4;
6118
6170
  var MAX_ATTENTION_ROWS = 15;
6119
6171
  function toFloomState(state) {
6120
6172
  switch (state) {
6121
6173
  case "active":
6122
- return { state: "up_to_date", label: "up to date", color: chalk5.green };
6174
+ return { state: "up_to_date", label: "up to date", color: chalk6.green };
6123
6175
  case "stale":
6124
- return { state: "update_available", label: "update available", color: chalk5.yellow };
6176
+ return { state: "update_available", label: "update available", color: chalk6.yellow };
6125
6177
  case "dirty":
6126
- return { state: "local_changes", label: "local changes", color: chalk5.yellow };
6178
+ return { state: "local_changes", label: "local changes", color: chalk6.yellow };
6127
6179
  case "conflict":
6128
- return { state: "changed_in_two_places", label: "changed in two places", color: chalk5.red };
6180
+ return { state: "changed_in_two_places", label: "changed in two places", color: chalk6.red };
6129
6181
  case "missing":
6130
6182
  return { state: "not_installed", label: "not installed", color: (s) => s };
6131
6183
  case "unsupported_target":
@@ -6308,7 +6360,7 @@ async function statusCommand(rawOpts = {}) {
6308
6360
  log.blank();
6309
6361
  const shown = attention.slice(0, MAX_ATTENTION_ROWS);
6310
6362
  for (const item of shown) {
6311
- log.info(` ${chalk5.bold(item.slug)}`);
6363
+ log.info(` ${chalk6.bold(item.slug)}`);
6312
6364
  for (const loc of item.locations) {
6313
6365
  const c = toFloomLabel(loc.state);
6314
6366
  log.info(` ${AGENT_LABELS[loc.agent].padEnd(10)} ${c.color(c.label.padEnd(22))}${tildePath(loc.path)}`);
@@ -6385,13 +6437,13 @@ function summarize(skills) {
6385
6437
  function toFloomLabel(state) {
6386
6438
  switch (state) {
6387
6439
  case "up_to_date":
6388
- return { label: "up to date", color: chalk5.green };
6440
+ return { label: "up to date", color: chalk6.green };
6389
6441
  case "local_changes":
6390
- return { label: "local changes", color: chalk5.yellow };
6442
+ return { label: "local changes", color: chalk6.yellow };
6391
6443
  case "update_available":
6392
- return { label: "update available", color: chalk5.yellow };
6444
+ return { label: "update available", color: chalk6.yellow };
6393
6445
  case "changed_in_two_places":
6394
- return { label: "changed in two places", color: chalk5.red };
6446
+ return { label: "changed in two places", color: chalk6.red };
6395
6447
  case "not_in_library_never_published":
6396
6448
  return { label: "not in Library", color: (s) => s };
6397
6449
  case "not_in_library_removed":
@@ -7907,7 +7959,7 @@ async function dashboardCommand() {
7907
7959
  }
7908
7960
 
7909
7961
  // src/lib/help.ts
7910
- import chalk6 from "chalk";
7962
+ import chalk7 from "chalk";
7911
7963
  var GROUPS = [
7912
7964
  {
7913
7965
  title: "Sign in",
@@ -7977,21 +8029,21 @@ var COMMON_FLAGS = `Common flags
7977
8029
  --no-secret-check with push, skip the pre-publish secret scan`;
7978
8030
  function printGroupedHelp() {
7979
8031
  const out2 = process.stdout;
7980
- out2.write("\n" + chalk6.bold("Usage: floom [command]") + "\n\n");
7981
- out2.write(` ${chalk6.cyan.bold("floom")} your dashboard \u2014 Library and agent status at a glance
8032
+ out2.write("\n" + chalk7.bold("Usage: floom [command]") + "\n\n");
8033
+ out2.write(` ${chalk7.cyan.bold("floom")} your dashboard \u2014 Library and agent status at a glance
7982
8034
  `);
7983
8035
  const allNames = GROUPS.flatMap((g) => g.rows.map((r) => r.name));
7984
8036
  const width = Math.max(...allNames.map((n) => n.length), 14);
7985
8037
  for (const group of GROUPS) {
7986
- out2.write("\n" + chalk6.bold(group.title) + "\n");
8038
+ out2.write("\n" + chalk7.bold(group.title) + "\n");
7987
8039
  for (const row of group.rows) {
7988
- const aliasNote = row.alias ? chalk6.dim(` ${row.alias} (alias)`) : "";
7989
- out2.write(` ${chalk6.cyan.bold(row.name.padEnd(width + 2))}${row.description}${aliasNote}
8040
+ const aliasNote = row.alias ? chalk7.dim(` ${row.alias} (alias)`) : "";
8041
+ out2.write(` ${chalk7.cyan.bold(row.name.padEnd(width + 2))}${row.description}${aliasNote}
7990
8042
  `);
7991
8043
  }
7992
8044
  }
7993
8045
  out2.write("\n" + COMMON_FLAGS + "\n");
7994
- out2.write("\n" + chalk6.dim("More help: https://floom.dev/docs") + "\n");
8046
+ out2.write("\n" + chalk7.dim("More help: https://floom.dev/docs") + "\n");
7995
8047
  }
7996
8048
 
7997
8049
  // src/index.ts
@@ -8054,7 +8106,7 @@ function editDistance(a, b) {
8054
8106
  return curr[b.length];
8055
8107
  }
8056
8108
  helpOpt(program.command("login").description("sign in to your Floom workspace")).action(loginCommand);
8057
- helpOpt(program.command("logout").description("sign out on this machine")).action(logoutCommand);
8109
+ helpOpt(program.command("logout").description("sign out on this machine").option("--all", "sign out all CLI sessions for this account")).action((opts) => logoutCommand(opts));
8058
8110
  helpOpt(program.command("account").description("show account details").option("--json", "print account state as JSON")).action((opts) => accountCommand(opts));
8059
8111
  helpOpt(program.command("whoami").description("show account details (alias)").option("--json", "print account state as JSON")).action((opts) => {
8060
8112
  aliasNotice("whoami", "account");
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = "3.0.2";
1
+ export const VERSION = "3.0.3";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floomhq/floom",
3
- "version": "3.0.2",
3
+ "version": "3.0.3",
4
4
  "description": "Floom CLI \u2014 one shared skill library, pulled into the AI agent you choose (Claude, Codex, Cursor, Gemini, OpenCode).",
5
5
  "license": "MIT",
6
6
  "homepage": "https://floom.dev",