semlith 0.41.0

A fast local vector store for AI agents — index files once, keep it current as you save, and answer questions across all of it in milliseconds without leaving the machine.
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
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
//! The client setup stanzas, read out of `docs/clients.md` at compile time.
//!
//! The portal's Agents view shows a copy-ready stanza per client, and there is
//! exactly one way for that to stay true as flags change: it has to be the
//! same text `tests/clients.rs` executes. So `docs/clients.md` is the source,
//! embedded with `include_str!` and parsed with the same rules the test uses — a heading
//! per client, then its fenced blocks.
//!
//! A stanza retyped into Rust would be a second copy that looks right for
//! exactly as long as nobody edits one of them.

use std::sync::OnceLock;

const CLIENTS_DOC: &str = include_str!("../docs/clients.md");

/// The heading the stanzas live under.
const SECTION: &str = "### Setting it up in your client";

/// The heading the HTTP stanzas live under.
const HTTP_SECTION: &str = "### Connecting over HTTP";

/// What a stanza names where the agent key goes.
///
/// The variable rather than the key from 0.14.0: a configuration file that
/// names it keeps working across every rotation, and one that carries the key
/// itself goes stale the moment somebody rotates. The portal substitutes the
/// literal value into this only when the reader has pressed Reveal.
pub const KEY_PLACEHOLDER: &str = "${SEMLITH_AGENT_KEY}";

/// What a stanza names where the semlith binary goes.
///
/// A registration naming the bare word `semlith` only launches from a `PATH`
/// that happens to carry it, and the process that reads these files is very
/// often not a login shell: an editor started from a desktop icon, a launchd or
/// systemd agent, and a desktop app all inherit a `PATH` that never sourced a
/// profile and so has never heard of `~/.cargo/bin`. The client reports that
/// the server exited and says nothing about why, which reads as semlith being
/// broken. So every registration semlith writes names the absolute path of the
/// binary that wrote it.
///
/// A placeholder in the document rather than a path assembled in Rust, for the
/// same reason as `KEY_PLACEHOLDER` above: `docs/clients.md` is the one copy of
/// every stanza, and a path built here would be a second one that looks right
/// for exactly as long as nobody edits the other.
const BIN_PLACEHOLDER: &str = "${SEMLITH_BIN}";

/// The placeholder a `rules` fence carries in place of the rule block itself.
///
/// The block lives in `docs/skill/RULES.md`, which is what `semlith setup`
/// writes and what `doctor` prints for a client whose rules file semlith does
/// not write. Repeating it per client in `docs/clients.md` would be six copies
/// to keep in step.
const RULES_PLACEHOLDER: &str = "${SEMLITH_RULES}";

/// The always-on rule block, embedded once.
pub const RULES: &str = include_str!("../docs/skill/RULES.md");

/// The Agent Skill `semlith setup` installs, embedded once.
pub const SKILL: &str = include_str!("../docs/skill/SKILL.md");

/// The semlith-explorer research subagent `setup` writes for Claude Code
/// (4.8): the skill's routing table in a read-only agent Claude chooses over
/// `Explore` for code research in an indexed folder.
pub const EXPLORER: &str = include_str!("../docs/skill/semlith-explorer.md");

/// One pasteable block.
#[derive(Debug, Clone, serde::Serialize)]
pub struct Stanza {
    /// The fence's language: `json`, `toml`, `sh`, `yaml`.
    pub format: String,
    pub text: String,
    /// The one `sh register` fence a client with a registration CLI carries:
    /// the command `semlith setup` runs for it, scope flag and all.
    ///
    /// It lives in the fence's info string rather than in a table here because
    /// a rendered fence hides everything after the language, so the fact stays
    /// in the document a human reads and in the one a program parses at once.
    /// A command retyped into Rust would look right for exactly as long as
    /// nobody edited one of the two.
    pub register: bool,
    /// From a bare `unregister` on a fence: a command that removes an existing
    /// semlith entry from this client, run before the registration.
    pub unregister: bool,
    /// From `path=` in the info string: the user-level configuration file
    /// `semlith setup --register-all` may merge into.
    ///
    /// `None` on every fence that is an example rather than a destination —
    /// which is every project-level file, because registering semlith into one
    /// repository's committed config is the defect 0.18.0 exists to end.
    pub path: Option<String>,
    /// From `os=` in the info string, when a client's file is somewhere else on
    /// another platform. `None` means the path is the same everywhere.
    pub os: Option<String>,
    /// From `root=` in the info string: an environment variable that, when set,
    /// replaces the path's first directory under `~` — Cline's `CLINE_DIR`
    /// moves `~/.cline` and everything in it.
    pub root: Option<String>,
    /// From a bare `hook` on a fence: the `PreToolUse` block `semlith setup`
    /// merges into this client's settings, and `semlith setup --no-hooks`
    /// removes again. Its `path=` is the settings file.
    pub hook: bool,
    /// From a bare `skills` on a fence: its `path=` is a user-level skill
    /// directory this client reads, which `semlith setup` links the canonical
    /// skill into.
    pub skills: bool,
    /// From a bare `rules` on a fence: its `path=` is a user-level rules file,
    /// written only under `--register-all` because it is prose a person owns
    /// rather than a server list semlith put there.
    pub rules: bool,
    /// From `scope=` on a `register` fence: what that command actually
    /// registers.
    ///
    /// `global` unless the fence says otherwise, and one client says otherwise.
    /// `opencode mcp add` on 1.18.11 has no global flag, so running it
    /// registers semlith for the directory the user happened to be
    /// standing in — which is the defect this release exists to end. semlith
    /// does not run a `scope=project` command; those clients reach every
    /// project through their user-level file instead.
    pub scope: Scope,
}

/// What a registration command registers.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Scope {
    /// Every project on the machine. The only kind semlith runs unasked.
    #[default]
    Global,
    /// The directory it is run in, and nothing else.
    Project,
}

impl Stanza {
    /// Whether this fence is for the system this binary runs on. `os=` takes
    /// one name or a comma list — `macos,linux` for a client whose file sits
    /// under `~/.config` on both and under `%APPDATA%` on Windows. No `os=`
    /// means every system.
    pub fn applies_here(&self) -> bool {
        let here = if cfg!(windows) {
            "windows"
        } else if cfg!(target_os = "macos") {
            "macos"
        } else {
            "linux"
        };
        self.os
            .as_deref()
            .is_none_or(|list| list.split(',').any(|os| os.trim() == here))
    }
}

/// What a fence's info string says beyond its language.
///
/// `sh register`, `json config path=~/.cursor/mcp.json`, and
/// `json config path=%APPDATA%\Claude\claude_desktop_config.json os=windows`
/// are the three shapes in use. An attribute this does not know is ignored
/// rather than refused: `docs/clients.md` is documentation first, and a fence
/// annotated for some future reader should not stop the portal rendering.
fn info(line: &str) -> Info {
    let mut format = String::new();
    let (mut register, mut unregister) = (false, false);
    let (mut hook, mut skills, mut rules) = (false, false, false);
    let (mut path, mut os, mut root, mut scope) = (None, None, None, Scope::Global);
    // Hand-split rather than `split_whitespace`, because one path has a space
    // in it — Claude Desktop's on macOS — and a quoted value has to survive.
    for word in split_attributes(line) {
        match word.split_once('=') {
            Some(("path", value)) => path = Some(unquote(value).to_string()),
            Some(("os", value)) => os = Some(unquote(value).to_string()),
            Some(("root", value)) => root = Some(unquote(value).to_string()),
            Some(("scope", "project")) => scope = Scope::Project,
            Some(("scope", _)) => scope = Scope::Global,
            _ if word == "register" => register = true,
            _ if word == "unregister" => unregister = true,
            _ if word == "hook" => hook = true,
            _ if word == "skills" => skills = true,
            _ if word == "rules" => rules = true,
            _ if format.is_empty() => format = word.to_string(),
            _ => {}
        }
    }
    Info {
        format,
        register,
        unregister,
        hook,
        skills,
        rules,
        path,
        os,
        root,
        scope,
    }
}

/// What an info string carried, beyond the fence's language.
struct Info {
    format: String,
    register: bool,
    unregister: bool,
    hook: bool,
    skills: bool,
    rules: bool,
    path: Option<String>,
    os: Option<String>,
    root: Option<String>,
    scope: Scope,
}

/// An info string's words, keeping a double-quoted value whole.
fn split_attributes(line: &str) -> Vec<String> {
    let mut out = Vec::new();
    let mut word = String::new();
    let mut quoted = false;
    for c in line.chars() {
        match c {
            '"' => {
                quoted = !quoted;
                word.push(c);
            }
            c if c.is_whitespace() && !quoted => {
                if !word.is_empty() {
                    out.push(std::mem::take(&mut word));
                }
            }
            c => word.push(c),
        }
    }
    if !word.is_empty() {
        out.push(word);
    }
    out
}

fn unquote(value: &str) -> &str {
    value
        .strip_prefix('"')
        .and_then(|rest| rest.strip_suffix('"'))
        .unwrap_or(value)
}

/// One client, its note, and every stanza `docs/clients.md` gives for it.
#[derive(Debug, Clone, serde::Serialize)]
pub struct Client {
    pub name: String,
    /// Where the file lives and anything the client needs told about it, as
    /// one line of plain text.
    pub note: String,
    /// `terminal`, `editor` or `desktop`, read from `docs/clients.md`'s own grouping
    /// so the portal's tabs and the documentation cannot disagree about which
    /// kind of thing a client is.
    pub group: String,
    pub stanzas: Vec<Stanza>,
}

impl Client {
    /// The command `semlith setup` runs to register this client, or `None` for
    /// a client whose only route in is a file somebody edits.
    ///
    /// Whitespace-collapsed, because a fence may wrap a long command and a
    /// newline inside an argument list is not part of the command.
    pub fn register_command(&self) -> Option<String> {
        self.stanzas
            .iter()
            .find(|stanza| stanza.register && stanza.applies_here())
            .map(|stanza| stanza.text.split_whitespace().collect::<Vec<_>>().join(" "))
    }

    /// The commands that remove an existing semlith entry from this client,
    /// run before the registration and with their status ignored.
    ///
    /// More than one where a client has more than one scope to clear: Claude
    /// Code's `user`, `project` and `local` are three separate entries, and the
    /// one that made this release necessary was at `local`. A client that
    /// documents no remove verb has none here, and semlith reports what the
    /// failing `add` said rather than guessing a command.
    pub fn unregister_commands(&self) -> Vec<String> {
        self.stanzas
            .iter()
            .filter(|stanza| stanza.unregister && stanza.applies_here())
            .map(|stanza| stanza.text.split_whitespace().collect::<Vec<_>>().join(" "))
            .collect()
    }

    /// Whether that command registers semlith for every project.
    ///
    /// The only kind `semlith setup` runs unasked. A command that registers the
    /// current directory would leave the user exactly where 0.18.0 found
    /// them — semlith working in the repository they happened to be standing in
    /// and missing from the next one — so those clients go through their
    /// user-level file instead.
    pub fn registers_globally(&self) -> bool {
        self.stanzas
            .iter()
            .any(|stanza| stanza.register && stanza.scope == Scope::Global && stanza.applies_here())
    }

    /// The user-level configuration files for this client.
    ///
    /// `semlith doctor` reads them to report where a client actually registered
    /// and at what scope. `semlith setup --register-all` writes them, but only
    /// for a client with no global registration CLI: a client semlith can ask
    /// to register itself is not a file semlith writes.
    pub fn config_files(&self) -> impl Iterator<Item = &Stanza> {
        self.stanzas.iter().filter(|stanza| {
            stanza.path.is_some() && !stanza.hook && !stanza.rules && !stanza.skills
        })
    }

    /// The `PreToolUse` block for this client, if it documents one.
    ///
    /// One per client: a second would be two hooks doing the same job, and the
    /// removal path would have to guess which one semlith put there.
    pub fn hook_stanza(&self) -> Option<&Stanza> {
        self.stanzas
            .iter()
            .find(|stanza| stanza.hook && stanza.path.is_some() && stanza.applies_here())
    }

    /// The user-level skill directories this client reads.
    pub fn skill_dirs(&self) -> impl Iterator<Item = &Stanza> {
        self.stanzas
            .iter()
            .filter(|stanza| stanza.skills && stanza.path.is_some())
    }

    /// The user-level rules file this client reads, if semlith knows one.
    ///
    /// A client without one is not a client semlith writes prose into on a
    /// guess: `doctor` prints the block for a person to paste instead.
    pub fn rules_file(&self) -> Option<&Stanza> {
        self.stanzas
            .iter()
            .find(|stanza| stanza.rules && stanza.path.is_some() && stanza.applies_here())
    }

    /// Whether `semlith setup --register-all` would write this client's file.
    pub fn needs_a_file_written(&self) -> bool {
        !self.registers_globally() && self.config_files().next().is_some()
    }
}

/// The HTTP stanzas, with `key` substituted for `docs/clients.md`'s placeholder.
///
/// One template for every client rather than one per client: the endpoint and
/// the header are the same wherever they are pasted, and a per-client copy
/// would be twelve places for one URL to go stale.
pub fn http_stanzas(key: &str) -> Vec<Stanza> {
    static PARSED: OnceLock<Vec<Stanza>> = OnceLock::new();
    PARSED
        .get_or_init(|| parse_http(CLIENTS_DOC))
        .iter()
        .map(|stanza| Stanza {
            text: stanza.text.replace(KEY_PLACEHOLDER, key),
            ..stanza.clone()
        })
        .collect()
}

fn parse_http(readme: &str) -> Vec<Stanza> {
    let Some(start) = readme.find(HTTP_SECTION) else {
        return Vec::new();
    };
    let rest = &readme[start + HTTP_SECTION.len()..];
    let section = match rest.find("\n## ") {
        Some(end) => &rest[..end],
        None => rest,
    };
    let mut out = Vec::new();
    let mut lines = section.lines();
    while let Some(line) = lines.next() {
        let Some(format) = line.strip_prefix("```") else {
            continue;
        };
        let mut text = String::new();
        for body in lines.by_ref() {
            if body.starts_with("```") {
                break;
            }
            text.push_str(body);
            text.push('\n');
        }
        out.push(stanza(info(format), &text));
    }
    out
}

/// The absolute path of the binary this process is running from, resolved once.
///
/// Every stanza goes through here on its way out of `docs/clients.md`, so a
/// caller cannot forget it and there is no second spelling of the path for a
/// client to disagree with — which also makes registering twice write the same
/// bytes twice.
pub fn binary_path() -> &'static str {
    static RESOLVED: OnceLock<String> = OnceLock::new();
    RESOLVED.get_or_init(resolve_binary_path)
}

/// The running binary, spelled with forward slashes on every platform.
///
/// Forward slashes deliberately, and not only for tidiness. `canonicalize` on
/// Windows returns the verbatim form — `\\?\C:\Users\you\.cargo\bin\semlith.exe`
/// — which several client launchers refuse to execute, and whose backslashes
/// would each have to be doubled to survive being embedded in the JSON, TOML
/// and double-quoted YAML these stanzas are written in. Miss one of those
/// escapes and the client's parser rejects the whole file, taking every other
/// server in it with semlith. `C:/Users/you/.cargo/bin/semlith.exe` is accepted
/// by `CreateProcess`, needs no escaping in any of the four formats, and is the
/// one spelling in all of them. On unix the separator is already `/` and the
/// replacement does nothing.
///
/// The bare command when `current_exe` fails, rather than a guess: it can fail
/// on a platform with no `/proc` and a binary that has been unlinked, and a
/// stanza naming `semlith` at least works wherever the `PATH` is right — which
/// is where this release found everybody. A wrong absolute path works nowhere,
/// and unlike the bare word it gives the reader nothing to recognise.
fn resolve_binary_path() -> String {
    let Ok(running) = std::env::current_exe() else {
        return "semlith".to_string();
    };
    // `unwrap_or`: `current_exe` already hands back an absolute path, and
    // canonicalising it only resolves the symlinks a package manager leaves
    // behind. If that fails the absolute path is still worth writing.
    let resolved = std::fs::canonicalize(&running).unwrap_or(running);
    let text = resolved.to_string_lossy().into_owned();
    if !cfg!(windows) {
        return text;
    }
    // `\\?\UNC\server\share\…` is the verbatim spelling of `\\server\share\…`,
    // and stripping only the `\\?\` off it would leave a path beginning with a
    // literal `UNC` that names nothing.
    match text.strip_prefix(r"\\?\UNC\") {
        Some(rest) => format!(r"\\{rest}"),
        None => text.strip_prefix(r"\\?\").unwrap_or(&text).to_string(),
    }
    .replace('\\', "/")
}

/// `text` with the binary path in place of the placeholder, spelled the way
/// each context needs it (audit S17). One raw substitution served about thirty
/// places in four syntaxes: a JSON string, a TOML string, a shell word, a JSON
/// object inside a single-quoted shell word, and a hook's command line that a
/// shell runs. A path with a space, a quote, a `$` or a backslash broke some
/// of them and, in the hook, split into a program and an argument.
fn spell_binary(format: &str, text: &str, bin: &str) -> String {
    let json = |raw: &str| {
        let quoted = serde_json::to_string(raw).expect("a string serialises");
        quoted[1..quoted.len() - 1].to_string()
    };
    match format {
        "json" => text
            .replace(
                &format!("{BIN_PLACEHOLDER} hook"),
                &format!("{} hook", json(&command_word(bin))),
            )
            .replace(BIN_PLACEHOLDER, &json(bin)),
        "toml" => text.replace(
            BIN_PLACEHOLDER,
            &bin.replace('\\', "\\\\").replace('"', "\\\""),
        ),
        // `setup::argv` splits these, and a person may paste them into a
        // shell: a single-quoted word means the same to both.
        "sh" => text
            .split_inclusive('\n')
            .map(|line| {
                if line.contains("'{") {
                    // The JSON object inside single quotes.
                    line.replace(BIN_PLACEHOLDER, &json(bin).replace('\'', r"'\''"))
                } else if bin.contains(['"', '$', '`', '\\']) {
                    line.replace(
                        &format!("\"{BIN_PLACEHOLDER}\""),
                        &crate::setup::posix_quote(bin),
                    )
                } else {
                    line.replace(BIN_PLACEHOLDER, bin)
                }
            })
            .collect(),
        _ => text.replace(BIN_PLACEHOLDER, bin),
    }
}

/// The binary as the first word of a command line a client hands to a shell:
/// as it is when nothing in it is special, quoted otherwise. Double quotes on
/// Windows, where `cmd` has no single quotes and a path cannot hold `"`.
fn command_word(bin: &str) -> String {
    if bin
        .chars()
        .all(|c| c.is_ascii_alphanumeric() || "/._-+:@~".contains(c))
    {
        bin.to_string()
    } else if cfg!(windows) {
        format!("\"{bin}\"")
    } else {
        crate::setup::posix_quote(bin)
    }
}

/// One parsed fence, with the binary path substituted for the placeholder
/// `docs/clients.md` writes.
fn stanza(info: Info, text: &str) -> Stanza {
    let text = spell_binary(&info.format, text, binary_path());
    Stanza {
        format: info.format,
        text: text.replace(RULES_PLACEHOLDER, RULES.trim_end()),
        register: info.register,
        unregister: info.unregister,
        hook: info.hook,
        skills: info.skills,
        rules: info.rules,
        path: info.path,
        os: info.os,
        root: info.root,
        scope: info.scope,
    }
}

/// Every documented client, parsed once.
pub fn clients() -> &'static [Client] {
    static PARSED: OnceLock<Vec<Client>> = OnceLock::new();
    PARSED.get_or_init(|| parse(CLIENTS_DOC))
}

fn parse(readme: &str) -> Vec<Client> {
    let Some(start) = readme.find(SECTION) else {
        return Vec::new();
    };
    let rest = &readme[start + SECTION.len()..];
    // The section ends at the next heading of its own level or above. Stopping
    // only at `## ` swept in the `### Connecting over HTTP` section below it,
    // and its example stanzas were then read as the last client's own.
    let end = ["\n## ", "\n### "]
        .iter()
        .filter_map(|marker| rest.find(marker))
        .min();
    let section = match end {
        Some(end) => &rest[..end],
        None => rest,
    };

    let mut out: Vec<Client> = Vec::new();
    let mut group = String::from("terminal");
    let mut lines = section.lines().peekable();
    while let Some(line) = lines.next() {
        // `docs/clients.md` groups its clients under `#### Terminal`, `#### Editors`
        // and `#### Desktop apps`. Read from there rather than from a table in
        // this file, so the grouping has one source.
        if let Some(heading) = line.strip_prefix("#### ") {
            group = match heading.trim() {
                "Editors" => "editor",
                "Desktop apps" => "desktop",
                _ => "terminal",
            }
            .to_string();
            continue;
        }
        // A client's block starts at its bold name at the start of a line.
        let Some(after) = line.strip_prefix("**") else {
            continue;
        };
        let Some((name, tail)) = after.split_once("**") else {
            continue;
        };

        // The note runs to the first blank line, and is joined into one line:
        // the portal shows it under the client's name, not as a paragraph.
        let mut note = tail.trim_start_matches([' ', '—', '-']).trim().to_string();
        for next in lines.by_ref() {
            if next.trim().is_empty() {
                break;
            }
            if next.starts_with("```") || next.starts_with("**") {
                break;
            }
            note.push(' ');
            note.push_str(next.trim());
        }

        let mut stanzas = Vec::new();
        // Then every fenced block until the next client or the end.
        while let Some(peeked) = lines.peek() {
            // The next client, or the heading of the next group — either way
            // this client's blocks have ended.
            if peeked.starts_with("**") || peeked.starts_with("#### ") {
                break;
            }
            let line = lines.next().expect("just peeked");
            let Some(format) = line.strip_prefix("```") else {
                continue;
            };
            let mut text = String::new();
            for body in lines.by_ref() {
                if body.starts_with("```") {
                    break;
                }
                text.push_str(body);
                text.push('\n');
            }
            stanzas.push(stanza(info(format), &text));
        }

        if !stanzas.is_empty() {
            out.push(Client {
                name: name.trim().to_string(),
                note: squeeze(&note),
                group: group.clone(),
                stanzas,
            });
        }
    }
    out
}

/// Markdown emphasis and backticks are noise in a portal panel that is not
/// rendering markdown.
fn squeeze(note: &str) -> String {
    note.replace("**", "")
        .replace('`', "")
        .split_whitespace()
        .collect::<Vec<_>>()
        .join(" ")
}

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

    /// If this ever comes back empty, the Agents view silently shows nothing
    /// and `docs/clients.md` has been restructured under it.
    #[test]
    fn every_documented_client_is_parsed_with_at_least_one_stanza() {
        let parsed = clients();
        assert!(
            parsed.len() == 12,
            "only {} clients parsed out of the CLIENTS_DOC",
            parsed.len()
        );
        for client in parsed {
            assert!(!client.name.is_empty());
            assert!(!client.stanzas.is_empty(), "{} has no stanza", client.name);
            for stanza in &client.stanzas {
                assert!(
                    !stanza.text.trim().is_empty(),
                    "{} has an empty stanza",
                    client.name
                );
            }
        }
    }

    /// The release's promise, asserted against the text the portal hands out:
    /// no client stanza carries a store path.
    #[test]
    fn no_parsed_stanza_carries_a_store_path() {
        for client in clients() {
            for stanza in &client.stanzas {
                assert!(
                    !stanza.text.contains("--store"),
                    "{} still passes --store:\n{}",
                    client.name,
                    stanza.text
                );
            }
        }
    }

    /// Every client is in one of the three groups the portal renders as tabs,
    /// and the counts sum to the number of clients `docs/clients.md` documents.
    #[test]
    fn every_client_carries_a_group_read_from_the_readme() {
        let parsed = clients();
        let mut counts = std::collections::BTreeMap::new();
        for client in parsed {
            assert!(
                ["terminal", "editor", "desktop"].contains(&client.group.as_str()),
                "{} is in group {:?}",
                client.name,
                client.group
            );
            *counts.entry(client.group.as_str()).or_insert(0usize) += 1;
        }
        assert_eq!(
            counts.values().sum::<usize>(),
            parsed.len(),
            "the groups do not cover every client"
        );
        assert_eq!(counts.len(), 3, "a group is empty: {counts:?}");
    }

    /// The HTTP stanzas carry the live key rather than the placeholder, and
    /// name the endpoint the daemon actually serves.
    #[test]
    fn the_http_stanzas_carry_the_key_they_are_given() {
        let stanzas = http_stanzas("sml_abc123");
        assert!(!stanzas.is_empty(), "no HTTP stanza was parsed");
        for stanza in &stanzas {
            assert!(
                stanza.text.contains("127.0.0.1:7365/mcp"),
                "an HTTP stanza does not name the endpoint:\n{}",
                stanza.text
            );
            assert!(
                stanza.text.contains("Bearer sml_abc123"),
                "an HTTP stanza does not carry the key it was given:\n{}",
                stanza.text
            );
            assert!(
                !stanza.text.contains(KEY_PLACEHOLDER),
                "an HTTP stanza still carries the placeholder:\n{}",
                stanza.text
            );
        }
    }

    /// Seven of the twelve clients have a registration CLI; six of them register
    /// every project, and those six are exactly the set `semlith setup`
    /// registers without being asked. The number is asserted rather than counted at runtime because a
    /// client silently losing its `sh register` fence is a client that goes
    /// back to being a stanza somebody pastes, which is the defect 0.18.0
    /// exists to end and which nothing else here would notice.
    #[test]
    fn seven_clients_carry_a_registration_command() {
        let with: Vec<&str> = clients()
            .iter()
            .filter(|client| client.register_command().is_some())
            .map(|client| client.name.as_str())
            .collect();
        assert_eq!(with.len(), 7, "registration commands found: {with:?}");
    }

    /// Every documented client is reachable: semlith can register it for every
    /// project, by its own CLI or by writing its user-level configuration file
    /// under `--register-all`. One appearing here lost its route in without
    /// anyone deciding that it should.
    #[test]
    fn every_client_is_reachable() {
        let mut unreachable = Vec::new();
        let mut by_file = Vec::new();
        for client in clients() {
            if client.registers_globally() {
                continue;
            }
            if client.needs_a_file_written() {
                by_file.push(client.name.as_str());
                continue;
            }
            unreachable.push(client.name.as_str());
        }
        assert!(
            unreachable.is_empty(),
            "semlith cannot register {unreachable:?}; by file: {by_file:?}"
        );
    }

    /// One client registers the directory it is run in, and semlith does not
    /// run that. Asserted by name: if another joins it, or it gains a global
    /// flag, that is a release decision rather than an edit.
    #[test]
    fn the_project_scoped_registration_is_the_one_known_to_be() {
        let project: Vec<&str> = clients()
            .iter()
            .filter(|client| {
                client
                    .stanzas
                    .iter()
                    .any(|stanza| stanza.register && stanza.scope == Scope::Project)
            })
            .map(|client| client.name.as_str())
            .collect();
        assert_eq!(project, vec!["OpenCode"]);
        for name in &project {
            let client = clients()
                .iter()
                .find(|client| &client.name == name)
                .expect("just found");
            assert!(
                !client.registers_globally(),
                "{name} is counted as global as well as project-scoped"
            );
            assert!(
                client.needs_a_file_written(),
                "{name} has no global route in at all, which would make it unreachable"
            );
        }
    }

    /// A configuration file semlith writes is user-level. A project-level path
    /// here would have semlith registering itself into one repository's
    /// committed config, which is the scope bug wearing different clothes.
    #[test]
    fn every_config_path_semlith_writes_is_user_level() {
        for client in clients() {
            for stanza in client.config_files() {
                let path = stanza.path.as_deref().expect("filtered on Some");
                assert!(
                    path.starts_with('~') || path.starts_with('%') || path.starts_with('/'),
                    "{}'s config path {path} is not user-level",
                    client.name
                );
            }
        }
    }

    /// A registration semlith performs never carries a credential and never
    /// names the endpoint: it launches `semlith mcp`, which reads the key out
    /// of `~/.semlith/agent.key` itself. This is the whole of what 0.18.0
    /// changed about how a client authenticates, so it is asserted over the
    /// text rather than left to the shape of the command.
    #[test]
    fn no_registration_command_carries_a_key_or_an_endpoint() {
        for client in clients() {
            let Some(command) = client.register_command() else {
                continue;
            };
            for forbidden in [KEY_PLACEHOLDER, "Bearer", "127.0.0.1:7365", "sml_"] {
                assert!(
                    !command.contains(forbidden),
                    "{}'s registration command carries {forbidden}:\n{command}",
                    client.name
                );
            }
            assert!(
                command.contains("semlith"),
                "{}'s registration command does not name semlith:\n{command}",
                client.name
            );
        }
    }

    #[test]
    fn claude_code_is_the_first_client_and_has_both_of_its_forms() {
        let first = &clients()[0];
        assert_eq!(first.name, "Claude Code");
        // Asserted by role rather than by position. The list grew an
        // `unregister` fence in 0.18.0 and will grow again; what has to stay
        // true is that Claude Code documents the endpoint as a file and as a
        // command, and carries exactly one registration.
        assert!(
            first
                .stanzas
                .iter()
                .all(|s| ["json", "sh", "text"].contains(&s.format.as_str())),
            "an unexpected fence language on Claude Code: {:?}",
            first.stanzas.iter().map(|s| &s.format).collect::<Vec<_>>()
        );
        assert_eq!(
            first.stanzas.iter().filter(|s| s.register).count(),
            1,
            "a client may document exactly one registration command"
        );
        assert!(
            !first.unregister_commands().is_empty(),
            "the entry being replaced is often not at the scope the new one goes to"
        );
        assert!(
            first.stanzas[0].text.contains("http://127.0.0.1:7365/mcp"),
            "the first stanza is not the endpoint:\n{}",
            first.stanzas[0].text
        );
        // The stdio form, at user scope, is what `semlith setup` runs from
        // 0.18.0. The scope flag is the release: without it this command
        // registers the directory it was typed in. The path is this test
        // binary's own, because `binary_path` resolves whatever is running.
        assert_eq!(
            first.register_command().as_deref(),
            Some(
                format!(
                    "claude mcp add --scope user semlith -- \"{}\" mcp",
                    binary_path()
                )
                .as_str()
            )
        );
        assert!(first.registers_globally());
    }

    /// Every command `semlith setup` runs hands the client the binary's
    /// absolute path, in one piece.
    ///
    /// Asserted through `setup::argv`, which is what actually splits these
    /// commands, rather than over the text: the path has to be quoted in the
    /// document or a machine whose home has a space in it — `C:/Users/Ada
    /// Lovelace/…`, and every Windows machine with a full name on it — would
    /// register a program called `C:/Users/Ada` and an argument called
    /// `Lovelace/…`. Word-containment rather than equality, so a client that
    /// takes the whole command line as one quoted argument still passes.
    #[test]
    fn every_registration_hands_the_client_the_resolved_path_whole() {
        for client in clients() {
            let Some(command) = client.register_command() else {
                continue;
            };
            let (_, args) = crate::setup::argv(&command).unwrap_or_else(|| {
                panic!("{}'s registration does not split: {command}", client.name)
            });
            assert!(
                args.iter().any(|word| word.contains(binary_path())),
                "{}'s registration does not hand over {} in one piece:\n{command}",
                client.name,
                binary_path()
            );
        }
    }

    /// Audit S17: a path with a space, a quote, a `$` or a backslash reaches
    /// every context whole — the hook's command line, a JSON or TOML string,
    /// a shell word and the JSON object inside one.
    #[cfg(unix)]
    #[test]
    fn an_awkward_binary_path_is_spelled_for_each_context() {
        let bin = r#"/home/a b/it's "$x"/semlith"#;
        let hook = spell_binary("json", r#"{"command": "${SEMLITH_BIN} hook"}"#, bin);
        let parsed: serde_json::Value = serde_json::from_str(&hook).unwrap();
        let line = parsed["command"].as_str().unwrap();
        let (program, args) = crate::setup::argv(line).unwrap();
        assert_eq!((program.as_str(), args), (bin, vec!["hook".to_string()]));

        let plain = spell_binary("json", r#"{"command": "${SEMLITH_BIN}"}"#, bin);
        let parsed: serde_json::Value = serde_json::from_str(&plain).unwrap();
        assert_eq!(parsed["command"], bin);

        let toml = spell_binary("toml", "command = \"${SEMLITH_BIN}\"\n", bin);
        assert_eq!(toml, "command = \"/home/a b/it's \\\"$x\\\"/semlith\"\n");

        let sh = spell_binary("sh", "x mcp add semlith -- \"${SEMLITH_BIN}\" mcp\n", bin);
        let (_, args) = crate::setup::argv(sh.trim_end()).unwrap();
        assert!(args.iter().any(|a| a == bin), "{sh}");

        let code = spell_binary(
            "sh",
            r#"code --add-mcp '{"name":"semlith","command":"${SEMLITH_BIN}","args":["mcp"]}'"#,
            bin,
        );
        let (_, args) = crate::setup::argv(&code).unwrap();
        let object: serde_json::Value = serde_json::from_str(&args[1]).unwrap();
        assert_eq!(object["command"], bin);
    }

    /// The resolved path is absolute and carries no `\\?\` prefix.
    ///
    /// A verbatim path is what `canonicalize` hands back on Windows, and one
    /// written into a client's configuration is a defect: several launchers
    /// refuse to execute it, and its backslashes have to be escaped again for
    /// every format these stanzas are written in.
    #[test]
    fn the_resolved_binary_path_is_absolute_and_not_verbatim() {
        let path = binary_path();
        assert!(
            // `C:/Users/…` on Windows, where the drive letter is what makes it
            // absolute and there is no leading separator.
            path.starts_with('/') || path.contains(":/"),
            "the resolved binary path is not absolute: {path}"
        );
        assert!(
            !path.contains('\\'),
            "the resolved binary path is in the verbatim or backslash \
             spelling, which no stanza can embed unescaped: {path}"
        );
    }
}