@curdx/flow 3.2.0 → 3.3.1

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/CHANGELOG.md CHANGED
@@ -2,6 +2,28 @@
2
2
 
3
3
  All notable changes to `@curdx/flow` are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/) and the project follows [Semantic Versioning](https://semver.org/).
4
4
 
5
+ ## 3.3.1 — 2026-04-27
6
+
7
+ ### Fixed
8
+
9
+ - **Silent stalls between phases** — added spinners to the previously-silent windows where flow shells out to `claude plugin list --json` and `claude mcp list` (the latter performs an MCP server health check and can take 5-15 seconds). Affected sites: `install` (state-derivation between marketplace refresh and the multiselect), `update` and `uninstall` (state-derivation at flow entry), and the post-flow CLAUDE.md sync (after install/update/uninstall busts the cache, sync re-queries state). Each now shows `Checking installed state… (claude plugin list / mcp list)` with a result line so the run no longer feels frozen.
10
+ - **CLAUDE.md sync feedback** — replaced the post-hoc `p.log.info` line with a live spinner that converts to a final status line on completion, matching the marketplace-refresh and per-item install UX.
11
+
12
+ ## 3.3.0 — 2026-04-27
13
+
14
+ ### Added
15
+
16
+ - **CLAUDE.md sync** — every successful `install` / `update` / `uninstall` now rewrites a small managed block in `~/.claude/CLAUDE.md` so Claude Code has session-start knowledge of which tools are installed and when to use each. The block lives between `<!-- BEGIN @curdx/flow v1 -->` / `<!-- END @curdx/flow v1 -->` markers; everything outside is preserved verbatim. Uninstalling all managed items removes the block entirely.
17
+ - **`Pkg.whenToUse` and `Pkg.slashNamespace`** — two new optional registry fields. `whenToUse` is the English trigger fragment shown in the CLAUDE.md "Available tools/plugins" list (e.g. "auto-fires on 2+ failures..."). `slashNamespace` is the slash invocation pattern (e.g. `/pua:*`) — only set on plugins that expose one. Both populated for the six bundled items, sourced from each upstream's own documentation.
18
+ - **Conditional Rules section** — the block's `Rules:` lines are emitted only for currently-installed tools, so the block never advises Claude to use a tool that isn't there. The "plan first" rule names whichever planners (`sequential-thinking`, `claude-mem`) are installed.
19
+ - **`--no-claude-md` flag and `CURDX_FLOW_NO_CLAUDE_MD` env var** — opt out of the CLAUDE.md sync (CI, locked-down filesystems, or users who prefer to manage CLAUDE.md by hand).
20
+
21
+ ### Notes
22
+
23
+ - Sync is **safe by default**: writes are atomic (tmp + `fs.rename`), partial CLAUDE.md changes are impossible, and a failed sync prints a warning but never aborts a successful install.
24
+ - Forward-compatible: the BEGIN/END regex matches any `v\d+` suffix, so a future `v2` block format will silently replace any pre-existing `v1` block.
25
+ - Block content is always English regardless of `--lang`. CLAUDE.md's audience is Claude itself; English keeps instructions stable and avoids diff churn from alternating language runs.
26
+
5
27
  ## 3.2.0 — 2026-04-26
6
28
 
7
29
  ### Added
package/README.md CHANGED
@@ -34,6 +34,28 @@ npx @curdx/flow --lang en # override language
34
34
  | `sequential-thinking` | mcp | `@modelcontextprotocol/server-sequential-thinking` |
35
35
  | `context7` | mcp | HTTP — `https://mcp.context7.com/mcp` (optional API key) |
36
36
 
37
+ ## What it writes to your filesystem
38
+
39
+ After every successful `install` / `update` / `uninstall`, flow keeps a short managed block in your global `~/.claude/CLAUDE.md` so Claude Code knows at session start which tools are installed and when to use them. The block looks like:
40
+
41
+ ```
42
+ <!-- BEGIN @curdx/flow v1 -->
43
+ ## Tool Usage
44
+
45
+ Available tools/plugins:
46
+ - pua (v3.0.0) — `/pua:*` — auto-fires on 2+ failures or user frustration; ...
47
+ - ...
48
+
49
+ Rules:
50
+ - Do not call every tool by default; ...
51
+ - ...
52
+
53
+ Run `npx @curdx/flow` to install / update / uninstall.
54
+ <!-- END @curdx/flow v1 -->
55
+ ```
56
+
57
+ Anything outside the BEGIN/END markers is preserved verbatim — flow only ever rewrites or removes the block itself. Uninstalling all managed items removes the block entirely. Pass `--no-claude-md` (or set `CURDX_FLOW_NO_CLAUDE_MD=1`) to opt out.
58
+
37
59
  ## Requirements
38
60
 
39
61
  - Node.js >= 20.12
package/dist/index.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import * as p8 from "@clack/prompts";
4
+ import * as p9 from "@clack/prompts";
5
5
  import { defineCommand, runMain } from "citty";
6
6
 
7
7
  // src/ui/language.ts
@@ -66,7 +66,15 @@ var messages = {
66
66
  "chrome.prereqNode": "\u9700\u8981 Node.js >= 20.19\uFF0C\u5F53\u524D\u7248\u672C {current}",
67
67
  "chrome.prereqChrome": "\u9700\u8981\u672C\u673A\u5DF2\u5B89\u88C5 Chrome\uFF08chrome-devtools-mcp \u4F1A\u8C03\u7528\u672C\u5730\u6D4F\u89C8\u5668\uFF09",
68
68
  "reinstall.uninstalling": "\u5148\u5378\u8F7D\u65E7\u7248\u672C\u2026",
69
- "reinstall.installing": "\u5B89\u88C5\u65B0\u7248\u672C\u2026"
69
+ "reinstall.installing": "\u5B89\u88C5\u65B0\u7248\u672C\u2026",
70
+ "state.checking": "\u68C0\u67E5\u5DF2\u5B89\u88C5\u72B6\u6001\u2026\uFF08claude plugin list / mcp list\uFF09",
71
+ "state.checked": "\u5DF2\u68C0\u67E5 {count} \u9879",
72
+ "claudeMd.syncing": "\u540C\u6B65 ~/.claude/CLAUDE.md \u2026",
73
+ "claudeMd.synced": "CLAUDE.md \u5DF2\u66F4\u65B0\uFF08{path}\uFF09",
74
+ "claudeMd.unchanged": "CLAUDE.md \u5DF2\u662F\u6700\u65B0",
75
+ "claudeMd.removed": "\u5DF2\u4ECE CLAUDE.md \u79FB\u9664 @curdx/flow \u533A\u5757",
76
+ "claudeMd.skipped": "\u5DF2\u8DF3\u8FC7 CLAUDE.md \u540C\u6B65\uFF08--no-claude-md\uFF09",
77
+ "claudeMd.failed": "CLAUDE.md \u540C\u6B65\u5931\u8D25\uFF1A{error}"
70
78
  };
71
79
  var zh_default = messages;
72
80
 
@@ -129,7 +137,15 @@ var messages2 = {
129
137
  "chrome.prereqNode": "Requires Node.js >= 20.19 (current: {current})",
130
138
  "chrome.prereqChrome": "Requires Chrome installed locally (chrome-devtools-mcp drives the local browser)",
131
139
  "reinstall.uninstalling": "Uninstalling old version\u2026",
132
- "reinstall.installing": "Installing new version\u2026"
140
+ "reinstall.installing": "Installing new version\u2026",
141
+ "state.checking": "Checking installed state\u2026 (claude plugin list / mcp list)",
142
+ "state.checked": "Checked {count} item(s)",
143
+ "claudeMd.syncing": "Syncing ~/.claude/CLAUDE.md\u2026",
144
+ "claudeMd.synced": "CLAUDE.md updated ({path})",
145
+ "claudeMd.unchanged": "CLAUDE.md already up to date",
146
+ "claudeMd.removed": "Removed @curdx/flow block from CLAUDE.md",
147
+ "claudeMd.skipped": "Skipped CLAUDE.md sync (--no-claude-md)",
148
+ "claudeMd.failed": "CLAUDE.md sync failed: {error}"
133
149
  };
134
150
  var en_default = messages2;
135
151
 
@@ -178,10 +194,10 @@ async function initLanguage(override) {
178
194
  }
179
195
 
180
196
  // src/ui/menu.ts
181
- import * as p7 from "@clack/prompts";
197
+ import * as p8 from "@clack/prompts";
182
198
 
183
199
  // src/flows/install.ts
184
- import * as p3 from "@clack/prompts";
200
+ import * as p4 from "@clack/prompts";
185
201
  import pc from "picocolors";
186
202
 
187
203
  // src/runner/state.ts
@@ -200,15 +216,15 @@ async function run(cmd, args) {
200
216
  stderr: result.stderr
201
217
  };
202
218
  }
203
- async function runStreaming(cmd, args, log4) {
204
- log4.message(`$ ${cmd} ${args.join(" ")}`);
219
+ async function runStreaming(cmd, args, log5) {
220
+ log5.message(`$ ${cmd} ${args.join(" ")}`);
205
221
  const proc = x(cmd, args, { throwOnError: false });
206
222
  let stdout = "";
207
223
  for await (const line of proc) {
208
224
  const trimmed = line.replace(/\r?\n$/, "");
209
225
  if (trimmed.length > 0) {
210
226
  stdout += trimmed + "\n";
211
- log4.message(trimmed);
227
+ log5.message(trimmed);
212
228
  }
213
229
  }
214
230
  const finished = await proc;
@@ -251,15 +267,15 @@ async function listPlugins(force = false) {
251
267
  }
252
268
  try {
253
269
  const arr = JSON.parse(res.stdout);
254
- pluginCache = arr.map((p9) => {
255
- const [name = p9.id, marketplace = ""] = p9.id.split("@");
270
+ pluginCache = arr.map((p10) => {
271
+ const [name = p10.id, marketplace = ""] = p10.id.split("@");
256
272
  return {
257
- id: p9.id,
273
+ id: p10.id,
258
274
  name,
259
275
  marketplace,
260
- version: p9.version,
261
- scope: p9.scope,
262
- enabled: p9.enabled
276
+ version: p10.version,
277
+ scope: p10.scope,
278
+ enabled: p10.enabled
263
279
  };
264
280
  });
265
281
  } catch {
@@ -305,7 +321,7 @@ async function listMcp(force = false) {
305
321
  }
306
322
  async function isPluginInstalled(id) {
307
323
  const list = await listPlugins();
308
- return list.some((p9) => p9.id === id);
324
+ return list.some((p10) => p10.id === id);
309
325
  }
310
326
  async function isMarketplaceAdded(name) {
311
327
  const list = await listMarketplaces();
@@ -317,7 +333,7 @@ async function isMcpInstalled(name) {
317
333
  }
318
334
  async function findPlugin(id) {
319
335
  const list = await listPlugins();
320
- return list.find((p9) => p9.id === id);
336
+ return list.find((p10) => p10.id === id);
321
337
  }
322
338
  var marketplaceJsonCache = /* @__PURE__ */ new Map();
323
339
  function marketplaceDir(name) {
@@ -339,7 +355,7 @@ async function readMarketplaceJson(name) {
339
355
  async function getMarketplacePluginVersion(marketplaceName, pluginName) {
340
356
  const m = await readMarketplaceJson(marketplaceName);
341
357
  if (!m?.plugins) return null;
342
- const entry = m.plugins.find((p9) => p9.name === pluginName);
358
+ const entry = m.plugins.find((p10) => p10.name === pluginName);
343
359
  return entry?.version ?? null;
344
360
  }
345
361
  var REFRESH_TTL_MS = 60 * 60 * 1e3;
@@ -399,11 +415,13 @@ var pua = {
399
415
  name: "pua",
400
416
  description: "tanweai/pua \u2014 Chinese Claude Code skills bundle",
401
417
  type: "plugin",
418
+ slashNamespace: "/pua:*",
419
+ whenToUse: "auto-fires on 2+ failures or user frustration; sub-modes p7 / p9 / pro / loop. Skip on first-attempt failures or when a known fix is executing.",
402
420
  marketplaces: () => [MARKETPLACE_NAME],
403
421
  isInstalled: () => isPluginInstalled(PLUGIN_ID),
404
422
  installedVersion: async () => {
405
- const p9 = await findPlugin(PLUGIN_ID);
406
- const v = p9?.version;
423
+ const p10 = await findPlugin(PLUGIN_ID);
424
+ const v = p10?.version;
407
425
  return v && v !== "unknown" ? v : null;
408
426
  },
409
427
  latestVersion: () => getMarketplacePluginVersion(MARKETPLACE_NAME, PLUGIN_NAME),
@@ -426,11 +444,13 @@ var claudeMem = {
426
444
  name: "claude-mem",
427
445
  description: "thedotmack/claude-mem \u2014 persistent cross-session memory for Claude Code",
428
446
  type: "plugin",
447
+ slashNamespace: "/claude-mem:*",
448
+ whenToUse: 'for cross-session memory search ("did we solve this before?"), phased planning (`make-plan`), or phased execution (`do`).',
429
449
  marketplaces: () => [MARKETPLACE_NAME2],
430
450
  isInstalled: () => isPluginInstalled(PLUGIN_ID2),
431
451
  installedVersion: async () => {
432
- const p9 = await findPlugin(PLUGIN_ID2);
433
- const v = p9?.version;
452
+ const p10 = await findPlugin(PLUGIN_ID2);
453
+ const v = p10?.version;
434
454
  return v && v !== "unknown" ? v : null;
435
455
  },
436
456
  latestVersion: () => getMarketplacePluginVersion(MARKETPLACE_NAME2, PLUGIN_NAME2),
@@ -461,6 +481,7 @@ var chromeDevtoolsMcp = {
461
481
  name: "chrome-devtools-mcp",
462
482
  description: "ChromeDevTools/chrome-devtools-mcp \u2014 drive a real Chrome from Claude Code",
463
483
  type: "plugin",
484
+ whenToUse: "when debugging code that runs in a browser: perf traces, network / console inspection, DOM / CSS issues. Prefer snapshot over screenshot.",
464
485
  prereqCheck: async (t2) => {
465
486
  const major = Number(process.versions.node.split(".")[0] ?? "0");
466
487
  const minor = Number(process.versions.node.split(".")[1] ?? "0");
@@ -489,6 +510,7 @@ var frontendDesign = {
489
510
  name: "frontend-design",
490
511
  description: "Anthropic official \u2014 UI/frontend design helpers",
491
512
  type: "plugin",
513
+ whenToUse: "auto-fires when building UI / web components / pages. Best where visual personality matters (landing, marketing, portfolio).",
492
514
  isInstalled: () => isPluginInstalled(PLUGIN_ID4),
493
515
  install: (ctx) => installPluginById(PLUGIN_ID4, ctx),
494
516
  uninstall: (ctx) => uninstallPluginById(PLUGIN_ID4, ctx),
@@ -503,6 +525,7 @@ var sequentialThinking = {
503
525
  name: "sequential-thinking",
504
526
  description: "modelcontextprotocol/server-sequential-thinking \u2014 structured reasoning helper",
505
527
  type: "mcp",
528
+ whenToUse: "for complex multi-step problems where assumptions may shift (architecture comparison, risk-assessed migrations, prod-only debugging). Skip for simple queries.",
506
529
  isInstalled: () => isMcpInstalled(MCP_NAME),
507
530
  install: async (ctx) => {
508
531
  const r = await runStreaming(
@@ -543,6 +566,7 @@ var context7 = {
543
566
  name: "context7",
544
567
  description: "upstash/context7 \u2014 up-to-date docs from any library (HTTP MCP, optional API key)",
545
568
  type: "mcp",
569
+ whenToUse: "for any library / SDK / framework / API / Claude Code docs lookup. Use instead of web search.",
546
570
  isInstalled: () => isMcpInstalled(MCP_NAME2),
547
571
  configPrompts: async ({ t: t2 }) => {
548
572
  p2.note(`${t2("context7.dashboardHint")}
@@ -595,7 +619,187 @@ var PKGS = [
595
619
  context7_default
596
620
  ];
597
621
  function findPkg(id) {
598
- return PKGS.find((p9) => p9.id === id);
622
+ return PKGS.find((p10) => p10.id === id);
623
+ }
624
+
625
+ // src/runner/claudeMd.ts
626
+ import { promises as fs2 } from "fs";
627
+ import path2 from "path";
628
+ import os2 from "os";
629
+ import * as p3 from "@clack/prompts";
630
+ var BEGIN_MARKER = "<!-- BEGIN @curdx/flow v1 -->";
631
+ var END_MARKER = "<!-- END @curdx/flow v1 -->";
632
+ var BLOCK_RE = /<!-- BEGIN @curdx\/flow v\d+[^>]*-->[\s\S]*?<!-- END @curdx\/flow v\d+ -->/;
633
+ function claudeMdPath() {
634
+ return path2.join(os2.homedir(), ".claude", "CLAUDE.md");
635
+ }
636
+ function renderItemLine(item) {
637
+ let line = `- ${item.name}`;
638
+ if (item.version) line += ` (v${item.version})`;
639
+ if (item.slashNamespace) line += ` \u2014 \`${item.slashNamespace}\``;
640
+ if (item.whenToUse) line += ` \u2014 ${item.whenToUse}`;
641
+ return line;
642
+ }
643
+ var ALWAYS_ON_RULES = [
644
+ "Do not call every tool by default; pick by the trigger condition above.",
645
+ "For first-attempt failures or simple edits, skip extra tools."
646
+ ];
647
+ function buildConditionalRules(installedIds) {
648
+ const out = [];
649
+ const planners = [];
650
+ if (installedIds.has("sequential-thinking")) planners.push("sequential-thinking");
651
+ if (installedIds.has("claude-mem")) planners.push("claude-mem `make-plan`");
652
+ if (planners.length > 0) {
653
+ out.push(`For complex / risky changes, plan first (${planners.join(" or ")}).`);
654
+ }
655
+ if (installedIds.has("context7")) {
656
+ out.push("For library / SDK lookups, prefer context7 over web search.");
657
+ }
658
+ if (installedIds.has("chrome-devtools-mcp")) {
659
+ out.push("For browser-rendered behavior, verify in chrome-devtools-mcp instead of guessing.");
660
+ }
661
+ return out;
662
+ }
663
+ function renderBlock(items) {
664
+ const installedIds = new Set(items.map((i) => i.id));
665
+ const rules = [...ALWAYS_ON_RULES, ...buildConditionalRules(installedIds)];
666
+ return [
667
+ BEGIN_MARKER,
668
+ "## Tool Usage",
669
+ "",
670
+ "Available tools/plugins:",
671
+ ...items.map(renderItemLine),
672
+ "",
673
+ "Rules:",
674
+ ...rules.map((r) => `- ${r}`),
675
+ "",
676
+ "Run `npx @curdx/flow` to install / update / uninstall.",
677
+ END_MARKER
678
+ ].join("\n");
679
+ }
680
+ function withEol(s, eol) {
681
+ return eol === "\n" ? s : s.split("\n").join(eol);
682
+ }
683
+ function ensureSingleTrailingNewline(s, eol) {
684
+ if (s.length === 0) return s;
685
+ return s.replace(/[\r\n]+$/, "") + eol;
686
+ }
687
+ function upsertBlock(existing, blockBody, eol) {
688
+ const block = withEol(blockBody, eol);
689
+ if (BLOCK_RE.test(existing)) {
690
+ return existing.replace(BLOCK_RE, block);
691
+ }
692
+ if (existing.length === 0) {
693
+ return block + eol;
694
+ }
695
+ const trimmed = existing.replace(/[\r\n\s]+$/, "");
696
+ return trimmed + eol + eol + block + eol;
697
+ }
698
+ function removeBlock(existing, eol) {
699
+ if (!BLOCK_RE.test(existing)) return existing;
700
+ let next = existing.replace(BLOCK_RE, "");
701
+ const tripleEol = new RegExp(`(?:\\r?\\n){3,}`, "g");
702
+ next = next.replace(tripleEol, eol + eol);
703
+ if (next.replace(/[\s\r\n]/g, "").length === 0) return "";
704
+ return ensureSingleTrailingNewline(next, eol);
705
+ }
706
+ async function pkgToItem(pkg) {
707
+ let version;
708
+ if (pkg.installedVersion) {
709
+ const v = await pkg.installedVersion();
710
+ if (v) version = v;
711
+ }
712
+ return {
713
+ id: pkg.id,
714
+ name: pkg.name,
715
+ type: pkg.type,
716
+ version,
717
+ whenToUse: pkg.whenToUse,
718
+ slashNamespace: pkg.slashNamespace
719
+ };
720
+ }
721
+ async function collectInstalledItems() {
722
+ await Promise.all([listPlugins(true), listMcp(true)]);
723
+ const items = [];
724
+ for (const pkg of PKGS) {
725
+ if (await pkg.isInstalled()) {
726
+ items.push(await pkgToItem(pkg));
727
+ }
728
+ }
729
+ items.sort((a, b) => {
730
+ if (a.type !== b.type) return a.type === "plugin" ? -1 : 1;
731
+ return a.name.localeCompare(b.name);
732
+ });
733
+ return items;
734
+ }
735
+ async function syncClaudeMd(opts) {
736
+ const file = claudeMdPath();
737
+ if (opts?.skip) return { status: "skipped", path: file };
738
+ try {
739
+ const items = await collectInstalledItems();
740
+ let existing = "";
741
+ let existed = true;
742
+ try {
743
+ existing = await fs2.readFile(file, "utf8");
744
+ } catch (err) {
745
+ if (err.code === "ENOENT") {
746
+ existed = false;
747
+ } else {
748
+ throw err;
749
+ }
750
+ }
751
+ const eol = existing.includes("\r\n") ? "\r\n" : "\n";
752
+ const hadBlock = BLOCK_RE.test(existing);
753
+ let next;
754
+ if (items.length === 0) {
755
+ if (!hadBlock) {
756
+ return { status: "unchanged", path: file };
757
+ }
758
+ next = removeBlock(existing, eol);
759
+ } else {
760
+ next = upsertBlock(existing, renderBlock(items), eol);
761
+ }
762
+ if (next === existing) {
763
+ return { status: "unchanged", path: file };
764
+ }
765
+ await fs2.mkdir(path2.dirname(file), { recursive: true });
766
+ const tmp = `${file}.tmp.${process.pid}`;
767
+ await fs2.writeFile(tmp, next, "utf8");
768
+ await fs2.rename(tmp, file);
769
+ if (!existed) return { status: "created", path: file };
770
+ if (hadBlock && items.length === 0) return { status: "removed", path: file };
771
+ return { status: "updated", path: file };
772
+ } catch (err) {
773
+ const msg = err instanceof Error ? err.message : String(err);
774
+ return { status: "failed", path: file, error: msg };
775
+ }
776
+ }
777
+ async function syncFromState(opts) {
778
+ if (opts?.skip) {
779
+ p3.log.info(t("claudeMd.skipped"));
780
+ return;
781
+ }
782
+ const sp = p3.spinner();
783
+ sp.start(t("claudeMd.syncing"));
784
+ const r = await syncClaudeMd();
785
+ switch (r.status) {
786
+ case "skipped":
787
+ sp.stop(t("claudeMd.skipped"));
788
+ return;
789
+ case "unchanged":
790
+ sp.stop(t("claudeMd.unchanged"));
791
+ return;
792
+ case "created":
793
+ case "updated":
794
+ sp.stop(t("claudeMd.synced", { path: r.path }));
795
+ return;
796
+ case "removed":
797
+ sp.stop(t("claudeMd.removed"));
798
+ return;
799
+ case "failed":
800
+ sp.stop(t("claudeMd.failed", { error: r.error ?? "unknown" }));
801
+ return;
802
+ }
599
803
  }
600
804
 
601
805
  // src/flows/install.ts
@@ -634,13 +838,13 @@ async function selectInteractive(states) {
634
838
  const s = states.get(pkg.id);
635
839
  return s.kind === "not_installed" || s.kind === "update_available";
636
840
  }).map((pkg) => pkg.id);
637
- const picked = await p3.multiselect({
841
+ const picked = await p4.multiselect({
638
842
  message: t("install.selectPrompt"),
639
843
  options,
640
844
  initialValues,
641
845
  required: false
642
846
  });
643
- if (p3.isCancel(picked)) return null;
847
+ if (p4.isCancel(picked)) return null;
644
848
  return picked.map((id) => findPkg(id)).filter((x2) => Boolean(x2));
645
849
  }
646
850
  function selectFromIds(opts) {
@@ -650,7 +854,7 @@ function selectFromIds(opts) {
650
854
  for (const id of opts.ids) {
651
855
  const pkg = findPkg(id);
652
856
  if (pkg) found.push(pkg);
653
- else p3.log.warn(`Unknown id: ${id}`);
857
+ else p4.log.warn(`Unknown id: ${id}`);
654
858
  }
655
859
  return found;
656
860
  }
@@ -662,11 +866,11 @@ async function runOne(pkg, state, opts) {
662
866
  mode = "update";
663
867
  } else {
664
868
  if (!opts.yes) {
665
- const ans = await p3.confirm({
869
+ const ans = await p4.confirm({
666
870
  message: t("install.confirmReinstall", { name: pkg.name }),
667
871
  initialValue: false
668
872
  });
669
- if (p3.isCancel(ans) || ans === false) {
873
+ if (p4.isCancel(ans) || ans === false) {
670
874
  return { id: pkg.id, status: "skip", message: t("install.skippedReinstall", { name: pkg.name }) };
671
875
  }
672
876
  }
@@ -675,7 +879,7 @@ async function runOne(pkg, state, opts) {
675
879
  if (pkg.prereqCheck) {
676
880
  const r = await pkg.prereqCheck(t);
677
881
  if (!r.ok) {
678
- p3.log.warn(t("install.prereqFail", { name: pkg.name, reason: r.reason }));
882
+ p4.log.warn(t("install.prereqFail", { name: pkg.name, reason: r.reason }));
679
883
  return { id: pkg.id, status: "skip", message: r.reason };
680
884
  }
681
885
  }
@@ -690,30 +894,30 @@ async function runOne(pkg, state, opts) {
690
894
  if (mode === "update" && state.kind === "update_available") {
691
895
  titleVars["version"] = state.latest;
692
896
  }
693
- const log4 = p3.taskLog({ title: t(titleKey, titleVars) });
897
+ const log5 = p4.taskLog({ title: t(titleKey, titleVars) });
694
898
  try {
695
899
  if (mode === "reinstall") {
696
- log4.message(t("reinstall.uninstalling"));
697
- await pkg.uninstall({ log: log4, config, t });
698
- log4.message(t("reinstall.installing"));
699
- await pkg.install({ log: log4, config, t });
900
+ log5.message(t("reinstall.uninstalling"));
901
+ await pkg.uninstall({ log: log5, config, t });
902
+ log5.message(t("reinstall.installing"));
903
+ await pkg.install({ log: log5, config, t });
700
904
  } else if (mode === "update") {
701
905
  if (pkg.update) {
702
- await pkg.update({ log: log4, config, t });
906
+ await pkg.update({ log: log5, config, t });
703
907
  } else {
704
- log4.message(t("reinstall.uninstalling"));
705
- await pkg.uninstall({ log: log4, config, t });
706
- log4.message(t("reinstall.installing"));
707
- await pkg.install({ log: log4, config, t });
908
+ log5.message(t("reinstall.uninstalling"));
909
+ await pkg.uninstall({ log: log5, config, t });
910
+ log5.message(t("reinstall.installing"));
911
+ await pkg.install({ log: log5, config, t });
708
912
  }
709
913
  } else {
710
- await pkg.install({ log: log4, config, t });
914
+ await pkg.install({ log: log5, config, t });
711
915
  }
712
- log4.success(t("install.success", { name: pkg.name }));
916
+ log5.success(t("install.success", { name: pkg.name }));
713
917
  return { id: pkg.id, status: "ok" };
714
918
  } catch (err) {
715
919
  const msg = err instanceof Error ? err.message : String(err);
716
- log4.error(`${t("install.failed", { name: pkg.name })}
920
+ log5.error(`${t("install.failed", { name: pkg.name })}
717
921
  ${msg}`);
718
922
  return { id: pkg.id, status: "fail", message: msg };
719
923
  }
@@ -731,7 +935,7 @@ function summarize(results) {
731
935
  ...skip.map((r) => ` ${pc.yellow("-")} ${r.id}${r.message ? pc.dim(` (${r.message})`) : ""}`),
732
936
  ...fail.map((r) => ` ${pc.red("\u2717")} ${r.id}${r.message ? pc.dim(` (${r.message.split("\n")[0]})`) : ""}`)
733
937
  ];
734
- p3.note(lines.join("\n"), t("install.summaryTitle"));
938
+ p4.note(lines.join("\n"), t("install.summaryTitle"));
735
939
  }
736
940
  async function maybeRefreshMarketplaces(opts) {
737
941
  if (opts.noRefresh) return;
@@ -740,7 +944,7 @@ async function maybeRefreshMarketplaces(opts) {
740
944
  if (pkg.marketplaces) for (const n of pkg.marketplaces()) names.add(n);
741
945
  }
742
946
  if (names.size === 0) return;
743
- const sp = p3.spinner();
947
+ const sp = p4.spinner();
744
948
  sp.start(t("marketplace.refreshing"));
745
949
  const refreshed = await refreshMarketplaces([...names]);
746
950
  sp.stop(
@@ -752,28 +956,35 @@ async function installFlow(opts = {}) {
752
956
  const explicit = opts.all || opts.ids && opts.ids.length > 0;
753
957
  const candidates = explicit ? selectFromIds(opts) : [...PKGS];
754
958
  if (candidates.length === 0) {
755
- p3.log.info(t("install.nothingSelected"));
959
+ p4.log.info(t("install.nothingSelected"));
756
960
  return;
757
961
  }
758
962
  const stateMap = /* @__PURE__ */ new Map();
759
- await Promise.all(
760
- candidates.map(async (pkg) => {
761
- stateMap.set(pkg.id, await deriveState(pkg));
762
- })
763
- );
963
+ const sp = p4.spinner();
964
+ sp.start(t("state.checking"));
965
+ try {
966
+ await Promise.all([listPlugins(), listMcp()]);
967
+ await Promise.all(
968
+ candidates.map(async (pkg) => {
969
+ stateMap.set(pkg.id, await deriveState(pkg));
970
+ })
971
+ );
972
+ } finally {
973
+ sp.stop(t("state.checked", { count: candidates.length }));
974
+ }
764
975
  let targets;
765
976
  if (explicit) {
766
977
  targets = candidates;
767
978
  } else {
768
979
  const picked = await selectInteractive(stateMap);
769
980
  if (picked === null) {
770
- p3.cancel(t("app.cancelled"));
981
+ p4.cancel(t("app.cancelled"));
771
982
  return;
772
983
  }
773
984
  targets = picked;
774
985
  }
775
986
  if (targets.length === 0) {
776
- p3.log.info(t("install.nothingSelected"));
987
+ p4.log.info(t("install.nothingSelected"));
777
988
  return;
778
989
  }
779
990
  const results = [];
@@ -782,38 +993,52 @@ async function installFlow(opts = {}) {
782
993
  results.push(await runOne(pkg, state, opts));
783
994
  }
784
995
  summarize(results);
996
+ await syncFromState({ skip: opts.noClaudeMd });
785
997
  }
786
998
 
787
999
  // src/flows/uninstall.ts
788
- import * as p4 from "@clack/prompts";
1000
+ import * as p5 from "@clack/prompts";
789
1001
  import pc2 from "picocolors";
790
1002
  async function getInstalled() {
791
1003
  const states = await Promise.all(PKGS.map(async (pkg) => ({ pkg, installed: await pkg.isInstalled() })));
792
1004
  return states.filter((s) => s.installed).map((s) => s.pkg);
793
1005
  }
1006
+ async function probeInstalled() {
1007
+ const sp = p5.spinner();
1008
+ sp.start(t("state.checking"));
1009
+ try {
1010
+ await Promise.all([listPlugins(), listMcp()]);
1011
+ const installed = await getInstalled();
1012
+ sp.stop(t("state.checked", { count: installed.length }));
1013
+ return installed;
1014
+ } catch (err) {
1015
+ sp.stop(t("state.checked", { count: 0 }));
1016
+ throw err;
1017
+ }
1018
+ }
794
1019
  async function uninstallFlow(opts = {}) {
795
- const installed = await getInstalled();
1020
+ const installed = await probeInstalled();
796
1021
  let targets;
797
1022
  if (opts.ids && opts.ids.length > 0) {
798
1023
  targets = [];
799
1024
  for (const id of opts.ids) {
800
1025
  const pkg = findPkg(id);
801
1026
  if (!pkg) {
802
- p4.log.warn(`Unknown id: ${id}`);
1027
+ p5.log.warn(`Unknown id: ${id}`);
803
1028
  continue;
804
1029
  }
805
1030
  if (!installed.some((x2) => x2.id === pkg.id)) {
806
- p4.log.warn(`${pkg.name}: ${t("pkg.notInstalled")}`);
1031
+ p5.log.warn(`${pkg.name}: ${t("pkg.notInstalled")}`);
807
1032
  continue;
808
1033
  }
809
1034
  targets.push(pkg);
810
1035
  }
811
1036
  } else {
812
1037
  if (installed.length === 0) {
813
- p4.log.info(t("uninstall.noneInstalled"));
1038
+ p5.log.info(t("uninstall.noneInstalled"));
814
1039
  return;
815
1040
  }
816
- const picked = await p4.multiselect({
1041
+ const picked = await p5.multiselect({
817
1042
  message: t("uninstall.selectPrompt"),
818
1043
  options: installed.map((pkg) => ({
819
1044
  value: pkg.id,
@@ -822,62 +1047,76 @@ async function uninstallFlow(opts = {}) {
822
1047
  })),
823
1048
  required: false
824
1049
  });
825
- if (p4.isCancel(picked)) {
826
- p4.cancel(t("app.cancelled"));
1050
+ if (p5.isCancel(picked)) {
1051
+ p5.cancel(t("app.cancelled"));
827
1052
  return;
828
1053
  }
829
1054
  targets = picked.map((id) => findPkg(id)).filter((x2) => Boolean(x2));
830
1055
  }
831
1056
  if (targets.length === 0) {
832
- p4.log.info(t("install.nothingSelected"));
1057
+ p5.log.info(t("install.nothingSelected"));
833
1058
  return;
834
1059
  }
835
1060
  if (!opts.yes) {
836
- const ok2 = await p4.confirm({
1061
+ const ok2 = await p5.confirm({
837
1062
  message: t("uninstall.confirm", { count: targets.length }),
838
1063
  initialValue: false
839
1064
  });
840
- if (p4.isCancel(ok2) || ok2 === false) {
841
- p4.cancel(t("app.cancelled"));
1065
+ if (p5.isCancel(ok2) || ok2 === false) {
1066
+ p5.cancel(t("app.cancelled"));
842
1067
  return;
843
1068
  }
844
1069
  }
845
1070
  const results = [];
846
1071
  for (const pkg of targets) {
847
- const log4 = p4.taskLog({ title: t("uninstall.starting", { name: pkg.name }) });
1072
+ const log5 = p5.taskLog({ title: t("uninstall.starting", { name: pkg.name }) });
848
1073
  try {
849
- await pkg.uninstall({ log: log4, config: {}, t });
850
- log4.success(t("uninstall.success", { name: pkg.name }));
1074
+ await pkg.uninstall({ log: log5, config: {}, t });
1075
+ log5.success(t("uninstall.success", { name: pkg.name }));
851
1076
  results.push({ id: pkg.id, status: "ok" });
852
1077
  } catch (err) {
853
1078
  const msg = err instanceof Error ? err.message : String(err);
854
- log4.error(`${t("uninstall.failed", { name: pkg.name })}
1079
+ log5.error(`${t("uninstall.failed", { name: pkg.name })}
855
1080
  ${msg}`);
856
1081
  results.push({ id: pkg.id, status: "fail", message: msg });
857
1082
  }
858
1083
  }
859
1084
  const ok = results.filter((r) => r.status === "ok").length;
860
1085
  const fail = results.filter((r) => r.status === "fail").length;
861
- p4.note(
1086
+ p5.note(
862
1087
  [
863
1088
  pc2.green(t("install.summaryOk", { count: ok })),
864
1089
  pc2.red(t("install.summaryFail", { count: fail }))
865
1090
  ].join("\n"),
866
1091
  t("install.summaryTitle")
867
1092
  );
1093
+ await syncFromState({ skip: opts.noClaudeMd });
868
1094
  }
869
1095
 
870
1096
  // src/flows/update.ts
871
- import * as p5 from "@clack/prompts";
1097
+ import * as p6 from "@clack/prompts";
872
1098
  import pc3 from "picocolors";
873
1099
  async function getInstalled2() {
874
1100
  const states = await Promise.all(PKGS.map(async (pkg) => ({ pkg, installed: await pkg.isInstalled() })));
875
1101
  return states.filter((s) => s.installed).map((s) => s.pkg);
876
1102
  }
1103
+ async function probeInstalled2() {
1104
+ const sp = p6.spinner();
1105
+ sp.start(t("state.checking"));
1106
+ try {
1107
+ await Promise.all([listPlugins(), listMcp()]);
1108
+ const installed = await getInstalled2();
1109
+ sp.stop(t("state.checked", { count: installed.length }));
1110
+ return installed;
1111
+ } catch (err) {
1112
+ sp.stop(t("state.checked", { count: 0 }));
1113
+ throw err;
1114
+ }
1115
+ }
877
1116
  async function updateFlow(opts = {}) {
878
- const installed = await getInstalled2();
1117
+ const installed = await probeInstalled2();
879
1118
  if (installed.length === 0) {
880
- p5.log.info(t("update.noneInstalled"));
1119
+ p6.log.info(t("update.noneInstalled"));
881
1120
  return;
882
1121
  }
883
1122
  let targets;
@@ -888,17 +1127,17 @@ async function updateFlow(opts = {}) {
888
1127
  for (const id of opts.ids) {
889
1128
  const pkg = findPkg(id);
890
1129
  if (!pkg) {
891
- p5.log.warn(`Unknown id: ${id}`);
1130
+ p6.log.warn(`Unknown id: ${id}`);
892
1131
  continue;
893
1132
  }
894
1133
  if (!installed.some((x2) => x2.id === pkg.id)) {
895
- p5.log.warn(`${pkg.name}: ${t("pkg.notInstalled")}`);
1134
+ p6.log.warn(`${pkg.name}: ${t("pkg.notInstalled")}`);
896
1135
  continue;
897
1136
  }
898
1137
  targets.push(pkg);
899
1138
  }
900
1139
  } else {
901
- const picked = await p5.multiselect({
1140
+ const picked = await p6.multiselect({
902
1141
  message: t("update.selectPrompt"),
903
1142
  options: installed.map((pkg) => ({
904
1143
  value: pkg.id,
@@ -907,41 +1146,41 @@ async function updateFlow(opts = {}) {
907
1146
  })),
908
1147
  required: false
909
1148
  });
910
- if (p5.isCancel(picked)) {
911
- p5.cancel(t("app.cancelled"));
1149
+ if (p6.isCancel(picked)) {
1150
+ p6.cancel(t("app.cancelled"));
912
1151
  return;
913
1152
  }
914
1153
  targets = picked.map((id) => findPkg(id)).filter((x2) => Boolean(x2));
915
1154
  }
916
1155
  if (targets.length === 0) {
917
- p5.log.info(t("install.nothingSelected"));
1156
+ p6.log.info(t("install.nothingSelected"));
918
1157
  return;
919
1158
  }
920
1159
  const results = [];
921
1160
  for (const pkg of targets) {
922
1161
  if (pkg.id === "sequential-thinking") {
923
- p5.log.info(t("update.mcpAutoNote", { name: pkg.name }));
1162
+ p6.log.info(t("update.mcpAutoNote", { name: pkg.name }));
924
1163
  results.push({ id: pkg.id, status: "noop" });
925
1164
  continue;
926
1165
  }
927
1166
  if (pkg.id === "context7") {
928
- p5.log.info(t("update.context7Note"));
1167
+ p6.log.info(t("update.context7Note"));
929
1168
  results.push({ id: pkg.id, status: "noop" });
930
1169
  continue;
931
1170
  }
932
- const log4 = p5.taskLog({ title: t("update.starting", { name: pkg.name }) });
1171
+ const log5 = p6.taskLog({ title: t("update.starting", { name: pkg.name }) });
933
1172
  try {
934
1173
  if (pkg.update) {
935
- await pkg.update({ log: log4, config: {}, t });
1174
+ await pkg.update({ log: log5, config: {}, t });
936
1175
  } else {
937
- await pkg.uninstall({ log: log4, config: {}, t });
938
- await pkg.install({ log: log4, config: {}, t });
1176
+ await pkg.uninstall({ log: log5, config: {}, t });
1177
+ await pkg.install({ log: log5, config: {}, t });
939
1178
  }
940
- log4.success(t("update.success", { name: pkg.name }));
1179
+ log5.success(t("update.success", { name: pkg.name }));
941
1180
  results.push({ id: pkg.id, status: "ok" });
942
1181
  } catch (err) {
943
1182
  const msg = err instanceof Error ? err.message : String(err);
944
- log4.error(`${t("update.failed", { name: pkg.name })}
1183
+ log5.error(`${t("update.failed", { name: pkg.name })}
945
1184
  ${msg}`);
946
1185
  results.push({ id: pkg.id, status: "fail", message: msg });
947
1186
  }
@@ -949,7 +1188,7 @@ ${msg}`);
949
1188
  const ok = results.filter((r) => r.status === "ok").length;
950
1189
  const fail = results.filter((r) => r.status === "fail").length;
951
1190
  const noop = results.filter((r) => r.status === "noop").length;
952
- p5.note(
1191
+ p6.note(
953
1192
  [
954
1193
  pc3.green(t("install.summaryOk", { count: ok })),
955
1194
  pc3.red(t("install.summaryFail", { count: fail })),
@@ -957,10 +1196,11 @@ ${msg}`);
957
1196
  ].join("\n"),
958
1197
  t("install.summaryTitle")
959
1198
  );
1199
+ await syncFromState({ skip: opts.noClaudeMd });
960
1200
  }
961
1201
 
962
1202
  // src/flows/status.ts
963
- import * as p6 from "@clack/prompts";
1203
+ import * as p7 from "@clack/prompts";
964
1204
  import pc4 from "picocolors";
965
1205
  async function statusFlow(opts = {}) {
966
1206
  const states = await Promise.all(
@@ -993,12 +1233,12 @@ async function statusFlow(opts = {}) {
993
1233
  const rows = states.map(
994
1234
  (s) => `${s.name.padEnd(nameW)} ${s.type.padEnd(typeW)} ${s.installed ? pc4.green(`\u2713 ${t("pkg.installed")}`) : pc4.yellow(`\u2717 ${t("pkg.notInstalled")}`)}`
995
1235
  );
996
- p6.note([header, sep, ...rows].join("\n"), t("status.title"));
1236
+ p7.note([header, sep, ...rows].join("\n"), t("status.title"));
997
1237
  }
998
1238
 
999
1239
  // src/ui/menu.ts
1000
1240
  async function mainMenu() {
1001
- const action = await p7.select({
1241
+ const action = await p8.select({
1002
1242
  message: t("menu.title"),
1003
1243
  options: [
1004
1244
  { value: "install", label: t("menu.install") },
@@ -1008,8 +1248,8 @@ async function mainMenu() {
1008
1248
  { value: "exit", label: t("menu.exit") }
1009
1249
  ]
1010
1250
  });
1011
- if (p7.isCancel(action) || action === "exit") {
1012
- p7.cancel(t("app.cancelled"));
1251
+ if (p8.isCancel(action) || action === "exit") {
1252
+ p8.cancel(t("app.cancelled"));
1013
1253
  return;
1014
1254
  }
1015
1255
  switch (action) {
@@ -1032,8 +1272,16 @@ async function mainMenu() {
1032
1272
  function parseLang(v) {
1033
1273
  return v === "zh" || v === "en" ? v : void 0;
1034
1274
  }
1275
+ function noClaudeMdFromArgs(args) {
1276
+ if (args["no-claude-md"]) return true;
1277
+ return Boolean(process.env["CURDX_FLOW_NO_CLAUDE_MD"]);
1278
+ }
1035
1279
  var sharedArgs = {
1036
- lang: { type: "string", description: "Override language: zh or en" }
1280
+ lang: { type: "string", description: "Override language: zh or en" },
1281
+ "no-claude-md": {
1282
+ type: "boolean",
1283
+ description: "Skip syncing the @curdx/flow block in ~/.claude/CLAUDE.md"
1284
+ }
1037
1285
  };
1038
1286
  var installCmd = defineCommand({
1039
1287
  meta: { name: "install", description: "Install, reinstall, or update plugins / MCP servers" },
@@ -1046,15 +1294,16 @@ var installCmd = defineCommand({
1046
1294
  },
1047
1295
  async run({ args }) {
1048
1296
  await initLanguage(parseLang(args.lang));
1049
- p8.intro(t("app.intro"));
1297
+ p9.intro(t("app.intro"));
1050
1298
  const ids = collectPositional(args);
1051
1299
  await installFlow({
1052
1300
  ids,
1053
1301
  all: Boolean(args.all),
1054
1302
  yes: Boolean(args.yes),
1055
- noRefresh: Boolean(args["no-refresh"])
1303
+ noRefresh: Boolean(args["no-refresh"]),
1304
+ noClaudeMd: noClaudeMdFromArgs(args)
1056
1305
  });
1057
- p8.outro(t("app.outro"));
1306
+ p9.outro(t("app.outro"));
1058
1307
  }
1059
1308
  });
1060
1309
  var uninstallCmd = defineCommand({
@@ -1066,10 +1315,14 @@ var uninstallCmd = defineCommand({
1066
1315
  },
1067
1316
  async run({ args }) {
1068
1317
  await initLanguage(parseLang(args.lang));
1069
- p8.intro(t("app.intro"));
1318
+ p9.intro(t("app.intro"));
1070
1319
  const ids = collectPositional(args);
1071
- await uninstallFlow({ ids, yes: Boolean(args.yes) });
1072
- p8.outro(t("app.outro"));
1320
+ await uninstallFlow({
1321
+ ids,
1322
+ yes: Boolean(args.yes),
1323
+ noClaudeMd: noClaudeMdFromArgs(args)
1324
+ });
1325
+ p9.outro(t("app.outro"));
1073
1326
  }
1074
1327
  });
1075
1328
  var updateCmd = defineCommand({
@@ -1081,10 +1334,14 @@ var updateCmd = defineCommand({
1081
1334
  },
1082
1335
  async run({ args }) {
1083
1336
  await initLanguage(parseLang(args.lang));
1084
- p8.intro(t("app.intro"));
1337
+ p9.intro(t("app.intro"));
1085
1338
  const ids = collectPositional(args);
1086
- await updateFlow({ ids, all: Boolean(args.all) });
1087
- p8.outro(t("app.outro"));
1339
+ await updateFlow({
1340
+ ids,
1341
+ all: Boolean(args.all),
1342
+ noClaudeMd: noClaudeMdFromArgs(args)
1343
+ });
1344
+ p9.outro(t("app.outro"));
1088
1345
  }
1089
1346
  });
1090
1347
  var statusCmd = defineCommand({
@@ -1095,16 +1352,16 @@ var statusCmd = defineCommand({
1095
1352
  },
1096
1353
  async run({ args }) {
1097
1354
  await initLanguage(parseLang(args.lang));
1098
- if (!args.json) p8.intro(t("app.intro"));
1355
+ if (!args.json) p9.intro(t("app.intro"));
1099
1356
  await statusFlow({ json: Boolean(args.json) });
1100
- if (!args.json) p8.outro(t("app.outro"));
1357
+ if (!args.json) p9.outro(t("app.outro"));
1101
1358
  }
1102
1359
  });
1103
1360
  var SUBCOMMANDS = /* @__PURE__ */ new Set(["install", "uninstall", "update", "status"]);
1104
1361
  var root = defineCommand({
1105
1362
  meta: {
1106
1363
  name: "@curdx/flow",
1107
- version: "3.2.0",
1364
+ version: "3.3.1",
1108
1365
  description: "Interactive installer for Claude Code plugins and MCP servers"
1109
1366
  },
1110
1367
  args: sharedArgs,
@@ -1139,9 +1396,9 @@ async function runInteractive(argv2) {
1139
1396
  else if (argv2[i]?.startsWith("--lang=")) lang = parseLang(argv2[i].slice("--lang=".length));
1140
1397
  }
1141
1398
  await initLanguage(lang);
1142
- p8.intro(t("app.intro"));
1399
+ p9.intro(t("app.intro"));
1143
1400
  await mainMenu();
1144
- p8.outro(t("app.outro"));
1401
+ p9.outro(t("app.outro"));
1145
1402
  }
1146
1403
  var argv = process.argv.slice(2);
1147
1404
  var first = firstNonFlag(argv);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@curdx/flow",
3
- "version": "3.2.0",
3
+ "version": "3.3.1",
4
4
  "description": "Interactive installer for Claude Code plugins and MCP servers",
5
5
  "type": "module",
6
6
  "bin": "./dist/index.mjs",