bitp 1.0.6__py3-none-any.whl

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.
@@ -0,0 +1,1374 @@
1
+ #
2
+ # Copyright (C) 2025 Bruce Ashfield <bruce.ashfield@gmail.com>
3
+ #
4
+ # SPDX-License-Identifier: GPL-2.0-only
5
+ #
6
+ """Recipe command - search and browse BitBake recipes."""
7
+
8
+ import glob
9
+ import hashlib
10
+ import json
11
+ import os
12
+ import re
13
+ import shutil
14
+ import subprocess
15
+ import sys
16
+ import time
17
+ import urllib.request
18
+ import urllib.error
19
+ import urllib.parse
20
+ from typing import Dict, List, Optional, Tuple
21
+
22
+ from ..core import Colors, fzf_available, get_fzf_color_args, get_fzf_preview_resize_bindings, load_defaults
23
+ from .common import (
24
+ resolve_bblayers_path,
25
+ resolve_base_and_layers,
26
+ dedupe_preserve_order,
27
+ layer_display_name,
28
+ extract_layer_paths,
29
+ )
30
+ from .projects import get_preview_window_arg, get_recipe_use_bitbake_layers
31
+
32
+
33
+ # =============================================================================
34
+ # Cache Management
35
+ # =============================================================================
36
+
37
+ def _get_recipe_cache_dir() -> str:
38
+ """Get path to recipe cache directory."""
39
+ cache_dir = os.path.expanduser("~/.cache/bit")
40
+ os.makedirs(cache_dir, exist_ok=True)
41
+ return cache_dir
42
+
43
+
44
+ def _get_cache_hash(layer_paths: List[str]) -> str:
45
+ """Generate cache hash from sorted layer paths."""
46
+ sorted_paths = sorted(layer_paths)
47
+ content = "\n".join(sorted_paths)
48
+ return hashlib.md5(content.encode()).hexdigest()[:12]
49
+
50
+
51
+ def _load_recipe_cache(layer_paths: List[str]) -> Optional[List[dict]]:
52
+ """Load recipe cache if valid (< 5 minutes old)."""
53
+ cache_dir = _get_recipe_cache_dir()
54
+ cache_hash = _get_cache_hash(layer_paths)
55
+ cache_path = os.path.join(cache_dir, f"recipe-index-{cache_hash}.json")
56
+
57
+ if not os.path.isfile(cache_path):
58
+ return None
59
+
60
+ try:
61
+ cache_age = time.time() - os.path.getmtime(cache_path)
62
+ if cache_age > 300: # 5 minutes TTL
63
+ return None
64
+
65
+ with open(cache_path, "r") as f:
66
+ data = json.load(f)
67
+ return data.get("recipes")
68
+ except (json.JSONDecodeError, OSError, KeyError):
69
+ return None
70
+
71
+
72
+ def _save_recipe_cache(layer_paths: List[str], recipes: List[dict]) -> None:
73
+ """Save recipe cache."""
74
+ cache_dir = _get_recipe_cache_dir()
75
+ cache_hash = _get_cache_hash(layer_paths)
76
+ cache_path = os.path.join(cache_dir, f"recipe-index-{cache_hash}.json")
77
+
78
+ try:
79
+ with open(cache_path, "w") as f:
80
+ json.dump({
81
+ "timestamp": time.time(),
82
+ "recipes": recipes,
83
+ }, f)
84
+ except OSError:
85
+ pass
86
+
87
+
88
+ # =============================================================================
89
+ # Recipe Metadata Parsing
90
+ # =============================================================================
91
+
92
+ def _parse_recipe_metadata(filepath: str) -> dict:
93
+ """
94
+ Extract metadata from a .bb recipe file using regex.
95
+
96
+ Returns dict with:
97
+ - SUMMARY, DESCRIPTION, LICENSE, HOMEPAGE, SECTION, DEPENDS
98
+ """
99
+ metadata = {
100
+ "SUMMARY": "",
101
+ "DESCRIPTION": "",
102
+ "LICENSE": "",
103
+ "HOMEPAGE": "",
104
+ "SECTION": "",
105
+ "DEPENDS": "",
106
+ }
107
+
108
+ try:
109
+ with open(filepath, "r", encoding="utf-8", errors="ignore") as f:
110
+ content = f.read()
111
+ except (OSError, IOError):
112
+ return metadata
113
+
114
+ # Parse each variable
115
+ for var in metadata.keys():
116
+ # Match VAR = "value" or VAR = 'value'
117
+ # Also handle ?= and ??= but prefer =
118
+ patterns = [
119
+ rf'^{var}\s*=\s*"([^"]*)"',
120
+ rf"^{var}\s*=\s*'([^']*)'",
121
+ rf'^{var}\s*\?=\s*"([^"]*)"',
122
+ rf"^{var}\s*\?=\s*'([^']*)'",
123
+ ]
124
+
125
+ for pattern in patterns:
126
+ match = re.search(pattern, content, re.MULTILINE)
127
+ if match:
128
+ metadata[var] = match.group(1).strip()
129
+ break
130
+
131
+ return metadata
132
+
133
+
134
+ def _extract_pn_pv(filename: str) -> Tuple[str, str]:
135
+ """
136
+ Extract PN (recipe name) and PV (version) from filename.
137
+
138
+ Handles:
139
+ - recipe_version.bb -> (recipe, version)
140
+ - recipe.bb -> (recipe, "")
141
+ - recipe-name_1.2.3.bb -> (recipe-name, 1.2.3)
142
+ - recipe_%.bbappend -> (recipe, %) for version-wildcard appends
143
+ - recipe.bbappend -> (recipe, "")
144
+ """
145
+ # Remove .bb or .bbappend extension
146
+ if filename.endswith(".bbappend"):
147
+ basename = filename[:-9]
148
+ elif filename.endswith(".bb"):
149
+ basename = filename[:-3]
150
+ else:
151
+ basename = filename
152
+
153
+ # Split on underscore for version
154
+ if "_" in basename:
155
+ parts = basename.rsplit("_", 1)
156
+ pn = parts[0]
157
+ pv = parts[1] if len(parts) > 1 else ""
158
+ else:
159
+ pn = basename
160
+ pv = ""
161
+
162
+ return pn, pv
163
+
164
+
165
+ # =============================================================================
166
+ # Recipe Scanning
167
+ # =============================================================================
168
+
169
+ def _scan_recipes_from_layers(
170
+ layer_paths: List[str],
171
+ force: bool = False,
172
+ progress: bool = True,
173
+ ) -> List[dict]:
174
+ """
175
+ Scan layers for recipes using file glob.
176
+
177
+ Returns list of recipe dicts with:
178
+ - pn, pv, path, layer, layer_name
179
+ - SUMMARY, DESCRIPTION, LICENSE, HOMEPAGE, SECTION, DEPENDS
180
+ """
181
+ if not force:
182
+ cached = _load_recipe_cache(layer_paths)
183
+ if cached is not None:
184
+ return cached
185
+
186
+ recipes = []
187
+ seen_recipes = set() # Track (pn, layer) to avoid duplicates
188
+
189
+ if progress:
190
+ print(Colors.dim("Scanning recipes..."), end=" ", flush=True)
191
+
192
+ total_layers = len(layer_paths)
193
+
194
+ for idx, layer_path in enumerate(layer_paths):
195
+ layer_name = layer_display_name(layer_path)
196
+
197
+ # Glob for recipes: recipes-*/*/*.bb and recipes-*/*/*.bbappend
198
+ pattern = os.path.join(layer_path, "recipes-*", "*", "*.bb")
199
+ bb_files = glob.glob(pattern)
200
+
201
+ # Also check for recipes directly in recipes-*/*.bb (less common)
202
+ pattern2 = os.path.join(layer_path, "recipes-*", "*.bb")
203
+ bb_files.extend(glob.glob(pattern2))
204
+
205
+ # Also glob for bbappend files
206
+ pattern_append = os.path.join(layer_path, "recipes-*", "*", "*.bbappend")
207
+ bb_files.extend(glob.glob(pattern_append))
208
+ pattern_append2 = os.path.join(layer_path, "recipes-*", "*.bbappend")
209
+ bb_files.extend(glob.glob(pattern_append2))
210
+
211
+ for bb_path in bb_files:
212
+ filename = os.path.basename(bb_path)
213
+
214
+ # Determine if this is a recipe or append
215
+ is_append = filename.endswith(".bbappend")
216
+
217
+ pn, pv = _extract_pn_pv(filename)
218
+
219
+ # Track unique entries per layer (separate tracking for recipes vs appends)
220
+ entry_type = "append" if is_append else "recipe"
221
+ key = (pn, layer_path, entry_type)
222
+ if key in seen_recipes:
223
+ continue
224
+ seen_recipes.add(key)
225
+
226
+ # Parse metadata
227
+ metadata = _parse_recipe_metadata(bb_path)
228
+
229
+ recipes.append({
230
+ "pn": pn,
231
+ "pv": pv,
232
+ "path": bb_path,
233
+ "layer": layer_path,
234
+ "layer_name": layer_name,
235
+ "type": entry_type,
236
+ **metadata,
237
+ })
238
+
239
+ if progress:
240
+ print(Colors.dim(f"{len(recipes)} recipes found"))
241
+
242
+ # Save to cache
243
+ _save_recipe_cache(layer_paths, recipes)
244
+
245
+ return recipes
246
+
247
+
248
+ def _scan_configured_recipes(
249
+ bblayers_path: str,
250
+ force: bool = False,
251
+ use_bitbake_layers: bool = True,
252
+ ) -> Tuple[List[dict], List[str]]:
253
+ """
254
+ Get recipes from configured layers.
255
+
256
+ First tries bitbake-layers show-recipes if available and use_bitbake_layers is True,
257
+ then falls back to file scan.
258
+
259
+ Args:
260
+ bblayers_path: Path to bblayers.conf
261
+ force: Force cache rebuild
262
+ use_bitbake_layers: If True, try bitbake-layers first (default True)
263
+
264
+ Returns (recipes, layer_paths).
265
+ """
266
+ # Get configured layer paths
267
+ try:
268
+ from .common import extract_layer_paths
269
+ layer_paths = extract_layer_paths(bblayers_path)
270
+ except SystemExit:
271
+ return [], []
272
+
273
+ # Try bitbake-layers if available and enabled (more accurate, includes bbappends)
274
+ if use_bitbake_layers and shutil.which("bitbake-layers") and not force:
275
+ recipes = _try_bitbake_layers_recipes()
276
+ if recipes:
277
+ return recipes, layer_paths
278
+
279
+ # Fall back to file scan
280
+ recipes = _scan_recipes_from_layers(layer_paths, force=force)
281
+ return recipes, layer_paths
282
+
283
+
284
+ def _try_bitbake_layers_recipes() -> Optional[List[dict]]:
285
+ """
286
+ Try to get recipes via bitbake-layers show-recipes.
287
+
288
+ Returns list of recipe dicts or None if unavailable/failed.
289
+ """
290
+ try:
291
+ result = subprocess.run(
292
+ ["bitbake-layers", "show-recipes", "-f"],
293
+ capture_output=True,
294
+ text=True,
295
+ timeout=60,
296
+ )
297
+ if result.returncode != 0:
298
+ return None
299
+
300
+ # Parse output - format is:
301
+ # recipe-name:
302
+ # layer-name version
303
+ recipes = []
304
+ current_pn = None
305
+ for line in result.stdout.splitlines():
306
+ line = line.rstrip()
307
+ if not line:
308
+ continue
309
+ if line.endswith(":") and not line.startswith(" "):
310
+ current_pn = line[:-1].strip()
311
+ elif line.startswith(" ") and current_pn:
312
+ # Parse " layer-name version"
313
+ parts = line.strip().split()
314
+ if len(parts) >= 2:
315
+ layer_name = parts[0]
316
+ pv = parts[1]
317
+ recipes.append({
318
+ "pn": current_pn,
319
+ "pv": pv,
320
+ "path": "", # bitbake-layers doesn't give path
321
+ "layer": "",
322
+ "layer_name": layer_name,
323
+ "SUMMARY": "",
324
+ "DESCRIPTION": "",
325
+ "LICENSE": "",
326
+ "HOMEPAGE": "",
327
+ "SECTION": "",
328
+ "DEPENDS": "",
329
+ })
330
+
331
+ return recipes if recipes else None
332
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
333
+ return None
334
+
335
+
336
+ # =============================================================================
337
+ # Layer Index API
338
+ # =============================================================================
339
+
340
+ def _get_recipe_index_cache_path(branch: str) -> str:
341
+ """Get path to recipe index API cache file."""
342
+ cache_dir = _get_recipe_cache_dir()
343
+ return os.path.join(cache_dir, f"recipe-api-{branch}.json")
344
+
345
+
346
+ def _fetch_recipe_index(branch: str = "master", force: bool = False, search: str = "") -> Optional[List[dict]]:
347
+ """
348
+ Fetch recipes from layers.openembedded.org API.
349
+
350
+ Args:
351
+ branch: Branch to filter by (default: master)
352
+ force: Force refresh, ignore cache
353
+ search: Optional search query (sent to API for server-side filtering)
354
+
355
+ Returns list of recipe dicts or None on error.
356
+ """
357
+ # Only use cache for full fetches (no search query)
358
+ cache_path = _get_recipe_index_cache_path(branch)
359
+ cache_max_age = 2 * 60 * 60 # 2 hours
360
+
361
+ if not search and not force and os.path.isfile(cache_path):
362
+ try:
363
+ cache_age = time.time() - os.path.getmtime(cache_path)
364
+ if cache_age < cache_max_age:
365
+ with open(cache_path, "r") as f:
366
+ data = json.load(f)
367
+ return data.get("recipes")
368
+ except (json.JSONDecodeError, OSError, KeyError):
369
+ pass
370
+
371
+ # Fetch from API
372
+ # The recipes endpoint is paginated, we need to fetch all pages
373
+ api_base = "https://layers.openembedded.org/layerindex/api/recipes/"
374
+ all_recipes = []
375
+
376
+ # Build URL with optional search parameter
377
+ if search:
378
+ encoded_search = urllib.parse.quote(search)
379
+ next_url = f"{api_base}?format=json&search={encoded_search}"
380
+ print(Colors.dim(f"Searching index for '{search}'..."), end=" ", flush=True)
381
+ else:
382
+ next_url = f"{api_base}?format=json"
383
+ print(Colors.dim("Fetching recipe index..."), end=" ", flush=True)
384
+
385
+ try:
386
+ page = 0
387
+ while next_url and page < 50: # Safety limit
388
+ page += 1
389
+ with urllib.request.urlopen(next_url, timeout=30) as resp:
390
+ data = json.loads(resp.read().decode())
391
+
392
+ # API may return paginated results
393
+ if isinstance(data, list):
394
+ all_recipes.extend(data)
395
+ next_url = None
396
+ elif isinstance(data, dict):
397
+ results = data.get("results", data.get("objects", []))
398
+ all_recipes.extend(results)
399
+ next_url = data.get("next")
400
+ else:
401
+ break
402
+
403
+ print(Colors.dim(f"{len(all_recipes)} recipes"))
404
+
405
+ # Transform to our format
406
+ recipes = []
407
+ for entry in all_recipes:
408
+ # Get branch info - filter by branch
409
+ lb = entry.get("layerbranch", {})
410
+ branch_info = lb.get("branch", {}) if isinstance(lb, dict) else {}
411
+ entry_branch = branch_info.get("name", "") if isinstance(branch_info, dict) else ""
412
+
413
+ # The API structure varies - adapt as needed
414
+ pn = entry.get("pn", entry.get("name", ""))
415
+ pv = entry.get("pv", "")
416
+ summary = entry.get("summary", "")
417
+ description = entry.get("description", "")
418
+ section = entry.get("section", "")
419
+ license_val = entry.get("license", "")
420
+ homepage = entry.get("homepage", "")
421
+
422
+ # Get layer name from layerbranch
423
+ layer_name = ""
424
+ if isinstance(lb, dict):
425
+ layer_info = lb.get("layer", {})
426
+ if isinstance(layer_info, dict):
427
+ layer_name = layer_info.get("name", "")
428
+
429
+ if pn:
430
+ recipes.append({
431
+ "pn": pn,
432
+ "pv": pv,
433
+ "path": "", # Remote, no local path
434
+ "layer": "",
435
+ "layer_name": layer_name,
436
+ "SUMMARY": summary,
437
+ "DESCRIPTION": description,
438
+ "LICENSE": license_val,
439
+ "HOMEPAGE": homepage,
440
+ "SECTION": section,
441
+ "DEPENDS": "",
442
+ "source": "index",
443
+ "branch": entry_branch,
444
+ })
445
+
446
+ # Save to cache
447
+ try:
448
+ with open(cache_path, "w") as f:
449
+ json.dump({"timestamp": time.time(), "recipes": recipes}, f)
450
+ except OSError:
451
+ pass
452
+
453
+ return recipes
454
+
455
+ except urllib.error.URLError as e:
456
+ print(Colors.dim("failed"))
457
+ # Try stale cache
458
+ if os.path.isfile(cache_path):
459
+ try:
460
+ with open(cache_path, "r") as f:
461
+ data = json.load(f)
462
+ if data.get("recipes"):
463
+ print(Colors.yellow(f"Using cached data (network error)"))
464
+ return data["recipes"]
465
+ except (json.JSONDecodeError, OSError, KeyError):
466
+ pass
467
+ print(f"Error fetching recipe index: {e}")
468
+ return None
469
+ except json.JSONDecodeError as e:
470
+ print(Colors.dim("failed"))
471
+ print(f"Error parsing response: {e}")
472
+ return None
473
+
474
+
475
+ # =============================================================================
476
+ # FZF Browser
477
+ # =============================================================================
478
+
479
+ def _build_recipe_menu(
480
+ recipes: List[dict],
481
+ source_name: str,
482
+ ) -> str:
483
+ """Build fzf menu input for recipe list."""
484
+ if not recipes:
485
+ return ""
486
+
487
+ # Separate recipes from appends
488
+ regular_recipes = [r for r in recipes if r.get("type") != "append"]
489
+ append_recipes = [r for r in recipes if r.get("type") == "append"]
490
+
491
+ # Calculate column widths across all entries
492
+ all_recipes = regular_recipes + append_recipes
493
+ max_pn_len = max(len(r["pn"]) for r in all_recipes) if all_recipes else 20
494
+ max_pn_len = min(max_pn_len, 30)
495
+ max_pv_len = max(len(r.get("pv", "")) for r in all_recipes) if all_recipes else 10
496
+ max_pv_len = min(max_pv_len, 15)
497
+ max_layer_len = max(len(r.get("layer_name", "")) for r in all_recipes) if all_recipes else 15
498
+ max_layer_len = min(max_layer_len, 20)
499
+
500
+ menu_lines = []
501
+
502
+ def format_recipe(recipe, is_append=False):
503
+ pn = recipe["pn"][:30]
504
+ pv = recipe.get("pv", "")[:15]
505
+ layer_name = recipe.get("layer_name", "")[:20]
506
+ summary = recipe.get("SUMMARY", "")[:50]
507
+ path = recipe.get("path", "")
508
+
509
+ # Color based on type and source
510
+ is_remote = recipe.get("source") == "index"
511
+ if is_append:
512
+ # Appends shown in yellow/magenta
513
+ colored_pn = Colors.yellow(f"{pn:<{max_pn_len}}")
514
+ type_marker = Colors.dim("(append)")
515
+ elif is_remote:
516
+ colored_pn = Colors.cyan(f"{pn:<{max_pn_len}}")
517
+ type_marker = ""
518
+ else:
519
+ colored_pn = Colors.green(f"{pn:<{max_pn_len}}")
520
+ type_marker = ""
521
+
522
+ if type_marker:
523
+ line = f"{path}\t{colored_pn} {pv:<{max_pv_len}} {layer_name:<{max_layer_len}} {type_marker} {summary}"
524
+ else:
525
+ line = f"{path}\t{colored_pn} {pv:<{max_pv_len}} {layer_name:<{max_layer_len}} {summary}"
526
+ return line
527
+
528
+ # Add regular recipes first
529
+ for recipe in regular_recipes:
530
+ menu_lines.append(format_recipe(recipe, is_append=False))
531
+
532
+ # Add separator if we have both types
533
+ if regular_recipes and append_recipes:
534
+ # fzf separator line (non-selectable)
535
+ menu_lines.append(f"---\t{Colors.dim('── Appends (' + str(len(append_recipes)) + ') ──')}")
536
+
537
+ # Add appends
538
+ for recipe in append_recipes:
539
+ menu_lines.append(format_recipe(recipe, is_append=True))
540
+
541
+ return "\n".join(menu_lines)
542
+
543
+
544
+ def _build_recipe_preview(recipe: dict) -> str:
545
+ """Build preview content for a recipe."""
546
+ lines = []
547
+
548
+ pn = recipe.get("pn", "")
549
+ pv = recipe.get("pv", "")
550
+ layer_name = recipe.get("layer_name", "")
551
+ path = recipe.get("path", "")
552
+
553
+ lines.append(f"{Colors.bold(pn)}")
554
+ if pv:
555
+ lines.append(f"Version: {pv}")
556
+ lines.append(f"Layer: {layer_name}")
557
+ if path:
558
+ lines.append(f"Path: {path}")
559
+ lines.append("")
560
+
561
+ summary = recipe.get("SUMMARY", "")
562
+ if summary:
563
+ lines.append(f"{Colors.cyan('Summary:')}")
564
+ lines.append(f" {summary}")
565
+ lines.append("")
566
+
567
+ description = recipe.get("DESCRIPTION", "")
568
+ if description:
569
+ lines.append(f"{Colors.cyan('Description:')}")
570
+ # Word wrap
571
+ for line in description.split("\n"):
572
+ lines.append(f" {line}")
573
+ lines.append("")
574
+
575
+ license_val = recipe.get("LICENSE", "")
576
+ if license_val:
577
+ lines.append(f"{Colors.cyan('License:')} {license_val}")
578
+
579
+ homepage = recipe.get("HOMEPAGE", "")
580
+ if homepage:
581
+ lines.append(f"{Colors.cyan('Homepage:')} {homepage}")
582
+
583
+ section = recipe.get("SECTION", "")
584
+ if section:
585
+ lines.append(f"{Colors.cyan('Section:')} {section}")
586
+
587
+ depends = recipe.get("DEPENDS", "")
588
+ if depends:
589
+ lines.append(f"{Colors.cyan('Depends:')} {depends}")
590
+
591
+ return "\n".join(lines)
592
+
593
+
594
+ def _recipe_fzf_browser(
595
+ recipes: List[dict],
596
+ source_name: str,
597
+ query: str = "",
598
+ allow_source_switch: bool = True,
599
+ is_remote: bool = False,
600
+ available_sources: List[str] = None,
601
+ ):
602
+ """
603
+ Interactive fzf browser for recipes.
604
+
605
+ Key bindings:
606
+ - Enter: View recipe in $EDITOR or less
607
+ - ctrl-e: Edit recipe in $EDITOR (disabled for remote)
608
+ - alt-c: Copy path to clipboard
609
+ - ctrl-f: Show full file in preview
610
+ - ctrl-i: Show metadata info in preview
611
+ - alt-s: Cycle through sources (Configured → Local → Index)
612
+ - left: Go back to source selection menu
613
+ - pgup/pgdn: Scroll preview
614
+ - ?: Toggle preview
615
+ - esc: Quit
616
+
617
+ Returns:
618
+ - 0: Normal exit
619
+ - 1: Error
620
+ - "menu": Go back to source selection menu
621
+ - ("switch", source): Switch to specified source
622
+ """
623
+ if not recipes:
624
+ print("No recipes found.")
625
+ return 1
626
+
627
+ if not fzf_available():
628
+ # Fall back to text list
629
+ for recipe in recipes:
630
+ pn = recipe.get("pn", "")
631
+ pv = recipe.get("pv", "")
632
+ layer_name = recipe.get("layer_name", "")
633
+ print(f" {Colors.green(pn):<30} {pv:<15} {layer_name}")
634
+ return 0
635
+
636
+ # Build menu
637
+ menu_input = _build_recipe_menu(recipes, source_name)
638
+
639
+ # Build preview script
640
+ # We'll use temp files - one for data, one for the preview script
641
+ import tempfile
642
+ preview_data = {r.get("path", r.get("pn", "")): r for r in recipes}
643
+ preview_data_file = tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False)
644
+ json.dump(preview_data, preview_data_file)
645
+ preview_data_file.close()
646
+
647
+ # Create a preview script file to avoid shell escaping issues
648
+ preview_script = tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False)
649
+ preview_script.write(f'''#!/usr/bin/env python3
650
+ import json
651
+ import sys
652
+
653
+ key = sys.argv[1] if len(sys.argv) > 1 else ""
654
+ # Strip any surrounding quotes that fzf might add
655
+ key = key.strip("'\\\"")
656
+ with open("{preview_data_file.name}", "r") as f:
657
+ data = json.load(f)
658
+
659
+ recipe = data.get(key, {{}})
660
+ if not recipe:
661
+ print(f"No data for: {{key}}")
662
+ sys.exit(0)
663
+
664
+ pn = recipe.get("pn", "")
665
+ pv = recipe.get("pv", "")
666
+ layer_name = recipe.get("layer_name", "")
667
+ path = recipe.get("path", "")
668
+ summary = recipe.get("SUMMARY", "")
669
+ description = recipe.get("DESCRIPTION", "")
670
+ license_val = recipe.get("LICENSE", "")
671
+ homepage = recipe.get("HOMEPAGE", "")
672
+ section = recipe.get("SECTION", "")
673
+ depends = recipe.get("DEPENDS", "")
674
+
675
+ print(f"\\033[1m{{pn}}\\033[0m")
676
+ if pv:
677
+ print(f"Version: {{pv}}")
678
+ print(f"Layer: {{layer_name}}")
679
+ if path:
680
+ print(f"Path: {{path}}")
681
+ print()
682
+ if summary:
683
+ print("\\033[36mSummary:\\033[0m")
684
+ print(f" {{summary}}")
685
+ print()
686
+ if description:
687
+ print("\\033[36mDescription:\\033[0m")
688
+ for line in description.split("\\n"):
689
+ print(f" {{line}}")
690
+ print()
691
+ if license_val:
692
+ print(f"\\033[36mLicense:\\033[0m {{license_val}}")
693
+ if homepage:
694
+ print(f"\\033[36mHomepage:\\033[0m {{homepage}}")
695
+ if section:
696
+ print(f"\\033[36mSection:\\033[0m {{section}}")
697
+ if depends:
698
+ print(f"\\033[36mDepends:\\033[0m {{depends}}")
699
+ ''')
700
+ preview_script.close()
701
+
702
+ # Preview commands - metadata view and full file view
703
+ # Note: fzf's {1} is already shell-quoted, don't add extra quotes
704
+ preview_metadata = f'python3 "{preview_script.name}" {{1}}'
705
+ # For file preview, check if file exists (remote recipes won't have local path)
706
+ preview_file = 'if [ -f {1} ]; then cat {1}; else echo "Remote recipe - no local file available"; echo ""; echo "Path: {1}"; fi'
707
+
708
+ # Build header - three lines to fit all keybindings (use ctrl/alt so typing works)
709
+ header_line1 = f"[{source_name}] Type to filter"
710
+ header_line2 = "Enter=view | "
711
+ if not is_remote:
712
+ header_line2 += "ctrl-e=edit | "
713
+ header_line2 += "alt-c=copy | alt-d=deps | ctrl-f=file | ctrl-i=info"
714
+ header_line3 = "alt-s=switch source | ←=back | pgup/pgdn=scroll | esc=quit"
715
+ header = f"{header_line1}\n{header_line2}\n{header_line3}"
716
+
717
+ # Determine expected keys (use modifiers so typing works)
718
+ expect_keys = ["ctrl-e", "alt-c", "alt-d", "esc", "left", "alt-s"]
719
+
720
+ preview_window = get_preview_window_arg("50%")
721
+
722
+ fzf_args = [
723
+ "fzf",
724
+ "--no-multi",
725
+ "--ansi",
726
+ "--height", "100%",
727
+ "--layout=reverse-list", # Prompt at bottom, A-Z top to bottom, first item selected
728
+ "--no-hscroll", # Keep recipe name visible, don't scroll to match
729
+ "--header", header,
730
+ "--prompt", f"Recipe ({source_name}): ",
731
+ "--with-nth", "2..",
732
+ "--delimiter", "\t",
733
+ "--preview", preview_metadata,
734
+ "--preview-window", preview_window,
735
+ "--bind", "?:toggle-preview",
736
+ "--bind", f"ctrl-f:change-preview({preview_file})",
737
+ "--bind", f"ctrl-i:change-preview({preview_metadata})",
738
+ "--bind", "pgup:preview-page-up",
739
+ "--bind", "pgdn:preview-page-down",
740
+ "--bind", "esc:abort",
741
+ "--expect", ",".join(expect_keys),
742
+ ]
743
+
744
+ # Add initial query if provided
745
+ if query:
746
+ fzf_args.extend(["--query", query])
747
+
748
+ fzf_args.extend(get_fzf_preview_resize_bindings())
749
+ fzf_args.extend(get_fzf_color_args())
750
+
751
+ try:
752
+ result = subprocess.run(
753
+ fzf_args,
754
+ input=menu_input,
755
+ stdout=subprocess.PIPE,
756
+ text=True,
757
+ )
758
+ except FileNotFoundError:
759
+ return 1
760
+ finally:
761
+ # Clean up temp files
762
+ for f in [preview_data_file.name, preview_script.name]:
763
+ try:
764
+ os.unlink(f)
765
+ except OSError:
766
+ pass
767
+
768
+ if result.returncode != 0 or not result.stdout.strip():
769
+ return 0
770
+
771
+ # Parse output
772
+ lines = result.stdout.split("\n")
773
+ key = lines[0].strip() if lines else ""
774
+ selected = lines[1].split("\t")[0].strip() if len(lines) > 1 else ""
775
+
776
+ if not selected:
777
+ return 0
778
+
779
+ # Find the selected recipe
780
+ recipe = None
781
+ for r in recipes:
782
+ if r.get("path") == selected or r.get("pn") == selected:
783
+ recipe = r
784
+ break
785
+
786
+ if not recipe:
787
+ return 0
788
+
789
+ # Handle actions (modifier keys so typing works)
790
+ if key == "alt-c":
791
+ # Copy path to clipboard
792
+ path = recipe.get("path", recipe.get("pn", ""))
793
+ if path:
794
+ _copy_to_clipboard(path)
795
+ print(f"Copied: {path}")
796
+ return _recipe_fzf_browser(recipes, source_name, query, allow_source_switch, is_remote, available_sources)
797
+
798
+ if key == "ctrl-e" and not is_remote:
799
+ # Edit in $EDITOR
800
+ path = recipe.get("path", "")
801
+ if path and os.path.isfile(path):
802
+ editor = os.environ.get("EDITOR", "vi")
803
+ subprocess.run([editor, path])
804
+ return _recipe_fzf_browser(recipes, source_name, query, allow_source_switch, is_remote, available_sources)
805
+
806
+ if key == "alt-d" and not is_remote:
807
+ # Show recipe dependencies
808
+ path = recipe.get("path", "")
809
+ pn = recipe.get("pn", "")
810
+ if path and os.path.isfile(path):
811
+ from .deps import _parse_recipe_depends, _render_recipe_tree_ascii, RecipeInfo
812
+ depends, rdepends = _parse_recipe_depends(path)
813
+ layer_name = recipe.get("layer_name", os.path.basename(os.path.dirname(os.path.dirname(os.path.dirname(path)))))
814
+ recipe_info = RecipeInfo(
815
+ name=pn,
816
+ version=recipe.get("pv", ""),
817
+ path=path,
818
+ layer=layer_name,
819
+ depends=depends,
820
+ rdepends=rdepends,
821
+ )
822
+ print(f"\n{_render_recipe_tree_ascii(recipe_info, include_rdepends=True)}\n")
823
+ input("Press Enter to continue...")
824
+ return _recipe_fzf_browser(recipes, source_name, query, allow_source_switch, is_remote, available_sources)
825
+
826
+ if key == "esc":
827
+ return 0
828
+
829
+ if key == "left":
830
+ # Go back to menu
831
+ return "menu"
832
+
833
+ if key == "alt-s":
834
+ # Cycle to next source
835
+ if available_sources and len(available_sources) > 1:
836
+ # Map display names to source keys
837
+ source_key_map = {
838
+ "Configured": "configured",
839
+ "Local": "local",
840
+ "Index": "index",
841
+ }
842
+ # Find current source key - handle combined names like "Configured+Local"
843
+ current_key = None
844
+ source_name_lower = source_name.lower()
845
+ for display, key_name in source_key_map.items():
846
+ if source_name == display or source_name_lower == key_name:
847
+ current_key = key_name
848
+ break
849
+
850
+ # If source_name is combined (e.g., "Configured+Local"), use first available as current
851
+ if current_key is None and "+" in source_name:
852
+ # Take the first part
853
+ first_part = source_name.split("+")[0]
854
+ current_key = source_key_map.get(first_part, first_part.lower())
855
+
856
+ if current_key and current_key in available_sources:
857
+ idx = available_sources.index(current_key)
858
+ next_idx = (idx + 1) % len(available_sources)
859
+ next_source = available_sources[next_idx]
860
+ return ("switch", next_source)
861
+ elif available_sources:
862
+ # Just pick the first available source if current not found
863
+ return ("switch", available_sources[0])
864
+ # Fall back to menu if can't cycle
865
+ return "menu"
866
+
867
+ # Enter - view recipe
868
+ path = recipe.get("path", "")
869
+ if path and os.path.isfile(path):
870
+ # Use $EDITOR if set, otherwise less
871
+ pager = os.environ.get("PAGER", "less")
872
+ subprocess.run([pager, path])
873
+ else:
874
+ # Remote recipe - show preview
875
+ preview = _build_recipe_preview(recipe)
876
+ pager = os.environ.get("PAGER", "less")
877
+ subprocess.run([pager], input=preview, text=True)
878
+
879
+ return _recipe_fzf_browser(recipes, source_name, query, allow_source_switch, is_remote, available_sources)
880
+
881
+
882
+ def _copy_to_clipboard(text: str) -> bool:
883
+ """Copy text to clipboard."""
884
+ clipboard_cmds = [
885
+ ["xclip", "-selection", "clipboard"],
886
+ ["xsel", "--clipboard", "--input"],
887
+ ["pbcopy"],
888
+ ]
889
+
890
+ for cmd in clipboard_cmds:
891
+ if shutil.which(cmd[0]):
892
+ try:
893
+ subprocess.run(cmd, input=text, text=True, check=True)
894
+ return True
895
+ except subprocess.CalledProcessError:
896
+ continue
897
+ return False
898
+
899
+
900
+ # =============================================================================
901
+ # Source Selection Menu
902
+ # =============================================================================
903
+
904
+ def _source_selection_menu(has_local: bool = True) -> Optional[Tuple[str, str, List[str], List[str], str]]:
905
+ """
906
+ Show unified source selection menu with toggles and browse/search options.
907
+
908
+ Returns:
909
+ - Tuple of (action, query, sources, search_fields, sort_by) where:
910
+ - action: "browse" or "search"
911
+ - query: what was typed in the prompt
912
+ - sources: list of enabled sources ["configured", "local", "index"]
913
+ - search_fields: list of fields to search ["name", "summary", "description"]
914
+ - sort_by: "name" or "layer"
915
+ - None: Cancelled
916
+ """
917
+ if not fzf_available():
918
+ # Text-based fallback
919
+ print("\nRecipe Search:")
920
+ if has_local:
921
+ print(" Sources: configured layers, local layers")
922
+ source = input("Enter search term (or empty to browse all): ").strip()
923
+ if source:
924
+ return ("search", source, ["configured", "local"], ["name"], "name")
925
+ return ("browse", "", ["configured", "local"], ["name"], "name")
926
+ else:
927
+ print(" Source: layer index (no local project)")
928
+ source = input("Enter search term (or empty to browse all): ").strip()
929
+ if source:
930
+ return ("search", source, ["index"], ["name"], "name")
931
+ return ("browse", "", ["index"], ["name"], "name")
932
+
933
+ # Build menu with toggles and actions
934
+ menu_lines = []
935
+
936
+ # Source toggles (selected by default based on has_local)
937
+ if has_local:
938
+ menu_lines.append("toggle:configured\t [x] Configured (layers in bblayers.conf)")
939
+ menu_lines.append("toggle:local\t [x] Local (configured + discovered)")
940
+ menu_lines.append("toggle:index\t [ ] Layer index (remote API)")
941
+
942
+ # Separator and actions
943
+ menu_lines.append("---\t " + "─" * 45)
944
+ menu_lines.append("browse\t > Browse (show all, type to filter)")
945
+ menu_lines.append("search\t > Search (type query above, Enter to search)")
946
+
947
+ menu_input = "\n".join(menu_lines)
948
+
949
+ # Track toggle states - sources, search fields, and sort
950
+ toggle_states = {
951
+ "configured": has_local,
952
+ "local": has_local,
953
+ "index": not has_local,
954
+ "name": True, # Search recipe name (default on)
955
+ "summary": False, # Search SUMMARY field
956
+ "description": False, # Search DESCRIPTION field
957
+ "sort_by_layer": False, # Sort by layer instead of name
958
+ }
959
+
960
+ # Create temp files for dynamic menu reload
961
+ import tempfile
962
+
963
+ state_file = tempfile.NamedTemporaryFile(mode='w', suffix='.state', delete=False)
964
+ menu_script = tempfile.NamedTemporaryFile(mode='w', suffix='.sh', delete=False)
965
+
966
+ # Write initial state
967
+ json.dump(toggle_states, state_file)
968
+ state_file.close()
969
+
970
+ # Write menu generation script
971
+ # Order: first items appear at bottom (near prompt) in default fzf layout
972
+ menu_script.write(f'''#!/bin/bash
973
+ state=$(cat "{state_file.name}")
974
+ configured=$(echo "$state" | python3 -c "import sys,json; print('[x]' if json.load(sys.stdin).get('configured') else '[ ]')")
975
+ local=$(echo "$state" | python3 -c "import sys,json; print('[x]' if json.load(sys.stdin).get('local') else '[ ]')")
976
+ index=$(echo "$state" | python3 -c "import sys,json; print('[x]' if json.load(sys.stdin).get('index') else '[ ]')")
977
+ name=$(echo "$state" | python3 -c "import sys,json; print('[x]' if json.load(sys.stdin).get('name') else '[ ]')")
978
+ summary=$(echo "$state" | python3 -c "import sys,json; print('[x]' if json.load(sys.stdin).get('summary') else '[ ]')")
979
+ description=$(echo "$state" | python3 -c "import sys,json; print('[x]' if json.load(sys.stdin).get('description') else '[ ]')")
980
+ sort_layer=$(echo "$state" | python3 -c "import sys,json; d=json.load(sys.stdin); print('layer' if d.get('sort_by_layer') else 'name')")
981
+ # Actions first (at bottom, near prompt)
982
+ echo "search > Search (type query below, Enter)"
983
+ echo "browse > Browse (show all, filter in browser)"
984
+ echo "--- ── Actions ─────────────────────────────────"
985
+ # Sort option
986
+ echo "toggle:sort_by_layer Sort: $sort_layer"
987
+ echo "--- ── Options ─────────────────────────────────"
988
+ # Source toggles (middle section)
989
+ echo "toggle:index $index Index (remote API)"
990
+ ''')
991
+ if has_local:
992
+ menu_script.write('''echo "toggle:local $local Local (configured + discovered)"
993
+ echo "toggle:configured $configured Config (layers in bblayers.conf)"
994
+ ''')
995
+ menu_script.write('''echo "--- ── Sources ─────────────────────────────────"
996
+ # Search field toggles (at top)
997
+ echo "toggle:description $description Description"
998
+ echo "toggle:summary $summary Summary"
999
+ echo "toggle:name $name Name"
1000
+ echo "--- ── Search In ───────────────────────────────"
1001
+ ''')
1002
+ menu_script.close()
1003
+ os.chmod(menu_script.name, 0o755)
1004
+
1005
+ # Write toggle script
1006
+ toggle_script = tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False)
1007
+ toggle_script.write(f'''#!/usr/bin/env python3
1008
+ import sys, json
1009
+ key = sys.argv[1] if len(sys.argv) > 1 else ""
1010
+ if key.startswith("toggle:"):
1011
+ source = key.replace("toggle:", "")
1012
+ with open("{state_file.name}", "r") as f:
1013
+ state = json.load(f)
1014
+ state[source] = not state.get(source, False)
1015
+ with open("{state_file.name}", "w") as f:
1016
+ json.dump(state, f)
1017
+ ''')
1018
+ toggle_script.close()
1019
+ os.chmod(toggle_script.name, 0o755)
1020
+
1021
+ try:
1022
+ # Generate initial menu
1023
+ initial_menu = subprocess.check_output(["bash", menu_script.name], text=True)
1024
+
1025
+ fzf_args = [
1026
+ "fzf",
1027
+ "--no-multi",
1028
+ "--no-sort",
1029
+ "--ansi",
1030
+ "--disabled", # Disable filtering - query is just captured, not used to filter
1031
+ "--height", "~20",
1032
+ "--header", "Space=toggle | Enter=select | Type query for Search | Esc=quit",
1033
+ "--prompt", "Query: ",
1034
+ "--print-query",
1035
+ "--with-nth", "2..",
1036
+ "--delimiter", "\t",
1037
+ "--bind", f"space:execute-silent(python3 {toggle_script.name} {{1}})+reload(bash {menu_script.name})",
1038
+ "--bind", "esc:abort",
1039
+ ]
1040
+ fzf_args.extend(get_fzf_color_args())
1041
+
1042
+ result = subprocess.run(
1043
+ fzf_args,
1044
+ input=initial_menu,
1045
+ stdout=subprocess.PIPE,
1046
+ text=True,
1047
+ )
1048
+
1049
+ if result.returncode != 0:
1050
+ return None
1051
+
1052
+ # Read final toggle state
1053
+ with open(state_file.name, "r") as f:
1054
+ toggle_states = json.load(f)
1055
+
1056
+ # Parse output: query on first line, selection on second
1057
+ lines = result.stdout.split("\n")
1058
+ query = lines[0].strip() if len(lines) > 0 else ""
1059
+ selected = lines[1].split("\t")[0].strip() if len(lines) > 1 else ""
1060
+
1061
+ # Handle separator - user somehow selected it, ignore
1062
+ if selected == "---" or selected.startswith("toggle:"):
1063
+ return None
1064
+
1065
+ # Handle browse/search actions
1066
+ sort_by = "layer" if toggle_states.get("sort_by_layer") else "name"
1067
+ if selected == "browse":
1068
+ sources = [s for s, enabled in toggle_states.items() if enabled and s in ("configured", "local", "index")]
1069
+ search_fields = [s for s, enabled in toggle_states.items() if enabled and s in ("name", "summary", "description")]
1070
+ if not sources:
1071
+ print("No sources selected. Enable at least one source.")
1072
+ return None
1073
+ return ("browse", query, sources, search_fields or ["name"], sort_by)
1074
+ elif selected == "search":
1075
+ sources = [s for s, enabled in toggle_states.items() if enabled and s in ("configured", "local", "index")]
1076
+ search_fields = [s for s, enabled in toggle_states.items() if enabled and s in ("name", "summary", "description")]
1077
+ if not sources:
1078
+ print("No sources selected. Enable at least one source.")
1079
+ return None
1080
+ if not query:
1081
+ print("No query entered. Type a search term first.")
1082
+ return None
1083
+ return ("search", query, sources, search_fields or ["name"], sort_by)
1084
+
1085
+ return None
1086
+
1087
+ finally:
1088
+ # Clean up temp files
1089
+ for f in [state_file.name, menu_script.name, toggle_script.name]:
1090
+ try:
1091
+ os.unlink(f)
1092
+ except OSError:
1093
+ pass
1094
+
1095
+
1096
+ # =============================================================================
1097
+ # Main Entry Point
1098
+ # =============================================================================
1099
+
1100
+ def run_recipe(args) -> int:
1101
+ """
1102
+ Main entry point for recipe command.
1103
+
1104
+ Args:
1105
+ args: Parsed command line arguments with:
1106
+ - query: Optional search query
1107
+ - browse: Browse all local recipes
1108
+ - configured: Search configured layers only
1109
+ - local: Search all local layers
1110
+ - index: Search layer index API
1111
+ - layer: Filter to specific layer
1112
+ - section: Filter by SECTION
1113
+ - list: Text output (no fzf)
1114
+ - force: Rebuild cache
1115
+ - branch: Branch for layer index (default: master)
1116
+ """
1117
+ query = getattr(args, "query", None) or ""
1118
+ browse_mode = getattr(args, "browse", False)
1119
+ configured_mode = getattr(args, "configured", False)
1120
+ local_mode = getattr(args, "local", False)
1121
+ index_mode = getattr(args, "index", False)
1122
+ layer_filter = getattr(args, "layer", None)
1123
+ section_filter = getattr(args, "section", None)
1124
+ list_mode = getattr(args, "list", False)
1125
+ force = getattr(args, "force", False)
1126
+ branch = getattr(args, "branch", "master")
1127
+ bblayers = getattr(args, "bblayers", None)
1128
+
1129
+ # Load defaults for configuration options
1130
+ defaults_file = getattr(args, "defaults_file", ".bit.defaults")
1131
+ defaults = load_defaults(defaults_file)
1132
+
1133
+ # Configuration: use bitbake-layers for configured recipes (default: True)
1134
+ # Configurable via 'bit config' -> Settings -> Recipe Scan
1135
+ use_bitbake_layers = get_recipe_use_bitbake_layers()
1136
+
1137
+ # Search field options from CLI
1138
+ search_name = getattr(args, "name", False)
1139
+ search_summary = getattr(args, "summary", False)
1140
+ search_description = getattr(args, "description", False)
1141
+
1142
+ # Sort option from CLI
1143
+ sort_by = getattr(args, "sort", None) or "name" # default: sort by name
1144
+
1145
+ # Check if we have a local project context
1146
+ bblayers_path = resolve_bblayers_path(bblayers)
1147
+ has_local = bblayers_path is not None
1148
+
1149
+ # Determine mode from CLI flags or show unified menu
1150
+ action = "browse" # default
1151
+ sources = []
1152
+ search_fields = ["name"] # default: search name only
1153
+
1154
+ # Build search_fields from CLI options (if any specified)
1155
+ if search_name or search_summary or search_description:
1156
+ search_fields = []
1157
+ if search_name:
1158
+ search_fields.append("name")
1159
+ if search_summary:
1160
+ search_fields.append("summary")
1161
+ if search_description:
1162
+ search_fields.append("description")
1163
+
1164
+ # Track if we're using interactive menu (allows looping back)
1165
+ use_interactive_menu = False
1166
+
1167
+ if browse_mode:
1168
+ action = "browse"
1169
+ sources = ["configured", "local"] if has_local else ["index"]
1170
+ elif configured_mode:
1171
+ # If query provided, use search action to pre-filter
1172
+ action = "search" if query else "browse"
1173
+ sources = ["configured"]
1174
+ elif local_mode:
1175
+ action = "search" if query else "browse"
1176
+ sources = ["local"]
1177
+ elif index_mode:
1178
+ action = "search" if query else "browse"
1179
+ sources = ["index"]
1180
+ elif not has_local:
1181
+ # No local context - auto-select index
1182
+ action = "browse"
1183
+ sources = ["index"]
1184
+ print(Colors.dim("No local project found, using layer index."))
1185
+ else:
1186
+ use_interactive_menu = True
1187
+
1188
+ # Main loop - allows returning to menu from browser
1189
+ while True:
1190
+ if use_interactive_menu:
1191
+ # Show unified source selection menu
1192
+ result = _source_selection_menu(has_local)
1193
+ if not result:
1194
+ return 0
1195
+ action, menu_query, sources, menu_search_fields, menu_sort_by = result
1196
+ # Use menu query
1197
+ query = menu_query
1198
+ # Use menu search fields if no CLI options specified
1199
+ if not (search_name or search_summary or search_description):
1200
+ search_fields = menu_search_fields
1201
+ # Use menu sort_by if no CLI --sort specified
1202
+ if not getattr(args, "sort", None):
1203
+ sort_by = menu_sort_by
1204
+
1205
+ # Fetch recipes from all enabled sources
1206
+ all_recipes = []
1207
+ source_names = []
1208
+ is_remote = False
1209
+
1210
+ # Helper to detect if query looks like a regex pattern
1211
+ def looks_like_regex(q: str) -> bool:
1212
+ regex_chars = set('^$.*+?[](){}|\\')
1213
+ return any(c in regex_chars for c in q)
1214
+
1215
+ def fetch_from_source(src: str) -> List[dict]:
1216
+ nonlocal is_remote
1217
+ if src == "index":
1218
+ is_remote = True
1219
+ # Pass query for server-side search (only for search action)
1220
+ api_search = query if action == "search" else ""
1221
+ # Warn if query looks like regex (index API doesn't support regex)
1222
+ if api_search and looks_like_regex(api_search):
1223
+ print(Colors.yellow(f"Warning: Index API doesn't support regex. '{api_search}' will be treated as literal text."))
1224
+ return _fetch_recipe_index(branch, force, search=api_search) or []
1225
+ elif src == "local":
1226
+ if has_local:
1227
+ pairs, _ = resolve_base_and_layers(bblayers_path, defaults, discover_all=True)
1228
+ layer_paths = dedupe_preserve_order(layer for layer, _ in pairs)
1229
+ return _scan_recipes_from_layers(layer_paths, force=force)
1230
+ return []
1231
+ elif src == "configured":
1232
+ if has_local:
1233
+ recipes, _ = _scan_configured_recipes(bblayers_path, force=force, use_bitbake_layers=use_bitbake_layers)
1234
+ return recipes
1235
+ return []
1236
+ return []
1237
+
1238
+ for src in sources:
1239
+ recipes = fetch_from_source(src)
1240
+ all_recipes.extend(recipes)
1241
+ source_names.append(src.capitalize())
1242
+
1243
+ # Dedupe by (pn, layer_name)
1244
+ seen = set()
1245
+ unique_recipes = []
1246
+ for r in all_recipes:
1247
+ key = (r.get("pn", ""), r.get("layer_name", ""))
1248
+ if key not in seen:
1249
+ seen.add(key)
1250
+ unique_recipes.append(r)
1251
+ recipes = unique_recipes
1252
+
1253
+ source_name = "+".join(source_names) if source_names else "Local"
1254
+
1255
+ # Apply filters
1256
+ if layer_filter:
1257
+ recipes = [r for r in recipes if layer_filter.lower() in r.get("layer_name", "").lower()]
1258
+
1259
+ if section_filter:
1260
+ recipes = [r for r in recipes if section_filter.lower() in r.get("SECTION", "").lower()]
1261
+
1262
+ # Helper to check if recipe matches query based on search_fields (supports regex)
1263
+ def recipe_matches(r: dict, q: str) -> bool:
1264
+ try:
1265
+ pattern = re.compile(q, re.IGNORECASE)
1266
+ except re.error:
1267
+ # Fall back to literal match if invalid regex
1268
+ q_lower = q.lower()
1269
+ if "name" in search_fields and q_lower in r.get("pn", "").lower():
1270
+ return True
1271
+ if "summary" in search_fields and q_lower in r.get("SUMMARY", "").lower():
1272
+ return True
1273
+ if "description" in search_fields and q_lower in r.get("DESCRIPTION", "").lower():
1274
+ return True
1275
+ return False
1276
+
1277
+ if "name" in search_fields and pattern.search(r.get("pn", "")):
1278
+ return True
1279
+ if "summary" in search_fields and pattern.search(r.get("SUMMARY", "")):
1280
+ return True
1281
+ if "description" in search_fields and pattern.search(r.get("DESCRIPTION", "")):
1282
+ return True
1283
+ return False
1284
+
1285
+ # For "search" action, filter by query before showing
1286
+ if action == "search" and query:
1287
+ pre_filter_count = len(recipes)
1288
+ recipes = [r for r in recipes if recipe_matches(r, query)]
1289
+ fields_str = "+".join(search_fields)
1290
+ print(f"Search '{query}' in [{fields_str}]: {len(recipes)} matches (from {pre_filter_count} recipes)")
1291
+ initial_query = "" # Don't re-filter in browser
1292
+ elif list_mode and query:
1293
+ # For --list mode, also filter
1294
+ recipes = [r for r in recipes if recipe_matches(r, query)]
1295
+ initial_query = ""
1296
+ else:
1297
+ initial_query = query
1298
+
1299
+ # Sort recipes
1300
+ if sort_by == "layer":
1301
+ # Sort by layer name, then by recipe name within each layer
1302
+ recipes = sorted(recipes, key=lambda r: (r.get("layer_name", "").lower(), r.get("pn", "").lower()))
1303
+ else:
1304
+ # Sort by recipe name A-Z (default)
1305
+ recipes = sorted(recipes, key=lambda r: r.get("pn", "").lower())
1306
+
1307
+ # Output
1308
+ if list_mode:
1309
+ # Text output
1310
+ if not recipes:
1311
+ print("No recipes found.")
1312
+ return 1
1313
+
1314
+ for recipe in recipes:
1315
+ pn = recipe.get("pn", "")
1316
+ pv = recipe.get("pv", "")
1317
+ layer_name = recipe.get("layer_name", "")
1318
+ path = recipe.get("path", "")
1319
+ print(f"{pn:<30} {pv:<15} {layer_name:<20} {path}")
1320
+ return 0
1321
+
1322
+ # Determine available sources for cycling
1323
+ available_sources = []
1324
+ if has_local:
1325
+ available_sources.extend(["configured", "local"])
1326
+ available_sources.append("index")
1327
+
1328
+ # FZF browser with source cycling
1329
+ current_source_name = source_name
1330
+ current_recipes = recipes
1331
+ current_is_remote = is_remote
1332
+
1333
+ while True:
1334
+ browser_result = _recipe_fzf_browser(
1335
+ current_recipes,
1336
+ current_source_name,
1337
+ query=initial_query,
1338
+ allow_source_switch=has_local,
1339
+ is_remote=current_is_remote,
1340
+ available_sources=available_sources,
1341
+ )
1342
+
1343
+ # Check if user wants to switch source inline
1344
+ if isinstance(browser_result, tuple) and browser_result[0] == "switch":
1345
+ next_source = browser_result[1]
1346
+ print(f"Switching to {next_source.capitalize()}...")
1347
+
1348
+ # Fetch from the new source
1349
+ current_is_remote = (next_source == "index")
1350
+ current_recipes = fetch_from_source(next_source)
1351
+
1352
+ # Apply filters
1353
+ if layer_filter:
1354
+ current_recipes = [r for r in current_recipes if layer_filter.lower() in r.get("layer_name", "").lower()]
1355
+ if section_filter:
1356
+ current_recipes = [r for r in current_recipes if section_filter.lower() in r.get("SECTION", "").lower()]
1357
+
1358
+ # Sort
1359
+ if sort_by == "layer":
1360
+ current_recipes = sorted(current_recipes, key=lambda r: (r.get("layer_name", "").lower(), r.get("pn", "").lower()))
1361
+ else:
1362
+ current_recipes = sorted(current_recipes, key=lambda r: r.get("pn", "").lower())
1363
+
1364
+ current_source_name = next_source.capitalize()
1365
+ continue # Re-show browser with new source
1366
+
1367
+ # Check if user wants to go back to menu
1368
+ if browser_result == "menu" and use_interactive_menu:
1369
+ break # Break inner loop to go back to source selection menu
1370
+
1371
+ # Normal exit
1372
+ return browser_result if isinstance(browser_result, int) else 0
1373
+
1374
+ continue # Continue outer while loop (back to menu)