ripgrepx 0.4.0

Instant ripgrep via a persistent candidate index, for the terminal and AI agents over MCP
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
//! rgx CLI. A bare `rgx <pattern>` is an (accelerated) ripgrep content search; the `--server` gate
//! holds daemon management, `--agent` the AI surface (MCP/skill), and `--find` does fd/find-style
//! name lookup. See `docs/cli.md`.
//!
//! Flags are recognized only as the leading token (rgx adds as few as possible to rg's surface).
//! The rg flag passthrough is a deliberate subset for now (-i, -s, -w, -F, -U, -A/-B/-C, `--`).

use std::io::Write;
use std::path::Path;
use std::process::ExitCode;

use rgx::compact::{self, CompactOpts};
use rgx::confirm::SearchOptions;
use rgx::cursor::{self, Mode};
use rgx::paths::resolve_root;
use rgx::proto::Request;
use rgx::{client, mcp, server};

// Heap profiling (cargo run --release --features dhat-heap ...): captures allocations for the whole
// run (e.g. run the daemon foreground over a repo to profile a cold build), writes dhat-heap.json.
#[cfg(feature = "dhat-heap")]
#[global_allocator]
static ALLOC: dhat::Alloc = dhat::Alloc;

fn main() -> ExitCode {
    #[cfg(feature = "dhat-heap")]
    let _dhat = dhat::Profiler::new_heap();

    let args: Vec<String> = std::env::args().skip(1).collect();
    match args.first().map(String::as_str) {
        None => {
            usage();
            ExitCode::from(2)
        }
        Some("--server") => server_cmd(&args[1..]),
        Some("--agent") => agent_cmd(&args[1..]),
        Some("--compact") => compact_cmd(&args[1..]),
        Some("--find") => find_cmd(&args[1..]),
        Some("-h" | "--help") => {
            // `rgx --help --server` / `--help --agent`: defer to the sub-surface guide.
            match args.get(1).map(String::as_str) {
                Some("--server") => print!("{SERVER_HELP}"),
                Some("--agent") => print!("{AGENT_HELP}"),
                _ => print!("{HELP}"),
            }
            ExitCode::SUCCESS
        }
        Some("-V" | "--version") => {
            println!("rgx {}", env!("CARGO_PKG_VERSION"));
            ExitCode::SUCCESS
        }
        _ => content_cmd(&args),
    }
}

/// Brief usage, to stderr, for the error paths (no/invalid args). Points at `--help` for the rest.
fn usage() {
    eprintln!(
        "usage:\n  rgx [flags] <pattern> [path]            content search (accelerated ripgrep)\n  \
         rgx --compact [opts] <pattern> [path]   token-savings view: grouped + paged\n  \
         rgx --find <name|path> [path] [--after PATH]   find files/dirs by name\n  \
         rgx --server [start|stop|restart|status|watch]\n  \
         rgx --agent [mcp|skill|install|uninstall|list]\n\n\
         flags: -i -s -w -n -F -U -A<n> -B<n> -C<n> --\n\
         run `rgx --help` for the full guide (drop-in use, server, agent: MCP/skill)"
    );
}

/// The full guide, to stdout, for `-h`/`--help`. Lean: an agent reads it once and knows the whole
/// surface — drop-in ripgrep use, the index server, and the AI path (compaction, MCP, skill).
/// `--server`/`--agent` have their own deeper guides (`rgx --server --help`, `rgx --agent --help`).
const HELP: &str = "\
rgx — Instant ripgrep for codebases you search over and over.

  rgx [flags] <pattern> [path]           content search (accelerated ripgrep)
  rgx --compact [opts] <pattern> [path]  token-savings view: grouped + paged
  rgx --find <name|path> [path]          locate files/dirs by name (find/fd-style)
  rgx --server [start|stop|restart|status|watch]   background index server  (rgx --server --help)
  rgx --agent [mcp|skill|install|uninstall|list]   AI-agent integration     (rgx --agent --help)
  rgx --version                                    print the rgx version (also -V)

DROP-IN FOR ripgrep — `rgx <pattern>` takes the same command line as `rg`, same output. Flags
(anywhere, like rg): -i -s -w -n -F -U -A<n> -B<n> -C<n> --. rgx's own modes are recognized only as the
first token. Examples:
    rgx 'fn \\w+_total' src/        rgx -i needle        rgx -- --server   (literal flag)

SERVER — the indexer starts on first use and stays fresh on its own; subcommands act on the cwd's
project. `status` reports readiness/counts/age, `watch` repaints live. Index + socket live under
$RGX_CACHE_DIR (else config `cache_dir`, else ~/.cache/rgx): a rebuildable cache, safe to delete.

FOR AI AGENTS — works with Claude Code, Codex, and any MCP client; see `rgx --agent --help`.
  Compaction — `--compact` groups matches by file, pages behind an opaque cursor, trims long lines.
  Nothing is dropped; the header reports the full total. `--page-size N` (default 50), `--cursor TOK`
  next page, `-l` files only, `-c` per-file counts. The cursor carries the whole query, so paging
  can't drift; a result set that changed gets a `note:`.
  MCP — `rgx --agent mcp` (stdio) exposes content_search (compact paged view), file_search, status.
  Install — `rgx --agent install [claude|codex|cursor|gemini|vscode]` writes a per-agent bundle.

Docs: https://github.com/igorgatis/ripgrepx
";

/// `rgx --server --help` (or `--help --server`): the index-server subcommands in full.
const SERVER_HELP: &str = "\
rgx --server — the background index server. Subcommands act on the current directory's project; the
indexer also starts on first search, so you rarely manage it by hand.

  rgx --server          run the indexer in the foreground
  rgx --server start    start the background indexer for this project
  rgx --server stop     stop it
  rgx --server restart  stop it (if running) and start a fresh daemon — e.g. after upgrading rgx
  rgx --server status   one-shot: readiness, file/trigram counts, memory, last-sync age
  rgx --server watch    live status, repaints on every change until interrupted

Index + socket live under $RGX_CACHE_DIR (else config `cache_dir`, else $XDG_CACHE_HOME/rgx, else
~/.cache/rgx): a rebuildable cache, safe to delete, never written into the indexed tree.

The daemon exits after `idle_timeout_secs` of no searches (default 1 h; zero or negative stays
resident forever) and respawns on the next one. A repo whose cold build is cheap (under
`persist_threshold_ms`, default 1 s) is kept in RAM only, with no snapshot — `status` shows
`ram-only`. Both are configurable; see docs/cli.md.
";

/// `rgx --agent --help` (or `--help --agent`): the AI-agent surface, with setup for the common hosts.
const AGENT_HELP: &str = "\
rgx --agent — integrate rgx with AI coding agents.

  rgx --agent mcp                       run the stdio MCP server: content_search, file_search, status
  rgx --agent skill                     print the agent skill (markdown) to stdout
  rgx --agent install   [TARGET...]     install the rgx bundle for each agent
  rgx --agent uninstall [TARGET...]     remove what install wrote
  rgx --agent list                      show detected agents and install status

TARGET (omit to auto-detect installed agents): claude  codex  cursor  gemini  vscode
Scope:   --user (default for claude/codex/gemini) or --project (default for cursor/vscode).
Confirm: install/uninstall print the exact changes and ask before touching anything. --yes (-y)
         applies without prompting (required when stdin is not a TTY); --dry-run (-n) only previews.

install writes only where rgx owns the namespace — Claude skill dir, a Gemini extension — or edits
shared files idempotently (a removable marked block in AGENTS.md / copilot-instructions, a merged
\"rgx\" key in .cursor/mcp.json / .vscode/mcp.json). MCP registration that belongs to a host's own CLI
(claude/codex mcp add) is printed for you to run, never executed.
";

fn server_cmd(rest: &[String]) -> ExitCode {
    if matches!(
        rest.first().map(String::as_str),
        Some("-h" | "--help" | "help")
    ) {
        print!("{SERVER_HELP}");
        return ExitCode::SUCCESS;
    }
    let root = resolve_root(None);
    match rest.first().map(String::as_str) {
        None => match server::run(root) {
            Ok(()) => ExitCode::SUCCESS,
            Err(e) => {
                eprintln!("rgx --server: {e}");
                ExitCode::from(2)
            }
        },
        Some("start") => spawn_and_report(&root, "starting"),
        Some("stop") => match client::request_existing(&root, &Request::Shutdown) {
            Ok(Some(_)) => {
                println!("rgx: daemon stopped");
                ExitCode::SUCCESS
            }
            Ok(None) => {
                println!("rgx: no daemon running");
                ExitCode::SUCCESS
            }
            Err(e) => {
                eprintln!("rgx: {e}");
                ExitCode::from(2)
            }
        },
        Some("restart") => {
            match client::request_existing(&root, &Request::Shutdown) {
                Ok(Some(_)) => {
                    if !client::wait_until_stopped(&root) {
                        eprintln!("rgx: previous daemon is still shutting down; try again");
                        return ExitCode::from(2);
                    }
                }
                Ok(None) => {}
                Err(e) => {
                    eprintln!("rgx: {e}");
                    return ExitCode::from(2);
                }
            }
            spawn_and_report(&root, "restarting")
        }
        Some("status") => match client::request_existing(&root, &Request::Status) {
            Ok(Some(bytes)) => {
                let _ = std::io::stdout().write_all(&bytes);
                ExitCode::SUCCESS
            }
            // No daemon: load the on-disk snapshot (if any) to report stats and its location.
            Ok(None) => {
                let snapshot = rgx::paths::snapshot_path(&root);
                let idx = rgx::index::Index::load(&snapshot).ok();
                let block = rgx::status::Status {
                    root: &root,
                    snapshot: &snapshot,
                    running: false,
                    ram_only: false,
                    state: None,
                    files: idx.as_ref().map(rgx::index::Index::file_count),
                    trigrams: idx.as_ref().map(rgx::index::Index::trigram_count),
                    memory_bytes: idx.as_ref().map(rgx::index::Index::memory_bytes),
                }
                .render();
                print!("{block}");
                ExitCode::SUCCESS
            }
            Err(e) => {
                eprintln!("rgx: {e}");
                ExitCode::from(2)
            }
        },
        Some("watch") => {
            // Live status: clear+home before each frame so it repaints in place.
            let res = client::watch(&root, |frame| {
                let mut out = std::io::stdout();
                let _ = out.write_all(b"\x1b[2J\x1b[H");
                let _ = out.write_all(frame);
                let _ = out.flush();
            });
            match res {
                Ok(()) => ExitCode::SUCCESS,
                Err(e) => {
                    eprintln!("rgx: {e}");
                    ExitCode::from(2)
                }
            }
        }
        Some(other) => {
            eprintln!("rgx --server: unknown subcommand {other:?}");
            ExitCode::from(2)
        }
    }
}

/// `rgx --agent <mcp|skill|install|uninstall|list>`: the AI-agent surface. `mcp` runs the stdio MCP
/// server; `skill` prints the agent skill; `install`/`uninstall` manage per-agent bundles; `list`
/// shows status. `--help` prints the agent guide; a missing subcommand is an error.
fn agent_cmd(rest: &[String]) -> ExitCode {
    match rest.first().map(String::as_str) {
        Some("mcp") => match mcp::run(resolve_root(None)) {
            Ok(()) => ExitCode::SUCCESS,
            Err(e) => {
                eprintln!("rgx --agent mcp: {e}");
                ExitCode::from(2)
            }
        },
        Some("skill") => {
            rgx::skill::print_skill();
            ExitCode::SUCCESS
        }
        Some("install") => agent_result("install", rgx::skill::install_cli(&rest[1..])),
        Some("uninstall") => agent_result("uninstall", rgx::skill::uninstall_cli(&rest[1..])),
        Some("list") => agent_result("list", rgx::skill::list()),
        Some("-h" | "--help" | "help") => {
            print!("{AGENT_HELP}");
            ExitCode::SUCCESS
        }
        None => {
            eprintln!(
                "rgx --agent: pick a subcommand (mcp|skill|install|uninstall|list); \
                 see `rgx --agent --help`"
            );
            ExitCode::from(2)
        }
        Some(other) => {
            eprintln!("rgx --agent: unknown subcommand {other:?}");
            ExitCode::from(2)
        }
    }
}

fn spawn_and_report(root: &Path, verb: &str) -> ExitCode {
    match client::spawn_daemon(root) {
        Ok(()) => {
            println!("rgx: daemon {verb} for {}", root.display());
            ExitCode::SUCCESS
        }
        Err(e) => {
            eprintln!("rgx: {e}");
            ExitCode::from(2)
        }
    }
}

fn agent_result(name: &str, r: anyhow::Result<()>) -> ExitCode {
    match r {
        Ok(()) => ExitCode::SUCCESS,
        Err(e) => {
            eprintln!("rgx --agent {name}: {e}");
            ExitCode::from(2)
        }
    }
}

const FIND_LIMIT: u32 = 1000;

fn find_cmd(rest: &[String]) -> ExitCode {
    let mut needle: Option<&str> = None;
    let mut path: Option<&str> = None;
    let mut after: Option<String> = None;
    let mut i = 0;
    while i < rest.len() {
        let a = &rest[i];
        if a == "--after" || a.starts_with("--after=") {
            let Some((v, consumed)) = long_value(rest, i, "--after") else {
                eprintln!("rgx: --after needs a value");
                return ExitCode::from(2);
            };
            after = Some(v.to_string());
            i += consumed;
            continue;
        }
        if needle.is_none() {
            needle = Some(a);
        } else if path.is_none() {
            path = Some(a);
        } else {
            eprintln!("rgx: unexpected extra argument {a:?}");
            return ExitCode::from(2);
        }
        i += 1;
    }
    let Some(needle) = needle else {
        eprintln!("usage: rgx --find <name|path> [path] [--after PATH]");
        return ExitCode::from(2);
    };
    let root = resolve_root(path);
    let bytes = match client::request(
        &root,
        &Request::Find {
            needle: needle.to_string(),
            after,
            limit: FIND_LIMIT,
        },
    ) {
        Ok(b) => b,
        Err(e) => {
            eprintln!("rgx: {e}");
            return ExitCode::from(2);
        }
    };

    let (header, body) = rgx::proto::parse_find_header(&bytes);
    let mut out = std::io::stdout();
    let Some(h) = header else {
        // Headerless blob (older daemon): emit paths as-is.
        let _ = out.write_all(body);
        return if body.is_empty() {
            ExitCode::from(1)
        } else {
            ExitCode::SUCCESS
        };
    };
    let first = if h.returned == 0 { 0 } else { h.start + 1 };
    let _ = writeln!(
        out,
        "[files {first}-{} of {}]",
        h.start + h.returned,
        h.total
    );
    let _ = out.write_all(body);
    if let Some(next) = h.next_after {
        let scope = path
            .map(|p| format!(" {}", shell_quote(p)))
            .unwrap_or_default();
        let _ = writeln!(
            out,
            "next: rgx --find {}{scope} --after {}",
            shell_quote(needle),
            shell_quote(&next)
        );
    }
    if h.total == 0 {
        ExitCode::from(1)
    } else {
        ExitCode::SUCCESS
    }
}

/// The leading-token flag surface shared by content search and `--compact`. `compact` additionally
/// recognizes `--cursor TOK` (resume, supersedes the pattern), `--page-size N`, and the `-l`/`-c`
/// output modes. Errors are reported here; the `Err` carries the exit code so callers just propagate.
struct ParsedSearch<'a> {
    opts: SearchOptions,
    cursor: Option<&'a str>,
    page_size: Option<usize>,
    mode: Mode,
    positionals: Vec<&'a str>,
}

fn parse_search<'a>(args: &'a [String], compact: bool) -> Result<ParsedSearch<'a>, ExitCode> {
    let mut opts = SearchOptions::default();
    let mut positionals: Vec<&str> = Vec::new();
    let mut cursor: Option<&str> = None;
    let mut page_size: Option<usize> = None;
    let mut mode = Mode::Matches;
    let mut only_positional = false; // set by `--`
    let mut i = 0;

    while i < args.len() {
        let a = &args[i];
        // A flag is recognized anywhere (like rg), until `--`. A leading-`-` token after `--`, or
        // the lone `-`, is positional. (A pattern that starts with `-` must follow `--`.)
        if only_positional || !a.starts_with('-') || a == "-" {
            positionals.push(a);
            i += 1;
            continue;
        }
        match a.as_str() {
            "--" => only_positional = true,
            "-i" | "--ignore-case" => opts.case_insensitive = true,
            "-s" | "--case-sensitive" => opts.case_insensitive = false,
            "-w" | "--word-regexp" => opts.word = true,
            "-F" | "--fixed-strings" => opts.fixed_strings = true,
            "-U" | "--multiline" => opts.multi_line = true,
            "-n" | "--line-number" => {}
            "-l" | "--files-with-matches" if compact => mode = Mode::Files,
            "-c" | "--count" if compact => mode = Mode::Count,
            p if compact && (p == "--cursor" || p.starts_with("--cursor=")) => {
                let Some((v, consumed)) = long_value(args, i, "--cursor") else {
                    eprintln!("rgx: --cursor needs a value");
                    return Err(ExitCode::from(2));
                };
                cursor = Some(v);
                i += consumed;
                continue;
            }
            p if compact && (p == "--page-size" || p.starts_with("--page-size=")) => {
                let n = long_value(args, i, "--page-size")
                    .and_then(|(v, c)| v.parse().ok().map(|n: usize| (n, c)));
                let Some((n, consumed)) = n else {
                    eprintln!("rgx: --page-size needs a number");
                    return Err(ExitCode::from(2));
                };
                page_size = Some(n.max(1));
                i += consumed;
                continue;
            }
            ctx if ctx.starts_with("-A") || ctx.starts_with("-B") || ctx.starts_with("-C") => {
                let (n, consumed) = match context_value(args, i) {
                    Some(v) => v,
                    None => {
                        eprintln!("rgx: {ctx} needs a number");
                        return Err(ExitCode::from(2));
                    }
                };
                match &ctx[..2] {
                    "-A" => opts.after_context = n,
                    "-B" => opts.before_context = n,
                    _ => {
                        opts.before_context = n;
                        opts.after_context = n;
                    }
                }
                i += consumed;
                continue;
            }
            other => {
                eprintln!("rgx: unsupported flag {other:?} (drop-in flag surface is WIP)");
                return Err(ExitCode::from(2));
            }
        }
        i += 1;
    }
    Ok(ParsedSearch {
        opts,
        cursor,
        page_size,
        mode,
        positionals,
    })
}

fn content_cmd(args: &[String]) -> ExitCode {
    let parsed = match parse_search(args, false) {
        Ok(p) => p,
        Err(code) => return code,
    };
    let opts = parsed.opts;

    let Some((pattern, rest)) = parsed.positionals.split_first() else {
        usage();
        return ExitCode::from(2);
    };
    let pattern = pattern.to_string();
    let path = rest.first().copied();
    if rest.len() > 1 {
        eprintln!("rgx: unexpected extra argument {:?}", rest[1]);
        return ExitCode::from(2);
    }
    let root = resolve_root(path);
    let mut stdout = std::io::stdout();

    // Fallback queries (no usable trigram) make every file a candidate, so the daemon can't narrow
    // anything and shipping a potentially huge result set back over the socket would be slower than
    // ripgrep. Scan in-process instead: a pipelined parallel walk+search streamed straight to stdout,
    // exactly like rg (and entirely self-contained — no daemon, no `rg` binary).
    if rgx::is_fallback(&pattern, opts) {
        use std::io::BufWriter;
        use std::sync::Mutex;
        use std::sync::atomic::{AtomicU64, Ordering};
        // Block-buffered (not the default line-buffered Stdout) so a match-everything query doesn't
        // flush once per line; the mutex serializes the parallel walk threads' writes.
        let out = Mutex::new(BufWriter::with_capacity(64 * 1024, std::io::stdout()));
        let bytes = AtomicU64::new(0);
        let res = rgx::stream_full_scan(&root, &pattern, opts, |c| {
            bytes.fetch_add(c.len() as u64, Ordering::Relaxed);
            if let Ok(mut w) = out.lock() {
                let _ = w.write_all(c);
            }
        });
        let _ = out.lock().map(|mut w| w.flush());
        return match res {
            Ok(()) if bytes.load(Ordering::Relaxed) == 0 => ExitCode::from(1),
            Ok(()) => ExitCode::SUCCESS,
            Err(e) => {
                eprintln!("rgx: {e}");
                ExitCode::from(2)
            }
        };
    }

    match client::request_stream(&root, &Request::Search { opts, pattern }, &mut stdout) {
        Ok(0) => ExitCode::from(1),
        Ok(_) => ExitCode::SUCCESS,
        // A closed stdout (e.g. `rgx pat | head`) is a clean exit for a grep-like tool, not an error.
        Err(e) if is_broken_pipe(&e) => ExitCode::SUCCESS,
        Err(e) => {
            eprintln!("rgx: {e}");
            ExitCode::from(2)
        }
    }
}

/// `--compact`: the token-savings view. Unlike a bare search, this must see the whole result set to
/// count, group by file, and paginate — so it buffers instead of streaming, then renders one page.
/// The match set is identical to `rg`; only presentation differs (see `compact`).
fn compact_cmd(args: &[String]) -> ExitCode {
    let parsed = match parse_search(args, true) {
        Ok(p) => p,
        Err(code) => return code,
    };

    // The cursor blob lives in the cwd's daemon (the pagination home), keyed by the printed token.
    // Resuming from the same directory finds it; from elsewhere it cleanly misses.
    let cwd = resolve_root(None);

    // Resume from the cursor (which carries the whole query) when given, else build a fresh query
    // from the flags + positionals. `prev` is the (total, fingerprint) at mint time, for the
    // staleness check below.
    let (pattern, opts, mode, start_after, page_size, root_hint, prev) = if let Some(tok) =
        parsed.cursor
    {
        // The cursor is self-contained, so any co-supplied query flag would be silently dropped.
        // Reject the combination explicitly rather than ignoring the flag.
        let stray_flags = parsed.page_size.is_some()
            || parsed.mode != Mode::Matches
            || parsed.opts != SearchOptions::default();
        if !parsed.positionals.is_empty() || stray_flags {
            eprintln!("rgx: --cursor is self-contained; don't combine it with a pattern or flags");
            return ExitCode::from(2);
        }
        let blob = match rgx::client::take_cursor(&cwd, tok) {
            Ok(Some(blob)) => blob,
            Ok(None) => {
                eprintln!("rgx: pagination expired — re-run the search");
                return ExitCode::from(2);
            }
            Err(e) => {
                eprintln!("rgx: {e}");
                return ExitCode::from(2);
            }
        };
        let c = match cursor::decode(&blob) {
            Ok(c) => c,
            Err(e) => {
                eprintln!("rgx: invalid --cursor ({e})");
                return ExitCode::from(2);
            }
        };
        let start_after = c.last_path.clone().map(|p| (p, c.last_lineno));
        (
            c.pattern,
            c.opts,
            c.mode,
            start_after,
            c.page_size,
            c.root_hint,
            Some((c.prev_total, c.fingerprint)),
        )
    } else {
        let Some((pattern, rest)) = parsed.positionals.split_first() else {
            usage();
            return ExitCode::from(2);
        };
        if rest.len() > 1 {
            eprintln!("rgx: unexpected extra argument {:?}", rest[1]);
            return ExitCode::from(2);
        }
        (
            pattern.to_string(),
            parsed.opts,
            parsed.mode,
            None,
            parsed.page_size.unwrap_or(compact::DEFAULT_PAGE_SIZE),
            rest.first().map(|s| s.to_string()),
            None,
        )
    };

    let root = resolve_root(root_hint.as_deref());
    let raw = match rgx::collect_search(&root, &pattern, opts) {
        Ok(r) => r,
        Err(e) => {
            eprintln!("rgx: {e}");
            return ExitCode::from(2);
        }
    };
    let page = compact::format(
        &raw,
        &pattern,
        opts,
        CompactOpts {
            mode,
            start_after,
            page_size,
            max_cols: compact::DEFAULT_MAX_COLS,
        },
    );

    let mut out = std::io::stdout();
    let _ = writeln!(out, "{}", page.header);
    let _ = out.write_all(page.body.as_bytes());
    if let Some(note) = page.staleness_note(prev) {
        let _ = writeln!(out, "note: {note}");
    }
    // Carry the original positional scope (a short relative path, or None for the cwd) rather than the
    // resolved absolute root: the caller pages from the same directory, so it re-resolves the same
    // tree. `root_hint` is already that scope from the branches above. Park the blob in the cwd daemon
    // and print its short token.
    if let Some(next) = page.next_cursor(mode, pattern, opts, page_size, root_hint) {
        match rgx::client::store_cursor(&cwd, cursor::encode(&next)) {
            Ok(token) => {
                let _ = writeln!(out, "next: rgx --compact --cursor {}", shell_quote(&token));
            }
            Err(e) => {
                let _ = writeln!(out, "note: could not store pagination cursor ({e})");
            }
        }
    }
    let empty = match mode {
        Mode::Matches => page.total_matches == 0,
        _ => page.total_files == 0,
    };
    if empty {
        ExitCode::from(1)
    } else {
        ExitCode::SUCCESS
    }
}

/// Single-quote `s` for the next-page shell hint, escaping embedded single quotes.
fn shell_quote(s: &str) -> String {
    format!("'{}'", s.replace('\'', "'\\''"))
}

/// True if `e` is (or wraps) a broken-pipe I/O error.
fn is_broken_pipe(e: &anyhow::Error) -> bool {
    e.downcast_ref::<std::io::Error>()
        .is_some_and(|io| io.kind() == std::io::ErrorKind::BrokenPipe)
}

/// Parse `-A5` (attached) or `-A 5` (separate); returns (value, args_consumed).
fn context_value(args: &[String], i: usize) -> Option<(usize, usize)> {
    let a = &args[i];
    if a.len() > 2 {
        a[2..].parse().ok().map(|n| (n, 1))
    } else {
        args.get(i + 1).and_then(|v| v.parse().ok()).map(|n| (n, 2))
    }
}

/// Parse a long flag's value: `--name=VALUE` (inline) or `--name VALUE` (separate). Returns
/// `(value, args_consumed)`.
fn long_value<'a>(args: &'a [String], i: usize, name: &str) -> Option<(&'a str, usize)> {
    let prefix = format!("{name}=");
    if let Some(v) = args[i].strip_prefix(&prefix) {
        Some((v, 1))
    } else {
        args.get(i + 1).map(|v| (v.as_str(), 2))
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn argv(parts: &[&str]) -> Vec<String> {
        parts.iter().map(|s| s.to_string()).collect()
    }

    #[test]
    fn parses_flags_pattern_and_path() {
        let args = argv(&["-i", "-w", "-n", "needle", "src/"]);
        let p = parse_search(&args, false).unwrap();
        assert!(p.opts.case_insensitive && p.opts.word);
        assert!(p.cursor.is_none());
        assert_eq!(p.positionals, vec!["needle", "src/"]);
    }

    #[test]
    fn compact_accepts_cursor_page_size_and_modes() {
        for args in [
            argv(&["--page-size", "20", "needle"]),
            argv(&["--page-size=20", "needle"]),
        ] {
            let p = parse_search(&args, true).unwrap();
            assert_eq!(p.page_size, Some(20), "args: {args:?}");
            assert_eq!(p.positionals, vec!["needle"]);
        }
        let cursor_args = argv(&["--cursor=ABC", "-l"]);
        let c = parse_search(&cursor_args, true).unwrap();
        assert_eq!(c.cursor, Some("ABC"));
        assert_eq!(c.mode, Mode::Files);
        let count_args = argv(&["-c", "needle"]);
        let count = parse_search(&count_args, true).unwrap();
        assert_eq!(count.mode, Mode::Count);
    }

    #[test]
    fn compact_only_flags_are_rejected_outside_compact() {
        assert!(parse_search(&argv(&["--cursor", "x", "needle"]), false).is_err());
        assert!(parse_search(&argv(&["--page-size", "2", "needle"]), false).is_err());
        assert!(parse_search(&argv(&["-l", "needle"]), false).is_err());
    }

    #[test]
    fn double_dash_makes_flaglike_pattern_positional() {
        let args = argv(&["--", "--cursor"]);
        let p = parse_search(&args, true).unwrap();
        assert_eq!(p.positionals, vec!["--cursor"]);
        assert!(p.cursor.is_none());
    }

    #[test]
    fn shell_quote_escapes_single_quotes() {
        assert_eq!(shell_quote("fn x"), "'fn x'");
        assert_eq!(shell_quote("it's"), "'it'\\''s'");
    }
}