mnemonad-cli 0.2.0 → 0.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/README.md CHANGED
@@ -15,16 +15,30 @@ signer's own wallet key.
15
15
  ### From npm (recommended)
16
16
 
17
17
  ```bash
18
- npm install -g mnemonad-cli
18
+ npm install -g mnemonad-cli@latest
19
19
  mnemonad --help
20
20
  ```
21
21
 
22
- To pick up a new release later:
22
+ To pick up a new release later, prefer reinstalling `@latest` over `npm update -g` — `update`
23
+ has known quirks where it doesn't reliably bump an already-installed global package even when
24
+ a newer version is published:
23
25
 
24
26
  ```bash
25
- npm update -g mnemonad-cli
27
+ npm install -g mnemonad-cli@latest
28
+ mnemonad --help # banner shows the version — confirm it actually moved
26
29
  ```
27
30
 
31
+ If it still reports the old version, you likely have two installs shadowing each other on
32
+ `PATH` (common with nvm, after switching Node versions):
33
+
34
+ ```bash
35
+ which mnemonad
36
+ npm ls -g mnemonad-cli
37
+ ```
38
+
39
+ If `which mnemonad` doesn't resolve into the `npm root -g` you just installed into, remove
40
+ the stale one — `npm uninstall -g mnemonad-cli`, then reinstall.
41
+
28
42
  ### From this repo (for development)
29
43
 
30
44
  This package lives in the monorepo alongside `mnemonad` and `monadsync`, linked through a
@@ -63,6 +77,8 @@ mnemonad diff <stream-id> <path> [options] List files added/modified/deleted
63
77
  mnemonad watch <stream-id> <path> [options] Watch folder: auto-push on changes, auto-pull on remote updates
64
78
  mnemonad compact <stream-id> <path> [options] Truncate old history and push folder as a fresh snapshot
65
79
  mnemonad burn <stream-id> --yes [options] Permanently destroy a stream — irreversible
80
+ mnemonad index <path> [options] Build/update a local vector-search index (optional)
81
+ mnemonad search <path> <query> [options] Search a folder's vector index
66
82
  ```
67
83
 
68
84
  `<path>` is always required for any command that touches a folder — there is no need to
@@ -88,9 +104,11 @@ Every command prints the installed CLI version as its first line of output.
88
104
  | `--chain <name>` | Chain: `testnet`, `mainnet`, `local` (default: `testnet`) |
89
105
  | `--rpc-url <url>` | Override the resolved RPC endpoint (e.g. a local Hardhat node) |
90
106
  | `--contract-address <addr>` | Registry address to use instead of the chain's known deployment (required with `--chain local`) |
91
- | `--gateway-url <url>` | IPFS gateway for reading externally-offloaded items (default: `https://gateway.pinata.cloud/ipfs/`, or `MNEMONAD_IPFS_GATEWAY` env var) |
107
+ | `--gateway-url <url>` | IPFS gateway for reading externally-offloaded items (default: this project's Pinata dedicated gateway, or `MNEMONAD_IPFS_GATEWAY` env var) |
92
108
  | `--key <privkey>` | Monad private key (or set `MNEMONAD_KEY` env var) |
93
109
  | `--phrase <mnemonic>` | Mnemonic phrase instead of a raw key |
110
+ | `--passkey` | Sign with a passkey instead: opens the explorer in your browser to sign in with WebAuthn, then relays signing requests to that tab for this command. Interactive only — not for scripts/CI. Cannot be combined with `--key`/`--phrase` |
111
+ | `--auth-origin <url>` | Explorer deployment `--passkey` opens (default: this project's explorer deployment, or `MNEMONAD_AUTH_ORIGIN` env var) |
94
112
  | `--version <n>` | Version to restore or compare against (`pull`, `diff`, `info`; default: latest) |
95
113
  | `--exclude <p1,p2>` | Extra exclude patterns (comma-separated) |
96
114
  | `--no-compress` | Disable gzip compression |
@@ -100,11 +118,19 @@ Every command prints the installed CLI version as its first line of output.
100
118
  | `--pinata-jwt <jwt>` | Pinata JWT for IPFS offload of large files (or `PINATA_JWT` env var) |
101
119
  | `--manifest` | Write/use `.mnemonad` manifest for faster change detection. With no stream-id given, every command recovers it from an existing `.mnemonad` in the folder instead of requiring it explicitly |
102
120
  | `--force-snapshot` | Push a full snapshot regardless of prior history (repairs a corrupt stream) |
121
+ | `--detach` | `push`: build the next version here, send it from a background process, and return right away. Existing streams only. See "Push in the background" below |
122
+ | `--index` | `push`/`watch`: update the folder's search index before every push (takes `--db`/`--model`/`--chunk-size`/`--chunk-overlap`); if indexing fails, nothing is pushed |
103
123
  | `--poll-interval <s>` | `watch`: seconds between remote version checks (default: `2`) |
104
124
  | `--debounce <ms>` | `watch`: quiet period in ms before pushing after a local change (default: `1000`) |
105
125
  | `--push-only` | `watch`: disable auto-pull |
106
126
  | `--pull-only` | `watch`: disable auto-push |
107
127
  | `--yes` | Required by `burn` — explicit opt-in for an irreversible action, never assumed |
128
+ | `--db <name>` | `index`/`search`: vector-index filename (default: `search_index.db`) |
129
+ | `--model <name>` | `index` / `--index`: embedding model — `potion` (built in, the default for a new index) or `minilm` (needs `npm install -g mnemonad-search-transformers`), or a full model id. Without it, an existing index keeps its own model; `search` always uses the index's model |
130
+ | `--rebuild` | `index` / `--index`: start the index over — the way to switch models (the next push uploads the whole index again) |
131
+ | `--chunk-size <n>` | `index`: target characters per chunk (default: `1000`) |
132
+ | `--chunk-overlap <n>` | `index`: characters of overlap between chunks (default: `150`) |
133
+ | `--limit <n>`, `-k <n>` | `search`: number of results (default: `5`) |
108
134
  | `--help` | Show help |
109
135
 
110
136
  ### Authentication
@@ -125,6 +151,21 @@ mnemonad push ~/my-data
125
151
  A mnemonic works instead of a raw key: `--phrase "word1 word2 ..."`. `--key` and
126
152
  `MNEMONAD_KEY` both take precedence over `--phrase`.
127
153
 
154
+ Or sign in with a passkey instead of any key at all:
155
+
156
+ ```bash
157
+ mnemonad push ~/my-data --passkey
158
+ ```
159
+
160
+ This opens the explorer's `/cli-auth` page in your default browser to complete a WebAuthn
161
+ sign-in — no key ever touches this machine's disk or environment, only signatures relayed
162
+ back for the duration of that one command. It needs a passkey already registered through the
163
+ explorer (`--passkey` only signs in, it doesn't create one), a browser to complete the
164
+ prompt in, and someone available to click through it — so it's a good fit for a human running
165
+ a command by hand, not for scripts or CI (use `--key`/`MNEMONAD_KEY` there instead). See
166
+ [`docs/wallet/passkey-accounts.md`](../docs/wallet/passkey-accounts.md) for how the account
167
+ is derived and what this flow does and doesn't send anywhere.
168
+
128
169
  `pull`, `info` and `diff` work without a key for public (unencrypted) streams. A key is
129
170
  required for encrypted streams that use wallet-based encryption (not needed for
130
171
  password-based encryption — see below), and for any `push`.
@@ -309,6 +350,48 @@ mnemonad burn 123
309
350
  mnemonad burn 123 --yes
310
351
  ```
311
352
 
353
+ ### Push in the background
354
+
355
+ A push spends most of its time waiting for the chain. `--detach` does everything that can
356
+ fail for your own reasons first, here and now: the owner check, unlocking, `--index`, "no
357
+ changes", and the balance against the estimated cost. Then it hands only the sending to a
358
+ background process and returns:
359
+
360
+ ```bash
361
+ mnemonad push ~/my-data --manifest --detach
362
+ # version 8 prepared (diff, gzip compressed, 2.1 KB)
363
+ # estimated cost: 0.0041 MON
364
+ # stream: 0x68d9…
365
+ # sending in the background: job 8f3c1a2b (pid 41210)
366
+ # log: ~/.cache/mnemonad/jobs/8f3c1a2b/push.log
367
+ # check: mnemonad info ~/my-data --manifest
368
+ ```
369
+
370
+ - **The version is frozen when the command runs.** Edits made after it returns go into the
371
+ next push, not this one.
372
+ - **Existing streams only.** It needs a stream id, or `--manifest` on a folder that has a
373
+ `.mnemonad`. Create a stream with a normal push first. `--passkey` can't be used: the browser
374
+ tab that signs for it closes when the command exits.
375
+ - **The next command waits for it.** A `push`, `pull`, `compact` or `watch` of the same stream
376
+ waits until the background push has finished, so nothing builds on, or restores, the version
377
+ before it. A push from the same wallet to another stream waits too, so the two don't race for
378
+ one nonce. `diff` only notes that a push is still sending.
379
+ - **Results:** `mnemonad info <path>` shows the last background push for the stream, and when
380
+ it failed, why. If one fails, the next command on that stream also prints a warning, once.
381
+ - **Nothing half-lands.** The background process refuses to send a version whose base is gone
382
+ (the stream changed after it was prepared), and the contract rejects any write that doesn't
383
+ land at the index it names. A failed background push leaves the stream as it was. Push again.
384
+ - **Secrets stay in memory.** The key, the password and a Pinata JWT are handed to the
385
+ background process over a pipe. They are never written to the job folder, the log, or the
386
+ process list.
387
+ - If the background process is killed mid-send, the job is reported as failed the next time a
388
+ command looks at it. A large push that was staged over several transactions, or paid for an
389
+ IPFS upload, can't be resumed. A paid but unused upload can be refunded after the gateway's
390
+ time limit, with the SDK's `MnemonadUploadGateway.refund()` (the CLI has no command for it).
391
+
392
+ Jobs live in `~/.cache/mnemonad/jobs/` (set `MNEMONAD_JOBS_DIR` to move them). Finished ones are
393
+ removed after 14 days.
394
+
312
395
  ### Repair a corrupt stream with a force snapshot
313
396
 
314
397
  If a diff patch was pushed against a stale base (e.g. a race condition in `watch`),
@@ -320,6 +403,63 @@ mnemonad push 123 ~/my-data --force-snapshot
320
403
  # restore() will recover from this snapshot, skipping any corrupt diffs before it
321
404
  ```
322
405
 
406
+ ### Vector search (optional)
407
+
408
+ Builds a local, plain (non-dot-prefixed) `search_index.db` at the folder root, using a local
409
+ embedding model — no API key, nothing sent anywhere. Because it's a plain file, it rides
410
+ through the next `push` like anything else in the folder, so anyone who later `pull`s the
411
+ stream gets a ready-to-search index with no re-embedding of their own.
412
+
413
+ ```bash
414
+ mnemonad index ~/my-data
415
+ # embeds every changed text file since the last run (skips unchanged ones); binary files
416
+ # are skipped entirely
417
+
418
+ mnemonad search ~/my-data "how does chunking work"
419
+ # 1. chunks.txt (chunk 0, distance 0.5838)
420
+ # one byte slips inward the boundaries hold their ground only one chunk moves
421
+
422
+ mnemonad push ~/my-data
423
+ # search_index.db is included automatically — it's just a file in the folder
424
+ ```
425
+
426
+ The built-in model is `minishlab/potion-base-8M`: static embeddings in plain JS, no native
427
+ code, a 31 MB download on first use, and a whole folder embedded in milliseconds. For better
428
+ results on paraphrased queries, MiniLM is available as an optional extension (about 450 MB
429
+ installed, most of it ONNX Runtime):
430
+
431
+ ```bash
432
+ npm install -g mnemonad-search-transformers
433
+ mnemonad index ~/my-data --model minilm
434
+ ```
435
+
436
+ An index keeps the model it was built with, and `search` always uses it. To switch an existing
437
+ index to another model, add `--rebuild`. See `docs/search.md` in the repository for how the two
438
+ compare on this project's own data.
439
+
440
+ `push` can also keep the index current for you: `--index` runs the same update right before pushing,
441
+ with the push's own `--exclude`, so the index always covers exactly what the push uploads.
442
+ If indexing fails (the first-time model download, say), nothing is pushed:
443
+
444
+ ```bash
445
+ mnemonad push ~/my-data --index
446
+ # updating search index...
447
+ # index: 1 file(s) re-embedded, 0 removed (3 chunks)
448
+
449
+ mnemonad watch <stream-id> ~/my-data --index
450
+ # re-indexes before every automatic push; the model loads once for the whole session
451
+ ```
452
+
453
+ A plain `push` of a folder that has an index checks it first. It doesn't load the model and
454
+ doesn't touch the file. If files changed since the index was built, it says so and suggests
455
+ `--index`, because a stale index would keep returning the old text's passages.
456
+
457
+ See [`../docs/search.md`](../docs/search.md) for the whole process, and why the index stays
458
+ diff-friendly on repeated pushes.
459
+
460
+ A pushed index also turns on a search box in the explorer's file browser for that stream.
461
+ The search runs in the browser on the same file and gives the same ranking as `mnemonad search`.
462
+
323
463
  ## End-to-end workflow
324
464
 
325
465
  Create a stream, verify what was stored, then roll back to an earlier version:
@@ -368,10 +508,13 @@ mnemonad pull 123 ~/my-data-v1 --version 1
368
508
  the on-chain version.
369
509
  5. **watch** — combines push and pull in a loop. A filesystem watcher triggers a debounced
370
510
  push on local changes. A poll interval checks the remote stream for new versions and
371
- pulls them if found. Push and pull never run concurrently. Monad's registry has no
372
- "expected prior length" guard on push, so `watch` re-checks the remote length itself
373
- right before pushing and resyncs if it has moved — a best-effort narrowing of the race
374
- window, not a hard guarantee.
511
+ pulls them if found. Push and pull never run concurrently. The contract *does* check an
512
+ `expectedLength` atomically on every write, so two writers can never land at the same
513
+ index — one just reverts. What it can't check is content: it stores opaque bytes, with no
514
+ way to verify a diff patch is actually consistent with the real previous item. `watch`
515
+ re-checks the remote length and resyncs before pushing specifically to narrow that
516
+ separate window — a best-effort narrowing, not a hard guarantee, since only an atomic
517
+ on-chain check (which it already has, for the index) could close it outright.
375
518
  6. **compact** — drops all existing history on-chain and pushes the current folder as a
376
519
  single fresh full snapshot, speeding up future replay after many incremental syncs.
377
520
 
@@ -379,7 +522,8 @@ mnemonad pull 123 ~/my-data-v1 --version 1
379
522
 
380
523
  The following are always excluded from snapshots: `node_modules`, `.git`, `.env`,
381
524
  `.DS_Store`, `.mnemonad`, `.claude`, `pnpm-lock.yaml`, `package-lock.json`. Add more with
382
- `--exclude`.
525
+ `--exclude`. `search_index.db` (see "Vector search" above) is deliberately **not** on this
526
+ list — it's a plain file, meant to be pushed like any other.
383
527
 
384
528
  ## Dependencies
385
529
 
@@ -387,4 +531,7 @@ The following are always excluded from snapshots: `node_modules`, `.git`, `.env`
387
531
  |---|---|
388
532
  | `mnemonad` | On-chain stream primitive (this repo's `js/`) |
389
533
  | `monadsync` | Folder-diff / snapshot layer on top of `mnemonad` (this repo's `monadsync/`) |
534
+ | `better-sqlite3`, `@sqliteai/sqlite-vector` | The search index: SQLite with vector search (`lib/search/`) |
535
+ | `@huggingface/tokenizers` | Tokenizer for the built-in embedding model (pure JS) |
536
+ | `mnemonad-search-transformers` | *Not a dependency* — the optional MiniLM extension, installed separately and found at runtime |
390
537
  | `viem` | Monad client / key management |
package/bin/mnemonad.js CHANGED
@@ -17,8 +17,10 @@ import { readFileSync } from 'node:fs';
17
17
  import { fileURLToPath } from 'node:url';
18
18
  import { dirname, join } from 'node:path';
19
19
 
20
- import { push, pull, info, diff, watch, compact, burn } from '../lib/commands/index.js';
20
+ import { push, pull, info, diff, watch, compact, burn, buildIndex, search } from '../lib/commands/index.js';
21
21
  import { parseQualifiedStreamId } from '../lib/commands/shared.js';
22
+ import { closePasskeyBridge } from '../lib/passkeyBridge.js';
23
+ import { runPushJob } from '../lib/jobs.js';
22
24
  import defaultConfig from '../mnemonad.config.js';
23
25
 
24
26
  const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
@@ -40,7 +42,7 @@ const CONFIG = defaultConfig || {};
40
42
  // Same default the explorer dApp uses (explorer/src/mnemonad/presignProvider.js) — a
41
43
  // public read gateway so pulling/diffing/inspecting a stream with IPFS-offloaded items
42
44
  // works out of the box, with no setup, for anyone who didn't push it themselves.
43
- const DEFAULT_IPFS_GATEWAY = CONFIG.gatewayUrl || 'https://gateway.pinata.cloud/ipfs/';
45
+ const DEFAULT_IPFS_GATEWAY = CONFIG.gatewayUrl || 'https://azure-casual-firefly-850.mypinata.cloud/ipfs/';
44
46
 
45
47
  // The deployed presign server (presign-server/) this project runs by default — see its own
46
48
  // README for what it does. Pushing an item too big to fit on-chain uses this automatically
@@ -48,6 +50,10 @@ const DEFAULT_IPFS_GATEWAY = CONFIG.gatewayUrl || 'https://gateway.pinata.cloud/
48
50
  // alternative for anyone who'd rather supply their own.
49
51
  const DEFAULT_PRESIGN_URL = CONFIG.presignUrl || null;
50
52
 
53
+ // See mnemonad.config.js's own comment on why this has to be a real deployed domain, not a
54
+ // placeholder — --passkey opens it to run the WebAuthn ceremony there.
55
+ const DEFAULT_AUTH_ORIGIN = CONFIG.authOrigin || 'https://mnemonad.vercel.app';
56
+
51
57
  const USAGE = `mnemonad v${VERSION} — sync local folders to a versioned, diffed stream on Monad
52
58
 
53
59
  Usage:
@@ -58,6 +64,9 @@ Usage:
58
64
  mnemonad watch <stream-id> <path> [options] Watch folder and auto-push/pull
59
65
  mnemonad compact <stream-id> <path> [options] Truncate old history, push a fresh snapshot
60
66
  mnemonad burn <stream-id> --yes [options] Permanently destroy a stream (irreversible)
67
+ mnemonad index <path> [options] Build/update a local vector-search index
68
+ for the folder (optional; see below)
69
+ mnemonad search <path> <query> [options] Search a folder's vector index
61
70
 
62
71
  <path> is always required wherever a command touches a folder — the CLI never assumes the
63
72
  current directory. The exceptions are \`mnemonad info\` with no stream-id (just checks your
@@ -73,11 +82,17 @@ Options:
73
82
  --contract-address <addr> Registry address to use instead of the chain's known deployment
74
83
  (required with --chain local)
75
84
  --gateway-url <url> IPFS gateway for reading externally-offloaded items
76
- (default: https://gateway.pinata.cloud/ipfs/, or MNEMONAD_IPFS_GATEWAY env var)
85
+ (default: this project's Pinata dedicated gateway, or MNEMONAD_IPFS_GATEWAY env var)
77
86
  --presign-url <url> Presign server for paid IPFS uploads with no Pinata credential
78
87
  (default: the deployed server in mnemonad.config.js, or MNEMONAD_PRESIGN_URL env var)
79
88
  --key <privkey> Monad private key (or set MNEMONAD_KEY env var)
80
89
  --phrase <mnemonic> Mnemonic phrase
90
+ --passkey Sign with a passkey instead of --key/--phrase: opens the explorer
91
+ in your browser to sign in with WebAuthn, then relays signing
92
+ requests to that tab for this command. Interactive only — not for
93
+ scripts/CI. See docs/wallet/passkey-accounts.md
94
+ --auth-origin <url> Explorer deployment --passkey opens (default: this project's
95
+ explorer deployment, or MNEMONAD_AUTH_ORIGIN env var)
81
96
  --version <n> Version to restore/compare (pull, diff, info; default: latest)
82
97
  --exclude <p1,p2> Extra exclude patterns (comma-separated)
83
98
  --no-compress Disable gzip compression
@@ -90,12 +105,39 @@ Options:
90
105
  With no stream-id given, every command recovers it from an
91
106
  existing .mnemonad in <path> instead of requiring it explicitly
92
107
  --force-snapshot Push a full snapshot regardless of prior history (repairs corrupt streams)
108
+ --detach push: build the next version here, send it from a background process,
109
+ and return right away. Existing streams only (an id, or --manifest).
110
+ Later pushes and pulls of that stream wait for it; \`mnemonad info\`
111
+ shows how it went
112
+ --index push/watch: update the folder's search index (search_index.db)
113
+ before every push, so the stream is searchable right after a pull.
114
+ Takes --db/--model/--chunk-size/--chunk-overlap like \`index\`
93
115
  --poll-interval <s> Watch: seconds between remote checks (default: 2)
94
116
  --debounce <ms> Watch: ms quiet period before pushing after a change (default: 1000)
95
117
  --push-only Watch: disable auto-pull
96
118
  --pull-only Watch: disable auto-push
97
119
  --yes Required by burn — explicit opt-in for an irreversible action
120
+ --db <name> index/search: vector-index filename (default: search_index.db)
121
+ --model <name> index / --index: embedding model. \`potion\` (built in, the default for
122
+ a new index) or \`minilm\` (better on paraphrased queries; needs the
123
+ optional extension: npm install -g mnemonad-search-transformers),
124
+ or a full model id. Without it, an existing index keeps its own
125
+ model. \`search\` always uses the index's own model. All local —
126
+ no API key, nothing sent anywhere
127
+ --rebuild index / --index: start the index over (the way to switch models);
128
+ the next push uploads the whole index again
129
+ --chunk-size <n> index: target characters per chunk (default: 1000)
130
+ --chunk-overlap <n> index: characters of overlap between chunks (default: 150)
131
+ --limit, -k <n> search: number of results (default: 5)
98
132
  --help Show this help
133
+
134
+ index/search are optional and independent of push/pull — a folder with no search_index.db
135
+ just has no search capability. \`index\` writes a plain (non-dot-prefixed) file at the
136
+ folder's root, so it rides through the next \`push\` like any other file: whoever pulls the
137
+ stream gets the index already built, with no re-embedding of their own. \`push --index\` and
138
+ \`watch --index\` run \`index\` for you before each push, and a plain \`push\` of a folder whose
139
+ index has fallen behind its files says so. See docs/search.md in the repository for how the
140
+ index works and stays diff-friendly on repeated pushes.
99
141
  `;
100
142
 
101
143
  function parseArgs(argv) {
@@ -112,6 +154,8 @@ function parseArgs(argv) {
112
154
  presignUrl: process.env.MNEMONAD_PRESIGN_URL || DEFAULT_PRESIGN_URL,
113
155
  key: null,
114
156
  phrase: null,
157
+ passkey: false,
158
+ authOrigin: process.env.MNEMONAD_AUTH_ORIGIN || DEFAULT_AUTH_ORIGIN,
115
159
  version: null,
116
160
  exclude: null,
117
161
  compress: 'gzip',
@@ -120,12 +164,21 @@ function parseArgs(argv) {
120
164
  password: null,
121
165
  pinataJwt: process.env.PINATA_JWT || null,
122
166
  manifest: false,
167
+ index: false,
168
+ rebuild: false,
123
169
  forceSnapshot: false,
170
+ detach: false,
124
171
  pollInterval: 2,
125
172
  debounce: 1000,
126
173
  pushOnly: false,
127
174
  pullOnly: false,
128
175
  yes: false,
176
+ query: null,
177
+ searchDb: null,
178
+ model: null,
179
+ chunkSize: null,
180
+ chunkOverlap: null,
181
+ limit: null,
129
182
  };
130
183
 
131
184
  const raw = argv.slice(2);
@@ -164,12 +217,22 @@ function parseArgs(argv) {
164
217
  return qualified.id;
165
218
  };
166
219
 
220
+ // `-k` is the one short flag — it has to end the positionals too, or `search`'s
221
+ // join-the-rest query below swallows it (`search ./f slow and steady -k 3` once searched
222
+ // for "slow and steady -k 3").
223
+ const isFlag = (arg) => arg.startsWith('--') || arg === '-k';
167
224
  const positionals = [];
168
- while (i < raw.length && !raw[i].startsWith('--')) {
225
+ while (i < raw.length && !isFlag(raw[i])) {
169
226
  positionals.push(raw[i]);
170
227
  i++;
171
228
  }
172
- if (positionals.length === 2) {
229
+ if (args.command === 'search') {
230
+ // <path> <query...> — everything after the path is the query, joined back with spaces
231
+ // so an unquoted multi-word query (`mnemonad search ./docs how does chunking work`)
232
+ // works the same as a quoted one. Never a stream id — `search` doesn't take one.
233
+ args.path = positionals[0] || null;
234
+ args.query = positionals.length > 1 ? positionals.slice(1).join(' ') : null;
235
+ } else if (positionals.length === 2) {
173
236
  args.streamId = takeStreamId(positionals[0]);
174
237
  args.path = positionals[1];
175
238
  } else if (positionals.length === 1) {
@@ -198,6 +261,10 @@ function parseArgs(argv) {
198
261
  args.key = raw[++i];
199
262
  } else if (flag === '--phrase' && i + 1 < raw.length) {
200
263
  args.phrase = raw[++i];
264
+ } else if (flag === '--passkey') {
265
+ args.passkey = true;
266
+ } else if (flag === '--auth-origin' && i + 1 < raw.length) {
267
+ args.authOrigin = raw[++i];
201
268
  } else if (flag === '--version' && i + 1 < raw.length) {
202
269
  args.version = raw[++i];
203
270
  } else if (flag === '--exclude' && i + 1 < raw.length) {
@@ -214,8 +281,14 @@ function parseArgs(argv) {
214
281
  args.pinataJwt = raw[++i];
215
282
  } else if (flag === '--manifest') {
216
283
  args.manifest = true;
284
+ } else if (flag === '--index') {
285
+ args.index = true;
286
+ } else if (flag === '--rebuild') {
287
+ args.rebuild = true;
217
288
  } else if (flag === '--force-snapshot') {
218
289
  args.forceSnapshot = true;
290
+ } else if (flag === '--detach') {
291
+ args.detach = true;
219
292
  } else if (flag === '--poll-interval' && i + 1 < raw.length) {
220
293
  args.pollInterval = Number(raw[++i]);
221
294
  } else if (flag === '--debounce' && i + 1 < raw.length) {
@@ -226,6 +299,16 @@ function parseArgs(argv) {
226
299
  args.pullOnly = true;
227
300
  } else if (flag === '--yes') {
228
301
  args.yes = true;
302
+ } else if (flag === '--db' && i + 1 < raw.length) {
303
+ args.searchDb = raw[++i];
304
+ } else if (flag === '--model' && i + 1 < raw.length) {
305
+ args.model = raw[++i];
306
+ } else if (flag === '--chunk-size' && i + 1 < raw.length) {
307
+ args.chunkSize = Number(raw[++i]);
308
+ } else if (flag === '--chunk-overlap' && i + 1 < raw.length) {
309
+ args.chunkOverlap = Number(raw[++i]);
310
+ } else if ((flag === '--limit' || flag === '-k') && i + 1 < raw.length) {
311
+ args.limit = Number(raw[++i]);
229
312
  }
230
313
  i++;
231
314
  }
@@ -253,6 +336,22 @@ console.log(`mnemonad v${VERSION}`);
253
336
  // anything in the RPC/fetch stack unref's its own I/O (see the process.exit note above).
254
337
  const keepAlive = setInterval(() => {}, 60_000);
255
338
 
339
+ // --passkey's browser tab shows a result line when the command finishes (see
340
+ // closePasskeyBridge()) rather than a bare "done" — commands don't return a summary
341
+ // explicitly, so this captures their own last console.log line instead, the same thing this
342
+ // terminal already sees. Only wired up when --passkey is in play; every other run's
343
+ // console.log is untouched.
344
+ const logLines = [];
345
+ const originalConsoleLog = console.log;
346
+ if (args.passkey) {
347
+ console.log = (...parts) => {
348
+ originalConsoleLog(...parts);
349
+ logLines.push(parts.join(' '));
350
+ };
351
+ }
352
+ let succeeded = true;
353
+ let errorMessage = null;
354
+
256
355
  try {
257
356
  if (args.command === 'push') {
258
357
  await push(args);
@@ -268,6 +367,13 @@ try {
268
367
  await compact(args);
269
368
  } else if (args.command === 'burn') {
270
369
  await burn(args);
370
+ } else if (args.command === 'index') {
371
+ await buildIndex(args);
372
+ } else if (args.command === 'search') {
373
+ await search(args);
374
+ } else if (args.command === '__push-job') {
375
+ // The detached half of `push --detach` (lib/jobs.js) — never typed by a person.
376
+ await runPushJob(args.path);
271
377
  } else {
272
378
  console.error('Unknown command:', args.command);
273
379
  console.log(USAGE);
@@ -276,15 +382,26 @@ try {
276
382
  } catch (err) {
277
383
  if (err._isProcessExit) {
278
384
  process.exitCode = err._exitCode;
385
+ succeeded = err._exitCode === 0;
279
386
  } else if (err._isUserError) {
280
387
  // Expected, actionable failure — message only, no stack trace.
281
388
  console.error('Error:', err.message);
282
389
  process.exitCode = 1;
390
+ succeeded = false;
283
391
  } else {
284
392
  console.error('Error:', err.message);
285
393
  if (err.stack) console.error(err.stack);
286
394
  process.exitCode = 1;
395
+ succeeded = false;
287
396
  }
397
+ errorMessage = err.message;
288
398
  } finally {
399
+ console.log = originalConsoleLog;
400
+ // No-op when --passkey was never used — see passkeyBridge.js. Runs whether the command
401
+ // succeeded or failed, so a browser tab left open on a failed push doesn't linger.
402
+ closePasskeyBridge({
403
+ ok: succeeded,
404
+ summary: succeeded ? (logLines.filter(Boolean).pop() || 'Done.') : (errorMessage || 'Failed.'),
405
+ });
289
406
  clearInterval(keepAlive);
290
407
  }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * JSON can't carry BigInt, and viem's transaction-request objects use BigInt for gas/value/
3
+ * fee fields — so a transaction going out over `--passkey`'s loopback WebSocket bridge
4
+ * (passkeyBridge.js) to the browser tab is wrapped through this first.
5
+ *
6
+ * Must produce exactly what explorer/src/wallet/bridgeCodec.js's `decodeFromBridge` expects
7
+ * — the two sides are never bundled together (separate packages), so keep them in sync by
8
+ * hand if either changes.
9
+ */
10
+ export function encodeForBridge(value) {
11
+ return JSON.parse(JSON.stringify(value, (_key, v) => (typeof v === 'bigint' ? { __bigint__: v.toString() } : v)));
12
+ }
@@ -8,6 +8,8 @@
8
8
  import { createPublicClient, createWalletClient, defineChain, http } from 'viem';
9
9
  import { privateKeyToAccount, mnemonicToAccount } from 'viem/accounts';
10
10
  import { monad, monadTestnet } from 'viem/chains';
11
+ import { openPasskeyBridge } from './passkeyBridge.js';
12
+ import { userError } from './commands/shared.js';
11
13
 
12
14
  /**
13
15
  * CLI `--chain` name -> { viem chain object, Mnemonad network shorthand for
@@ -45,10 +47,20 @@ function normalizeKey(key) {
45
47
  return key.startsWith('0x') ? key : `0x${key}`;
46
48
  }
47
49
 
48
- function resolveAccount(args) {
50
+ /**
51
+ * `--key`/`--phrase`/`MNEMONAD_KEY` resolve synchronously; `--passkey` doesn't (it opens a
52
+ * browser tab and waits for a human to complete a WebAuthn prompt) — see
53
+ * passkeyBridge.js. That's the one thing that makes this function, and therefore
54
+ * `makeChainClient`, async rather than the plain synchronous lookup it used to be.
55
+ */
56
+ async function resolveAccount(args) {
49
57
  const key = args.key || process.env.MNEMONAD_KEY;
58
+ if (args.passkey && (key || args.phrase)) {
59
+ throw userError('--passkey cannot be combined with --key/--phrase/MNEMONAD_KEY — pick one signing method.');
60
+ }
50
61
  if (key) return privateKeyToAccount(normalizeKey(key));
51
62
  if (args.phrase) return mnemonicToAccount(args.phrase);
63
+ if (args.passkey) return openPasskeyBridge(args);
52
64
  return null;
53
65
  }
54
66
 
@@ -60,16 +72,17 @@ function resolveAccount(args) {
60
72
  * `requireWalletClient` themselves, so the "no key" error only surfaces where it's
61
73
  * actually required.
62
74
  *
63
- * @param {Object} args - parsed CLI args: `chain`, `rpcUrl`, `key`, `phrase`, `contractAddress`
64
- * @returns {{
75
+ * @param {Object} args - parsed CLI args: `chain`, `rpcUrl`, `key`, `phrase`, `passkey`,
76
+ * `authOrigin`, `contractAddress`
77
+ * @returns {Promise<{
65
78
  * publicClient: Object,
66
79
  * walletClient: ?Object,
67
80
  * account: ?Object,
68
81
  * contractAddress: string,
69
82
  * chain: Object,
70
- * }}
83
+ * }>}
71
84
  */
72
- export function makeChainClient(args) {
85
+ export async function makeChainClient(args) {
73
86
  let chain, contractAddress;
74
87
  if (args.chain === 'local') {
75
88
  // Dev/test only — see localChain() above.
@@ -95,7 +108,7 @@ export function makeChainClient(args) {
95
108
  const transport = http(args.rpcUrl);
96
109
  const publicClient = createPublicClient({ chain, transport });
97
110
 
98
- const account = resolveAccount(args);
111
+ const account = await resolveAccount(args);
99
112
  const walletClient = account ? createWalletClient({ chain, transport, account }) : null;
100
113
 
101
114
  return { publicClient, walletClient, account, contractAddress, chain };
@@ -111,7 +124,8 @@ export function requireWalletClient(client) {
111
124
  // first thing anyone hits before configuring a key, and a stack trace through
112
125
  // chainClient makes an actionable one-liner look like a crash.
113
126
  const err = new Error(
114
- 'No signing key. Set the MNEMONAD_KEY env var to a private key, or pass --key / --phrase.'
127
+ 'No signing key. Set the MNEMONAD_KEY env var to a private key, pass --key / --phrase, ' +
128
+ 'or pass --passkey to sign in with a passkey via the browser.'
115
129
  );
116
130
  err._isUserError = true;
117
131
  throw err;