···231231 let cache_file = mlf_dir.join(".lexicon-cache.toml");
232232 let mut cache = LexiconCache::load(&cache_file)?;
233233234234- // Check if already cached
235235- if cache.lexicons.contains_key(nsid) {
234234+ // Validate NSID format: must be specific (3+ segments) or use wildcard
235235+ validate_nsid_format(nsid)?;
236236+237237+ // Check if it's a wildcard pattern
238238+ let is_wildcard = nsid.ends_with(".*");
239239+ let nsid_pattern = if is_wildcard {
240240+ nsid.strip_suffix(".*").unwrap()
241241+ } else {
242242+ nsid
243243+ };
244244+245245+ // Check if already cached (for specific NSIDs only)
246246+ if !is_wildcard && cache.lexicons.contains_key(nsid) {
236247 println!("Lexicon '{}' is already cached. Skipping fetch.", nsid);
237248 println!(" (Use --force to re-fetch)");
238249 return Ok(());
239250 }
240251241241- // Extract authority from NSID (e.g., "stream.place" from "stream.place.foo")
242242- let authority = extract_authority(nsid)?;
243243- println!("Fetching lexicons for authority: {}", authority);
252252+ // Extract authority from NSID (e.g., "place.stream" from "place.stream.key")
253253+ let authority = extract_authority(nsid_pattern)?;
254254+ println!("Fetching lexicons for pattern: {}", nsid);
244255245256 // Step 1: DNS TXT lookup
246257 let did = resolve_lexicon_did(&authority)?;
···257268 )));
258269 }
259270271271+ let mut processed_count = 0;
272272+260273 // Step 3: Process each record
261274 for record in records {
262275 // Extract NSID from record URI or value
263276 let record_nsid = extract_nsid_from_record(&record)?;
264277265265- // Only process records that match or are under the requested NSID authority
266266- if !record_nsid.starts_with(&authority) {
278278+ // Match against pattern
279279+ let matches = if is_wildcard {
280280+ // Wildcard: match all records starting with the pattern
281281+ record_nsid.starts_with(nsid_pattern) && record_nsid.len() > nsid_pattern.len()
282282+ } else {
283283+ // Specific: exact match only
284284+ record_nsid == nsid
285285+ };
286286+287287+ if !matches {
267288 continue;
268289 }
269290270291 println!(" Processing: {}", record_nsid);
292292+ processed_count += 1;
271293272294 // Save JSON file with directory structure
273295 // e.g., "place.stream.key" -> "place/stream/key.json"
···316338 // Save cache
317339 cache.save(&cache_file)?;
318340319319- println!("✓ Successfully fetched lexicons for {}", nsid);
341341+ if processed_count == 0 {
342342+ return Err(FetchError::HttpError(format!(
343343+ "No lexicons matched pattern: {}",
344344+ nsid
345345+ )));
346346+ }
347347+348348+ println!("✓ Successfully fetched {} lexicon(s) for {}", processed_count, nsid);
349349+ Ok(())
350350+}
351351+352352+fn validate_nsid_format(nsid: &str) -> Result<(), FetchError> {
353353+ // Remove wildcard suffix for validation
354354+ let nsid_base = nsid.strip_suffix(".*").unwrap_or(nsid);
355355+356356+ let parts: Vec<&str> = nsid_base.split('.').collect();
357357+358358+ // NSID must have at least 3 segments (authority + name)
359359+ // e.g., "place.stream.key" or "place.stream.*"
360360+ if parts.len() < 3 {
361361+ return Err(FetchError::InvalidNsid(format!(
362362+ "NSID must have at least 3 segments or use wildcard (e.g., 'place.stream.key' or 'place.stream.*'): {}",
363363+ nsid
364364+ )));
365365+ }
366366+320367 Ok(())
321368}
322369323323-fn extract_authority(nsid: &str) -> Result<String, FetchError> {
370370+fn extract_authority(nsid_pattern: &str) -> Result<String, FetchError> {
324371 // NSID format: authority.name(.name)*
325325- // For "stream.place" or "stream.place.foo", authority is "stream.place"
372372+ // For "place.stream.key", authority is "place.stream"
326373 // Typically authority is the first 2 segments (reversed domain)
327327- let parts: Vec<&str> = nsid.split('.').collect();
374374+ let parts: Vec<&str> = nsid_pattern.split('.').collect();
328375329376 if parts.len() < 2 {
330377 return Err(FetchError::InvalidNsid(format!(
331378 "NSID must have at least 2 segments: {}",
332332- nsid
379379+ nsid_pattern
333380 )));
334381 }
335382
+101-70
website/content/docs/cli/07-fetch.md
···1212# Fetch all dependencies from mlf.toml
1313mlf fetch
14141515-# Fetch a specific namespace
1616-mlf fetch <NAMESPACE>
1515+# Fetch a specific lexicon
1616+mlf fetch <NSID>
1717+1818+# Fetch all lexicons matching a wildcard
1919+mlf fetch <PATTERN>
17201821# Fetch and save to dependencies
1919-mlf fetch <NAMESPACE> --save
2222+mlf fetch <NSID> --save
2023```
21242225**Arguments:**
2323-- `[NAMESPACE]` - Optional namespace to fetch (e.g., `stream.place`, `app.bsky`)
2626+- `[NSID]` - Optional NSID or pattern to fetch:
2727+ - Specific lexicon: `com.example.forum.post`
2828+ - Wildcard pattern: `com.example.forum.*`
2929+ - Real-world example: `app.bsky.feed.*`
24302531**Options:**
2626-- `--save` - Add the namespace to dependencies in `mlf.toml`
3232+- `--save` - Add the NSID/pattern to dependencies in `mlf.toml`
27332834## How It Works
2935···42484349```toml
4450[dependencies]
4545-dependencies = ["stream.place", "app.bsky"]
5151+dependencies = ["com.example.forum.*", "com.example.social.*"]
4652```
47534854Run:
···5561```
5662Fetching 2 dependencies...
57635858-Fetching: stream.place
5959-Fetching lexicons for authority: stream.place
6060- → Resolved DID: did:web:stream.place
6161- → Using PDS: https://stream.place
6262- → Found 5 lexicon record(s)
6363- Processing: stream.place.thread
6464- → Saved JSON to .mlf/lexicons/json/stream.place.thread.json
6565- → Converted to MLF at .mlf/lexicons/mlf/stream.place.thread.mlf
6666-✓ Successfully fetched lexicons for stream.place
6464+Fetching: com.example.forum.*
6565+Fetching lexicons for pattern: com.example.forum.*
6666+ → Resolved DID: did:web:example.com
6767+ → Using PDS: https://example.com
6868+ → Found 3 lexicon record(s)
6969+ Processing: com.example.forum.post
7070+ → Saved JSON to .mlf/lexicons/json/com/example/forum/post.json
7171+ → Converted to MLF at .mlf/lexicons/mlf/com/example/forum/post.mlf
7272+ Processing: com.example.forum.thread
7373+ → Saved JSON to .mlf/lexicons/json/com/example/forum/thread.json
7474+ → Converted to MLF at .mlf/lexicons/mlf/com/example/forum/thread.mlf
7575+✓ Successfully fetched 2 lexicon(s) for com.example.forum.*
67766868-Fetching: app.bsky
7777+Fetching: com.example.social.*
6978...
70797180✓ Successfully fetched all 2 dependencies
7281```
73827474-### Fetch Specific Namespace
8383+### Fetch Specific Lexicon
75847685```bash
7777-mlf fetch stream.place
8686+mlf fetch com.example.forum.post
7887```
79888080-This downloads all lexicons under the `stream.place` authority.
8989+This downloads only the `com.example.forum.post` lexicon.
9090+9191+### Fetch with Wildcard
9292+9393+```bash
9494+mlf fetch com.example.forum.*
9595+```
9696+9797+This downloads all lexicons under the `com.example.forum` namespace.
81988299### Fetch and Save
8310084101```bash
8585-mlf fetch stream.place --save
102102+mlf fetch com.example.forum.* --save
86103```
8710488105This:
8989-1. Downloads the lexicons
9090-2. Adds `"stream.place"` to the dependencies array in `mlf.toml`
106106+1. Downloads all `com.example.forum.*` lexicons
107107+2. Adds `"com.example.forum.*"` to the dependencies array in `mlf.toml`
911083. Creates `mlf.toml` if it doesn't exist
9210993110## Storage Structure
···100117├── .lexicon-cache.toml # Cache metadata
101118└── lexicons/
102119 ├── json/ # Original JSON lexicons
103103- │ ├── stream.place.thread.json
104104- │ └── app.bsky.actor.profile.json
120120+ │ ├── com/
121121+ │ │ └── example/
122122+ │ │ ├── forum/
123123+ │ │ │ ├── post.json
124124+ │ │ │ └── thread.json
125125+ │ │ └── social/
126126+ │ │ └── profile.json
127127+ │ └── app/
128128+ │ └── bsky/
129129+ │ └── feed/
130130+ │ └── post.json
105131 └── mlf/ # Converted MLF format
106106- ├── stream.place.thread.mlf
107107- └── app.bsky.actor.profile.mlf
132132+ ├── com/
133133+ │ └── example/
134134+ │ ├── forum/
135135+ │ │ ├── post.mlf
136136+ │ │ └── thread.mlf
137137+ │ └── social/
138138+ │ └── profile.mlf
139139+ └── app/
140140+ └── bsky/
141141+ └── feed/
142142+ └── post.mlf
108143```
109144110145### Cache File
···112147The `.lexicon-cache.toml` tracks what's been fetched:
113148114149```toml
115115-[[lexicons.stream.place.thread]]
116116-nsid = "stream.place.thread"
150150+[[lexicons."com.example.forum.post"]]
151151+nsid = "com.example.forum.post"
117152fetched_at = "2024-01-15T10:30:00Z"
118118-did = "did:web:stream.place"
153153+did = "did:web:example.com"
154154+hash = "abc123..."
119155```
120156121157## DNS Resolution
122158123123-For a namespace like `stream.place.thread`:
159159+For an NSID like `com.example.forum.post`:
124160125125-1. Extract authority: `stream.place`
126126-2. Reverse for DNS: `place.stream`
127127-3. Query TXT record: `_lexicon.place.stream`
161161+1. Extract authority: `com.example` (first 2 segments)
162162+2. Reverse for DNS: `example.com`
163163+3. Query TXT record: `_lexicon.example.com`
1281644. Parse `did=did:web:...` or `did=did:plc:...`
129165130166**Example DNS record:**
131167```
132132-_lexicon.place.stream. 300 IN TXT "did=did:web:stream.place"
168168+_lexicon.example.com. 300 IN TXT "did=did:web:example.com"
133169```
134170135171## DID Resolution
136172137173### did:web
138174139139-For `did:web:stream.place`, the PDS is `https://stream.place`
175175+For `did:web:example.com`, the PDS is `https://example.com`
140176141177### did:plc
142178···149185### Type References
150186151187```mlf
152152-use stream.place.thread;
188188+use com.example.forum.post;
153189154154-def Reply = {
155155- thread!: stream.place.thread,
190190+record reply {
191191+ post!: com.example.forum.post,
156192 text!: string,
157157-};
193193+}
158194```
159195160196### Code Generation
···178214If you don't have an `mlf.toml`, the fetch command will offer to create one:
179215180216```bash
181181-$ mlf fetch stream.place
217217+$ mlf fetch com.example.forum.*
182218No mlf.toml found in current or parent directories.
183219Would you like to create one in the current directory? (y/n)
184220y
···188224189225## Re-fetching
190226191191-If a namespace is already cached, fetch skips it:
227227+If a lexicon is already cached, fetch skips it:
192228193229```bash
194194-$ mlf fetch stream.place
195195-Lexicon 'stream.place.thread' is already cached. Skipping fetch.
230230+$ mlf fetch com.example.forum.post
231231+Lexicon 'com.example.forum.post' is already cached. Skipping fetch.
196232 (Use --force to re-fetch)
197233```
198234199235To re-fetch:
200236201237```bash
202202-mlf fetch stream.place --force # Not yet implemented
238238+mlf fetch com.example.forum.post --force # Not yet implemented
203239```
204240205241## Error Handling
···207243### DNS Errors
208244209245```
210210-✗ DNS lookup failed: No TXT record found for _lexicon.place.stream
246246+✗ DNS lookup failed: No TXT record found for _lexicon.example.com
211247```
212248213249**Causes:**
···229265### No Records Found
230266231267```
232232-✗ No lexicon records found for stream.place
268268+✗ No lexicons matched pattern: com.example.forum.*
233269```
234270235271**Causes:**
236236-- Namespace exists but has no published lexicons
237237-- Wrong namespace (typo)
272272+- No lexicons exist matching the pattern
273273+- Wrong NSID (typo)
238274- PDS doesn't support lexicon publishing
239275276276+### Invalid NSID Format
277277+278278+```
279279+✗ NSID must have at least 3 segments or use wildcard (e.g., 'com.example.forum.post' or 'com.example.forum.*'): com.example
280280+```
281281+282282+**Solution:**
283283+- Use a specific NSID: `com.example.forum.post`
284284+- Or use a wildcard: `com.example.forum.*`
285285+240286## Best Practices
2412872422881. **Fetch before work** - Always fetch dependencies before coding
···2452914. **Check DNS** - Verify TXT records before fetching
2462925. **Version dependencies** - Consider tracking lexicon versions (future feature)
247293248248-## CI/CD Integration
249249-250250-In your CI pipeline:
251251-252252-```yaml
253253-# GitHub Actions example
254254-- name: Fetch ATProto Lexicons
255255- run: |
256256- mlf fetch
257257-258258-- name: Generate Code
259259- run: |
260260- mlf generate
261261-```
262262-263263-This ensures builds have access to the latest lexicons.
264294265295## Comparison with npm/cargo
266296···279309280310```bash
281311# Check DNS resolution
282282-dig TXT _lexicon.place.stream
312312+dig TXT _lexicon.example.com
283313284314# Test DID resolution
285315curl https://plc.directory/did:plc:abc123
286316```
287317288288-### Invalid Namespace
318318+### Invalid NSID Format
289319290290-Make sure you're using the correct namespace format:
291291-- ✓ `stream.place`
292292-- ✓ `app.bsky`
293293-- ✗ `stream.place.thread` (too specific)
320320+Make sure you're using the correct format:
321321+- ✓ `com.example.forum.post` (specific lexicon)
322322+- ✓ `com.example.forum.*` (wildcard)
323323+- ✓ `app.bsky.feed.*` (real-world wildcard)
324324+- ✗ `com.example` (must be specific or use wildcard)
294325295326### Permission Errors
296327