@formstr/mcp 0.3.1 → 0.4.0

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
@@ -21,9 +21,27 @@ npx -y @formstr/mcp login
21
21
  ```
22
22
 
23
23
  Subcommands: `formstr-mcp login` · `formstr-mcp whoami` · `formstr-mcp accounts` ·
24
- `formstr-mcp switch <npub>` · `formstr-mcp logout` · `formstr-mcp help` ·
25
- `formstr-mcp` (run the stdio server, the default). Run `formstr-mcp help` (or `-h`) for
26
- the full usage.
24
+ `formstr-mcp switch <npub>` · `formstr-mcp logout` · `formstr-mcp version` ·
25
+ `formstr-mcp help` · `formstr-mcp` (run the stdio server, the default). Run
26
+ `formstr-mcp help` (or `-h`) for the full usage.
27
+
28
+ ## Version & updates
29
+
30
+ `formstr-mcp version` (or `-v` / `--version`) prints the installed version and checks the
31
+ npm registry for a newer release:
32
+
33
+ ```text
34
+ $ formstr-mcp version
35
+ @formstr/mcp 0.4.0
36
+ Update available: 0.5.0 (you have 0.4.0).
37
+ Upgrade: npm install -g @formstr/mcp@latest
38
+ Or just re-run via: npx -y @formstr/mcp@latest
39
+ ```
40
+
41
+ The update check is best-effort — if you're offline or the registry is unreachable it
42
+ prints the installed version and a note, never an error. If you run the server via
43
+ `npx -y @formstr/mcp` you already get the latest published version on each launch; pin a
44
+ version (`@formstr/mcp@0.4.0`) in your host config if you'd rather control upgrades.
27
45
 
28
46
  ## Sign-in
29
47
 
@@ -71,6 +89,82 @@ After `login`, no key belongs in the config:
71
89
  Add `"--allow-writes"` to `args` to enable the gated (destructive/outward) tools, and
72
90
  `"--relays", "wss://a,wss://b"` to override relays.
73
91
 
92
+ ### Passing the ncryptsec passphrase
93
+
94
+ If your active account is an `ncryptsec` key (Create / Import login), the server needs its
95
+ passphrase to unlock at boot. An MCP host spawns the server with stdin wired to the
96
+ JSON-RPC channel, so it **can't prompt** — supply the passphrase through an `"env"` block in
97
+ the server entry of your MCP config (`mcp_config.json`, `claude_desktop_config.json`, Cursor's
98
+ `~/.cursor/mcp.json`, etc.):
99
+
100
+ ```json
101
+ {
102
+ "mcpServers": {
103
+ "formstr": {
104
+ "command": "npx",
105
+ "args": ["-y", "@formstr/mcp"],
106
+ "env": {
107
+ "FORMSTR_MCP_NCRYPTSEC_PASSPHRASE": "your-passphrase-here"
108
+ }
109
+ }
110
+ }
111
+ }
112
+ ```
113
+
114
+ The host hands that value to the server as an environment variable at startup — it never
115
+ enters the chat transcript. Each account has its own passphrase, so this unlocks whichever
116
+ one is **active** (set it with `formstr-mcp switch <npub>`).
117
+
118
+ > **Tip:** prefer not to keep a passphrase in a config file? Use a **NIP-46 (bunker)**
119
+ > account instead — it reconnects from its stored session and needs **no** passphrase, so
120
+ > the config can stay secret-free. Run `formstr-mcp switch <npub>` to a bunker account.
121
+
122
+ ## Using with Ollama (local models)
123
+
124
+ Ollama isn't an MCP client — it just runs the model. To drive this server with a **local**
125
+ model you need an MCP **host** that uses Ollama as its backend. A good, actively-maintained
126
+ option is [**Goose**](https://block.github.io/goose/) (open-source agent by Block; CLI +
127
+ desktop), which has both first-class Ollama support and native stdio MCP extensions.
128
+
129
+ **1. Pull a tool-calling-capable model and start Ollama:**
130
+
131
+ ```bash
132
+ ollama pull qwen2.5 # llama3.1 / 3.2, mistral, … also work — the model MUST support tools
133
+ ollama serve # serves the API on http://localhost:11434
134
+ ```
135
+
136
+ A model **without** tool/function-calling support can chat but can't invoke this server's
137
+ tools (`list_forms`, `create_form`, …), so don't pick one of those.
138
+
139
+ **2. Sign in to formstr once** (stores your key in the keystore):
140
+
141
+ ```bash
142
+ npx -y @formstr/mcp login
143
+ ```
144
+
145
+ **3. Point Goose at Ollama** — `goose configure` → _Configure Providers_ → **Ollama**, then
146
+ enter the host (`http://localhost:11434`) and pick your model.
147
+
148
+ **4. Add this server as an extension** — `goose configure` → _Add Extension_ → **Command-line
149
+ Extension**, then answer the prompts:
150
+
151
+ | Prompt | Value |
152
+ | --------------------- | ------------------------------------------------------------- |
153
+ | Name | `formstr` |
154
+ | Command | `npx -y @formstr/mcp` (add `--allow-writes` to enable writes) |
155
+ | Timeout (secs) | `300` |
156
+ | Environment variables | `FORMSTR_MCP_NCRYPTSEC_PASSPHRASE` = _your passphrase_ |
157
+
158
+ Goose passes that env var to the server when it spawns it (so it unlocks headlessly), and
159
+ saves the extension to `~/.config/goose/config.yaml`. Then just run `goose` (or `goose
160
+ session`) and ask it to work with your forms.
161
+
162
+ > **Bunker accounts need no passphrase** — skip the env-variable step and `formstr-mcp switch
163
+ <npub>` to a NIP-46 account (see the tip above); the extension then stores no secret.
164
+
165
+ The same approach works with any other Ollama-backed MCP host: point it at
166
+ `npx -y @formstr/mcp` and supply the passphrase through that host's env mechanism.
167
+
74
168
  ## Headless / unattended
75
169
 
76
170
  Run `formstr-mcp login` once interactively to populate the keystore, then run the server
package/dist/index.js CHANGED
@@ -24436,8 +24436,21 @@ async function loginNostrConnect(deps, log) {
24436
24436
  perms: NIP46_PERMS
24437
24437
  });
24438
24438
  }
24439
- async function doLogout(signer, pubkey) {
24440
- await signer.logout(pubkey);
24439
+ async function doLogout(signer, target) {
24440
+ if (target) {
24441
+ const match = findAccount(listAccounts(signer), target);
24442
+ if (!match) {
24443
+ throw new Error(
24444
+ `No stored account matching "${target}". Run \`formstr-mcp accounts\` to list them.`
24445
+ );
24446
+ }
24447
+ await signer.logout(match.pubkey);
24448
+ return match;
24449
+ }
24450
+ const active = signer.getActiveAccount();
24451
+ if (!active) return null;
24452
+ await signer.logout(active.pubkey);
24453
+ return active;
24441
24454
  }
24442
24455
  function whoami(signer) {
24443
24456
  return signer.getActiveAccount();
@@ -27480,12 +27493,23 @@ async function unlockNcryptsec(signer, account, deps) {
27480
27493
  }
27481
27494
 
27482
27495
  // src/cli.ts
27483
- var SUBCOMMANDS = /* @__PURE__ */ new Set(["login", "logout", "whoami", "accounts", "switch", "help"]);
27496
+ var SUBCOMMANDS = /* @__PURE__ */ new Set([
27497
+ "login",
27498
+ "logout",
27499
+ "whoami",
27500
+ "accounts",
27501
+ "switch",
27502
+ "help",
27503
+ "version"
27504
+ ]);
27484
27505
  function parseCli(argv) {
27485
27506
  const rest = [...argv];
27486
27507
  if (rest.includes("-h") || rest.includes("--help")) {
27487
27508
  return { command: "help", allowWrites: false };
27488
27509
  }
27510
+ if (rest.includes("-v") || rest.includes("--version")) {
27511
+ return { command: "version", allowWrites: false };
27512
+ }
27489
27513
  let command = "run";
27490
27514
  if (rest[0] && SUBCOMMANDS.has(rest[0])) {
27491
27515
  command = rest.shift();
@@ -27521,11 +27545,12 @@ function helpText() {
27521
27545
  "Commands:",
27522
27546
  " run Run the stdio MCP server (default when no command is given).",
27523
27547
  " login Sign in (create / import / bunker URI / QR) and store the key.",
27524
- " logout [npub] Remove a stored account (defaults to the active one).",
27548
+ " logout [npub|hex] Permanently remove a stored account from the keystore (defaults to active).",
27525
27549
  " whoami Print the active account.",
27526
27550
  " accounts List stored accounts ('*' marks the active one).",
27527
27551
  " switch <npub> Set the active account (accepts an npub or hex pubkey).",
27528
27552
  " help Show this help (also -h, --help).",
27553
+ " version Print the installed version and check for an update (also -v, --version).",
27529
27554
  "",
27530
27555
  "Flags:",
27531
27556
  " --allow-writes Enable gated write tools (update / delete / share / submit).",
@@ -27534,7 +27559,28 @@ function helpText() {
27534
27559
  "",
27535
27560
  "Env:",
27536
27561
  " FORMSTR_MCP_NCRYPTSEC_PASSPHRASE Unlock the active ncryptsec account at boot.",
27537
- " FORMSTR_MCP_PASSPHRASE Encrypt the keystore file (keychain-less hosts)."
27562
+ " FORMSTR_MCP_PASSPHRASE Encrypt the keystore file (keychain-less hosts).",
27563
+ "",
27564
+ "Setting the passphrase in your MCP host config:",
27565
+ " When an MCP host (Claude Desktop/Code, Cursor, \u2026) spawns the server, stdin is the",
27566
+ " JSON-RPC channel, so it can't prompt for your ncryptsec passphrase. Pass it via an",
27567
+ ' "env" block in the server entry of your MCP config (e.g. mcp_config.json /',
27568
+ " claude_desktop_config.json) so the host hands it to the server at startup:",
27569
+ "",
27570
+ " {",
27571
+ ' "mcpServers": {',
27572
+ ' "formstr": {',
27573
+ ' "command": "npx",',
27574
+ ' "args": ["-y", "@formstr/mcp"],',
27575
+ ' "env": {',
27576
+ ' "FORMSTR_MCP_NCRYPTSEC_PASSPHRASE": "your-passphrase-here"',
27577
+ " }",
27578
+ " }",
27579
+ " }",
27580
+ " }",
27581
+ "",
27582
+ " Tip: a NIP-46 (bunker) account needs no passphrase \u2014 `switch` to one and the config",
27583
+ ' can stay secret-free. Add "--allow-writes" to args to enable gated write tools.'
27538
27584
  ].join("\n");
27539
27585
  }
27540
27586
 
@@ -32418,19 +32464,27 @@ async function fetchMyForms() {
32418
32464
  return summary;
32419
32465
  }).filter((s) => s !== null);
32420
32466
  }
32467
+ var MY_FORMS_LIST_READ_ATTEMPTS = 3;
32468
+ async function readMyFormsEntriesForWrite(signer, userPubkey, relays) {
32469
+ let listEvent = null;
32470
+ for (let attempt = 0; attempt < MY_FORMS_LIST_READ_ATTEMPTS; attempt++) {
32471
+ listEvent = await fetchLatestMyFormsEvent(relays, userPubkey);
32472
+ if (listEvent?.content) break;
32473
+ }
32474
+ if (!listEvent?.content) return [];
32475
+ try {
32476
+ return await decryptListEntries(signer, userPubkey, listEvent.content);
32477
+ } catch (err) {
32478
+ throw new Error(
32479
+ `Could not read your existing forms list to add this form, so it was left unchanged to avoid losing your other forms. The form itself was published \u2014 retry to add it. (${err instanceof Error ? err.message : String(err)})`
32480
+ );
32481
+ }
32482
+ }
32421
32483
  async function appendToMyFormsList(formPubkey, formId, relay, signingKeyHex, viewKeyHex) {
32422
32484
  const signer = await signerManager.getSigner();
32423
32485
  const userPubkey = await signer.getPublicKey();
32424
32486
  const relays = relayManager.getRelaysForModule("forms");
32425
- const existing = await fetchLatestMyFormsEvent(relays, userPubkey);
32426
- let entries = [];
32427
- if (existing?.content) {
32428
- try {
32429
- entries = await decryptListEntries(signer, userPubkey, existing.content);
32430
- } catch {
32431
- entries = [];
32432
- }
32433
- }
32487
+ let entries = await readMyFormsEntriesForWrite(signer, userPubkey, relays);
32434
32488
  entries = entries.map(
32435
32489
  (e) => e[0] === "f" && e.length < 4 ? ["f", e[1] ?? "", e[2] ?? "", ""] : e
32436
32490
  );
@@ -32614,9 +32668,13 @@ async function fetchFormSummaryFromRef(pubkey, formId) {
32614
32668
  };
32615
32669
  }
32616
32670
  async function importForm(summary) {
32617
- const current = await fetchMyForms();
32618
- if (current.some((f) => f.id === summary.id && f.pubkey === summary.pubkey)) return;
32619
- await saveToMyForms([...current, summary]);
32671
+ await appendToMyFormsList(
32672
+ summary.pubkey,
32673
+ summary.id,
32674
+ summary.relay ?? "",
32675
+ summary.signingKey,
32676
+ summary.viewKey
32677
+ );
32620
32678
  }
32621
32679
  function parseFormEvent(event, decryptedRows) {
32622
32680
  const merged = decryptedRows ? [...event.tags, ...decryptedRows] : event.tags;
@@ -34865,6 +34923,25 @@ function computeResults(responses) {
34865
34923
  return { results, totalVotes: totalVoters };
34866
34924
  }
34867
34925
 
34926
+ // ../agent/src/tools/pubkey.ts
34927
+ function normalizePubkey(value) {
34928
+ const trimmed = value.trim();
34929
+ if (!trimmed) return null;
34930
+ if (/^[0-9a-f]{64}$/i.test(trimmed)) return trimmed.toLowerCase();
34931
+ if (trimmed.startsWith("npub1")) {
34932
+ try {
34933
+ const decoded = nip19_exports.decode(trimmed);
34934
+ if (decoded.type === "npub") return decoded.data;
34935
+ } catch {
34936
+ }
34937
+ }
34938
+ return null;
34939
+ }
34940
+ function normalizePubkeyList(values) {
34941
+ if (!Array.isArray(values)) return [];
34942
+ return values.map((v) => typeof v === "string" ? normalizePubkey(v) : null).filter((p) => !!p);
34943
+ }
34944
+
34868
34945
  // ../agent/src/tools/calendar.ts
34869
34946
  var calendarTools = buildCalendarTools();
34870
34947
  function buildCalendarTools() {
@@ -34904,7 +34981,7 @@ function buildCalendarTools() {
34904
34981
  server.registerTool(
34905
34982
  "create_calendar_event",
34906
34983
  {
34907
- description: "Schedule a calendar event. start/end are ISO 8601. Events default to PRIVATE (encrypted) and are linked into a calendar list \u2014 that is the only way they show on calendar.formstr.app, which renders only events referenced in a calendar list. Pass calendarId to choose which calendar; if omitted and the user already has calendars, this tool returns the list so you can ASK the user which one (then re-run with calendarId). Set isPrivate:false for a public, unencrypted event \u2014 note public events do NOT sync to calendar.formstr.app. participants receive NIP-59 invitations.",
34984
+ description: "Schedule a calendar event. start/end are ISO 8601. Events default to PRIVATE (encrypted) and are linked into a calendar list \u2014 that is the only way they show on calendar.formstr.app, which renders only events referenced in a calendar list. Pass calendarId to choose which calendar; if omitted and the user already has calendars, this tool returns the list so you can ASK the user which one (then re-run with calendarId). Set isPrivate:false for a public, unencrypted event \u2014 note public events do NOT sync to calendar.formstr.app. participants (npub or hex) receive NIP-59 invitations.",
34908
34985
  inputSchema: {
34909
34986
  title: external_exports.string(),
34910
34987
  description: external_exports.string().optional(),
@@ -34944,7 +35021,9 @@ function buildCalendarTools() {
34944
35021
  begin,
34945
35022
  end,
34946
35023
  location: args.location,
34947
- participants: args.participants,
35024
+ // Accept npub OR hex for each participant — the wire (["p"] tags, NIP-59
35025
+ // invitation wraps, relay-list query) needs hex, so convert here.
35026
+ participants: normalizePubkeyList(args.participants),
34948
35027
  isPrivate,
34949
35028
  calendarId: args.calendarId,
34950
35029
  rrule: args.rrule,
@@ -34971,7 +35050,8 @@ function buildCalendarTools() {
34971
35050
  inputSchema: { coordinate: external_exports.string() }
34972
35051
  },
34973
35052
  async ({ coordinate }) => {
34974
- const event = await service_exports2.fetchCalendarEventByCoordinate(coordinate);
35053
+ const viewKey = await service_exports2.lookupEventViewKey(coordinate);
35054
+ const event = await service_exports2.fetchCalendarEventByCoordinate(coordinate, viewKey);
34975
35055
  if (!event) return fail(`No event found for ${coordinate}.`, "NOT_FOUND");
34976
35056
  return ok(`Event "${event.title}".`, {
34977
35057
  event: {
@@ -35222,7 +35302,8 @@ function buildCalendarTools() {
35222
35302
  `updates event ${args.coordinate}`
35223
35303
  );
35224
35304
  if (blocked) return blocked;
35225
- const existing = await service_exports2.fetchCalendarEventByCoordinate(args.coordinate);
35305
+ const viewKey = await service_exports2.lookupEventViewKey(args.coordinate);
35306
+ const existing = await service_exports2.fetchCalendarEventByCoordinate(args.coordinate, viewKey);
35226
35307
  if (!existing) return fail(`No event found for ${args.coordinate}.`, "NOT_FOUND");
35227
35308
  const draft = {
35228
35309
  title: args.title ?? existing.title,
@@ -35264,7 +35345,8 @@ function buildCalendarTools() {
35264
35345
  `attaches a form to ${coordinate}`
35265
35346
  );
35266
35347
  if (blocked) return blocked;
35267
- const existing = await service_exports2.fetchCalendarEventByCoordinate(coordinate);
35348
+ const viewKey = await service_exports2.lookupEventViewKey(coordinate);
35349
+ const existing = await service_exports2.fetchCalendarEventByCoordinate(coordinate, viewKey);
35268
35350
  if (!existing) return fail(`No event found for ${coordinate}.`, "NOT_FOUND");
35269
35351
  const draft = {
35270
35352
  title: existing.title,
@@ -35543,23 +35625,6 @@ function buildDriveTools() {
35543
35625
  }
35544
35626
 
35545
35627
  // ../agent/src/tools/shared.ts
35546
- function normalizePubkey(value) {
35547
- const trimmed = value.trim();
35548
- if (!trimmed) return null;
35549
- if (/^[0-9a-f]{64}$/i.test(trimmed)) return trimmed.toLowerCase();
35550
- if (trimmed.startsWith("npub1")) {
35551
- try {
35552
- const decoded = nip19_exports.decode(trimmed);
35553
- if (decoded.type === "npub") return decoded.data;
35554
- } catch {
35555
- }
35556
- }
35557
- return null;
35558
- }
35559
- function normalizePubkeyList(values) {
35560
- if (!Array.isArray(values)) return [];
35561
- return values.map((v) => typeof v === "string" ? normalizePubkey(v) : null).filter((p) => !!p);
35562
- }
35563
35628
  var ANSWER_TYPES = new Set(Object.values(AnswerType));
35564
35629
  var LEGACY_TYPE_ALIASES = {
35565
35630
  multiChoiceGrid: "multipleChoiceGrid" /* multipleChoiceGrid */
@@ -46543,6 +46608,95 @@ async function startStdio(ctx) {
46543
46608
  await server.connect(transport);
46544
46609
  }
46545
46610
 
46611
+ // src/version.ts
46612
+ var import_node_fs2 = require("fs");
46613
+ var import_node_path2 = require("path");
46614
+ var PACKAGE_NAME = "@formstr/mcp";
46615
+ function readInstalledVersion() {
46616
+ try {
46617
+ const pkg = JSON.parse((0, import_node_fs2.readFileSync)((0, import_node_path2.join)(__dirname, "..", "package.json"), "utf8"));
46618
+ return pkg.version ?? "0.0.0";
46619
+ } catch {
46620
+ return "0.0.0";
46621
+ }
46622
+ }
46623
+ function parseSemver(version2) {
46624
+ const cleaned = version2.trim().replace(/^v/i, "").split("+")[0];
46625
+ const [core, pre] = cleaned.split("-", 2);
46626
+ const release = core.split(".").map((n) => Number.parseInt(n, 10) || 0);
46627
+ while (release.length < 3) release.push(0);
46628
+ return { release, prerelease: pre ? pre.split(".") : [] };
46629
+ }
46630
+ function compareIdentifiers(a, b) {
46631
+ const na = /^\d+$/.test(a);
46632
+ const nb = /^\d+$/.test(b);
46633
+ if (na && nb) return Number(a) - Number(b) < 0 ? -1 : Number(a) === Number(b) ? 0 : 1;
46634
+ if (na) return -1;
46635
+ if (nb) return 1;
46636
+ return a < b ? -1 : a > b ? 1 : 0;
46637
+ }
46638
+ function compareVersions(a, b) {
46639
+ const va = parseSemver(a);
46640
+ const vb = parseSemver(b);
46641
+ for (let i4 = 0; i4 < 3; i4++) {
46642
+ if (va.release[i4] !== vb.release[i4]) return va.release[i4] < vb.release[i4] ? -1 : 1;
46643
+ }
46644
+ if (va.prerelease.length === 0 && vb.prerelease.length === 0) return 0;
46645
+ if (va.prerelease.length === 0) return 1;
46646
+ if (vb.prerelease.length === 0) return -1;
46647
+ const len = Math.max(va.prerelease.length, vb.prerelease.length);
46648
+ for (let i4 = 0; i4 < len; i4++) {
46649
+ const ia = va.prerelease[i4];
46650
+ const ib = vb.prerelease[i4];
46651
+ if (ia === void 0) return -1;
46652
+ if (ib === void 0) return 1;
46653
+ const c = compareIdentifiers(ia, ib);
46654
+ if (c !== 0) return c < 0 ? -1 : 1;
46655
+ }
46656
+ return 0;
46657
+ }
46658
+ async function fetchLatestVersion(pkgName, opts = {}) {
46659
+ const fetchImpl = opts.fetchImpl ?? fetch;
46660
+ const timeoutMs = opts.timeoutMs ?? 3e3;
46661
+ const controller = new AbortController();
46662
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
46663
+ try {
46664
+ const res = await fetchImpl(`https://registry.npmjs.org/${pkgName}/latest`, {
46665
+ signal: controller.signal,
46666
+ // The abbreviated metadata document is far smaller than the full packument.
46667
+ headers: { accept: "application/vnd.npm.install-v1+json" }
46668
+ });
46669
+ if (!res.ok) return null;
46670
+ const body = await res.json();
46671
+ return typeof body.version === "string" ? body.version : null;
46672
+ } catch {
46673
+ return null;
46674
+ } finally {
46675
+ clearTimeout(timer);
46676
+ }
46677
+ }
46678
+ function formatVersionReport(installed, latest) {
46679
+ const head = `${PACKAGE_NAME} ${installed}`;
46680
+ if (!latest) {
46681
+ return `${head}
46682
+ (could not check for updates \u2014 offline or registry unreachable)`;
46683
+ }
46684
+ const cmp = compareVersions(installed, latest);
46685
+ if (cmp < 0) {
46686
+ return [
46687
+ head,
46688
+ `Update available: ${latest} (you have ${installed}).`,
46689
+ `Upgrade: npm install -g ${PACKAGE_NAME}@latest`,
46690
+ `Or just re-run via: npx -y ${PACKAGE_NAME}@latest`
46691
+ ].join("\n");
46692
+ }
46693
+ if (cmp > 0) {
46694
+ return `${head}
46695
+ (ahead of the latest published release ${latest})`;
46696
+ }
46697
+ return `${head} (latest)`;
46698
+ }
46699
+
46546
46700
  // src/index.ts
46547
46701
  async function runServer(cli) {
46548
46702
  const cfg = resolveConfig(cli, process.env);
@@ -46593,8 +46747,10 @@ async function main() {
46593
46747
  await runLogin(cli);
46594
46748
  return cli.command;
46595
46749
  case "logout": {
46596
- await doLogout(await buildMcpSigner(), cli.account);
46597
- console.error("formstr-mcp: signed out.");
46750
+ const removed = await doLogout(await buildMcpSigner(), cli.target ?? cli.account);
46751
+ console.error(
46752
+ removed ? `formstr-mcp: removed ${removed.npub} (${removed.method}).` : "formstr-mcp: no account to remove."
46753
+ );
46598
46754
  return cli.command;
46599
46755
  }
46600
46756
  case "whoami": {
@@ -46631,6 +46787,12 @@ async function main() {
46631
46787
  case "help":
46632
46788
  console.error(helpText());
46633
46789
  return cli.command;
46790
+ case "version": {
46791
+ const installed = readInstalledVersion();
46792
+ const latest = await fetchLatestVersion(PACKAGE_NAME);
46793
+ console.log(formatVersionReport(installed, latest));
46794
+ return cli.command;
46795
+ }
46634
46796
  case "run":
46635
46797
  default:
46636
46798
  await runServer(cli);