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,884 @@
1
+ #
2
+ # Copyright (C) 2025 Bruce Ashfield <bruce.ashfield@gmail.com>
3
+ #
4
+ # SPDX-License-Identifier: GPL-2.0-only
5
+ #
6
+ """Fragment command - browse and manage OE configuration fragments."""
7
+
8
+ import json
9
+ import os
10
+ import re
11
+ import shutil
12
+ import subprocess
13
+ import tempfile
14
+ from dataclasses import dataclass
15
+ from typing import Dict, List, Optional, Tuple
16
+
17
+ from ..core import Colors, fzf_available, get_fzf_color_args, get_fzf_preview_resize_bindings, terminal_color
18
+ from .common import resolve_bblayers_path, extract_layer_paths
19
+ from .projects import get_preview_window_arg
20
+
21
+
22
+ # =============================================================================
23
+ # Data Classes
24
+ # =============================================================================
25
+
26
+ @dataclass
27
+ class Fragment:
28
+ """Represents a configuration fragment."""
29
+ name: str # e.g., "meta/yocto/root-login-with-empty-password"
30
+ path: str # Full filesystem path to .conf file (empty for builtin)
31
+ layer: str # Layer name (e.g., "meta")
32
+ domain: str # Domain path (e.g., "yocto")
33
+ summary: str # BB_CONF_FRAGMENT_SUMMARY value
34
+ description: str # BB_CONF_FRAGMENT_DESCRIPTION value
35
+ is_enabled: bool # Whether in OE_FRAGMENTS
36
+ is_builtin: bool # Whether this is a built-in fragment (machine/distro)
37
+
38
+
39
+ @dataclass
40
+ class BuiltinPrefix:
41
+ """Represents a built-in fragment prefix (machine:MACHINE, distro:DISTRO)."""
42
+ prefix: str # e.g., "machine"
43
+ variable: str # e.g., "MACHINE"
44
+
45
+
46
+ # =============================================================================
47
+ # Constants
48
+ # =============================================================================
49
+
50
+ # Default built-in fragment prefixes from OE-core bitbake.conf
51
+ DEFAULT_BUILTIN_PREFIXES = [
52
+ BuiltinPrefix("machine", "MACHINE"),
53
+ BuiltinPrefix("distro", "DISTRO"),
54
+ ]
55
+
56
+
57
+ # =============================================================================
58
+ # toolcfg.conf Parsing
59
+ # =============================================================================
60
+
61
+ def _get_toolcfg_path(bblayers_path: Optional[str] = None) -> str:
62
+ """
63
+ Find toolcfg.conf relative to build directory.
64
+
65
+ Returns path to conf/toolcfg.conf relative to the build directory
66
+ containing bblayers.conf.
67
+ """
68
+ if bblayers_path:
69
+ # toolcfg.conf is in the same conf/ directory as bblayers.conf
70
+ conf_dir = os.path.dirname(bblayers_path)
71
+ return os.path.join(conf_dir, "toolcfg.conf")
72
+
73
+ # Default locations
74
+ candidates = ["conf/toolcfg.conf", "build/conf/toolcfg.conf"]
75
+ for cand in candidates:
76
+ parent_conf = os.path.dirname(cand)
77
+ if os.path.isdir(parent_conf):
78
+ return cand
79
+
80
+ return "conf/toolcfg.conf"
81
+
82
+
83
+ def _parse_enabled_fragments(toolcfg_path: str) -> List[str]:
84
+ """
85
+ Parse OE_FRAGMENTS from toolcfg.conf using regex.
86
+
87
+ Handles:
88
+ - OE_FRAGMENTS = "..."
89
+ - OE_FRAGMENTS += "..."
90
+ - Line continuations with backslash
91
+ """
92
+ if not os.path.isfile(toolcfg_path):
93
+ return []
94
+
95
+ try:
96
+ with open(toolcfg_path, "r") as f:
97
+ content = f.read()
98
+ except (OSError, IOError):
99
+ return []
100
+
101
+ # Handle line continuations
102
+ content = content.replace("\\\n", " ")
103
+
104
+ # Match OE_FRAGMENTS = "..." or OE_FRAGMENTS += "..."
105
+ # Collect all values (multiple assignments get concatenated)
106
+ fragments = []
107
+ pattern = re.compile(r'OE_FRAGMENTS\s*\+?=\s*"([^"]*)"', re.MULTILINE)
108
+
109
+ for match in pattern.finditer(content):
110
+ value = match.group(1).strip()
111
+ if value:
112
+ fragments.extend(value.split())
113
+
114
+ return fragments
115
+
116
+
117
+ def _ensure_toolcfg_exists(toolcfg_path: str) -> None:
118
+ """Create toolcfg.conf with default content if it doesn't exist."""
119
+ if os.path.exists(toolcfg_path):
120
+ return
121
+
122
+ # Create conf directory if needed
123
+ conf_dir = os.path.dirname(toolcfg_path)
124
+ if conf_dir and not os.path.isdir(conf_dir):
125
+ os.makedirs(conf_dir, exist_ok=True)
126
+
127
+ with open(toolcfg_path, "w") as f:
128
+ f.write("""# Automated config file controlled by bit/bitbake-config-build
129
+ #
130
+ # Run 'bit fragment enable <fragment-name>' to enable additional fragments
131
+ # or replace built-in ones (e.g. machine/<name> or distro/<name> to change MACHINE or DISTRO).
132
+
133
+ OE_FRAGMENTS += ""
134
+ """)
135
+
136
+
137
+ def _update_oe_fragments(
138
+ toolcfg_path: str,
139
+ to_add: List[str],
140
+ to_remove: List[str],
141
+ builtin_prefixes: List[BuiltinPrefix],
142
+ ) -> bool:
143
+ """
144
+ Update OE_FRAGMENTS variable in toolcfg.conf.
145
+
146
+ - Adds new fragments
147
+ - Removes specified fragments
148
+ - For builtin fragments (machine/, distro/), removes existing value with same prefix
149
+
150
+ Returns True if file was modified.
151
+ """
152
+ _ensure_toolcfg_exists(toolcfg_path)
153
+
154
+ with open(toolcfg_path, "r") as f:
155
+ content = f.read()
156
+
157
+ # Parse current fragments
158
+ current = _parse_enabled_fragments(toolcfg_path)
159
+
160
+ # Get builtin prefix names for special handling
161
+ builtin_prefix_names = {p.prefix for p in builtin_prefixes}
162
+
163
+ # Process removals
164
+ for fragment in to_remove:
165
+ if fragment in current:
166
+ current.remove(fragment)
167
+ # Also handle prefix-based removal (e.g., "machine/" removes all machine/*)
168
+ if fragment.endswith("/"):
169
+ current = [f for f in current if not f.startswith(fragment)]
170
+
171
+ # Process additions
172
+ for fragment in to_add:
173
+ if fragment in current:
174
+ continue
175
+
176
+ # For builtin fragments, remove existing with same prefix first
177
+ prefix = fragment.split("/", 1)[0]
178
+ if prefix in builtin_prefix_names:
179
+ current = [f for f in current if not f.startswith(f"{prefix}/")]
180
+
181
+ current.append(fragment)
182
+
183
+ # Update the file
184
+ new_value = " ".join(current)
185
+
186
+ # Check if OE_FRAGMENTS line exists
187
+ pattern = re.compile(r'(OE_FRAGMENTS\s*\+?=\s*)"[^"]*"', re.MULTILINE)
188
+
189
+ if pattern.search(content):
190
+ # Replace existing line
191
+ new_content = pattern.sub(rf'\1"{new_value}"', content, count=1)
192
+ else:
193
+ # Add new line
194
+ new_content = content.rstrip() + f'\n\nOE_FRAGMENTS += "{new_value}"\n'
195
+
196
+ if new_content == content:
197
+ return False
198
+
199
+ with open(toolcfg_path, "w") as f:
200
+ f.write(new_content)
201
+
202
+ return True
203
+
204
+
205
+ # =============================================================================
206
+ # Fragment Discovery
207
+ # =============================================================================
208
+
209
+ def _parse_fragment_metadata(conf_path: str) -> Tuple[str, str]:
210
+ """
211
+ Extract BB_CONF_FRAGMENT_SUMMARY and BB_CONF_FRAGMENT_DESCRIPTION from a .conf file.
212
+
213
+ Returns (summary, description).
214
+ """
215
+ summary = ""
216
+ description = ""
217
+
218
+ try:
219
+ with open(conf_path, "r", encoding="utf-8", errors="ignore") as f:
220
+ content = f.read()
221
+ except (OSError, IOError):
222
+ return summary, description
223
+
224
+ # Parse BB_CONF_FRAGMENT_SUMMARY = "..."
225
+ summary_match = re.search(r'BB_CONF_FRAGMENT_SUMMARY\s*=\s*"([^"]*)"', content)
226
+ if summary_match:
227
+ summary = summary_match.group(1).strip()
228
+
229
+ # Parse BB_CONF_FRAGMENT_DESCRIPTION = "..."
230
+ # This can be multiline with backslash continuation
231
+ desc_match = re.search(r'BB_CONF_FRAGMENT_DESCRIPTION\s*=\s*"([^"]*(?:\\\n[^"]*)*)"', content)
232
+ if desc_match:
233
+ description = desc_match.group(1).strip()
234
+ # Clean up line continuations
235
+ description = description.replace("\\\n", " ").replace(" ", " ")
236
+
237
+ return summary, description
238
+
239
+
240
+ def _get_layer_name(layer_path: str) -> str:
241
+ """
242
+ Get layer name from layer.conf BBFILE_COLLECTIONS.
243
+ Falls back to directory name.
244
+ """
245
+ layer_conf = os.path.join(layer_path, "conf", "layer.conf")
246
+
247
+ if os.path.isfile(layer_conf):
248
+ try:
249
+ with open(layer_conf, "r") as f:
250
+ content = f.read()
251
+ match = re.search(r'BBFILE_COLLECTIONS\s*\+?=\s*"([^"]*)"', content)
252
+ if match:
253
+ # Take first collection name
254
+ names = match.group(1).strip().split()
255
+ if names:
256
+ return names[0]
257
+ except (OSError, IOError):
258
+ pass
259
+
260
+ return os.path.basename(layer_path)
261
+
262
+
263
+ def _discover_fragments(layer_paths: List[str], progress: bool = True) -> List[Fragment]:
264
+ """
265
+ Scan layers for fragments in conf/fragments/<domain>/*.conf.
266
+
267
+ Returns list of Fragment objects.
268
+ """
269
+ fragments = []
270
+
271
+ if progress:
272
+ print(Colors.dim("Scanning fragments..."), end=" ", flush=True)
273
+
274
+ for layer_path in layer_paths:
275
+ layer_name = _get_layer_name(layer_path)
276
+ fragments_dir = os.path.join(layer_path, "conf", "fragments")
277
+
278
+ if not os.path.isdir(fragments_dir):
279
+ continue
280
+
281
+ for root, dirs, files in os.walk(fragments_dir):
282
+ # Get domain relative to fragments_dir
283
+ domain = os.path.relpath(root, fragments_dir)
284
+ if domain == ".":
285
+ domain = ""
286
+
287
+ for conf_file in sorted(files):
288
+ if not conf_file.endswith(".conf") or conf_file.startswith("."):
289
+ continue
290
+
291
+ conf_path = os.path.join(root, conf_file)
292
+ name_base = conf_file[:-5] # Remove .conf
293
+
294
+ # Build fragment name: layer/domain/name
295
+ if domain:
296
+ fragment_name = f"{layer_name}/{domain}/{name_base}"
297
+ else:
298
+ fragment_name = f"{layer_name}/{name_base}"
299
+
300
+ # Normalize path separators
301
+ fragment_name = fragment_name.replace("\\", "/")
302
+
303
+ summary, description = _parse_fragment_metadata(conf_path)
304
+
305
+ fragments.append(Fragment(
306
+ name=fragment_name,
307
+ path=conf_path,
308
+ layer=layer_name,
309
+ domain=domain,
310
+ summary=summary or "(no summary)",
311
+ description=description or "",
312
+ is_enabled=False,
313
+ is_builtin=False,
314
+ ))
315
+
316
+ if progress:
317
+ print(Colors.dim(f"{len(fragments)} fragments found"))
318
+
319
+ return fragments
320
+
321
+
322
+ def _get_enabled_builtin_values(
323
+ enabled_fragments: List[str],
324
+ prefixes: List[BuiltinPrefix],
325
+ ) -> Dict[str, str]:
326
+ """
327
+ Extract current values for builtin fragments.
328
+
329
+ E.g., if OE_FRAGMENTS contains "machine/qemuarm64", return {"machine": "qemuarm64"}
330
+ """
331
+ result = {}
332
+ prefix_names = {p.prefix for p in prefixes}
333
+
334
+ for fragment in enabled_fragments:
335
+ if "/" in fragment:
336
+ prefix, value = fragment.split("/", 1)
337
+ if prefix in prefix_names:
338
+ result[prefix] = value
339
+
340
+ return result
341
+
342
+
343
+ # =============================================================================
344
+ # FZF Browser
345
+ # =============================================================================
346
+
347
+ def _build_fragment_menu(
348
+ fragments: List[Fragment],
349
+ builtin_prefixes: List[BuiltinPrefix],
350
+ enabled_builtins: Dict[str, str],
351
+ toolcfg_path: str = "",
352
+ ) -> str:
353
+ """Build fzf menu input with fragments grouped by layer/domain."""
354
+ lines = []
355
+
356
+ # Calculate column widths
357
+ max_name_len = max(len(f.name) for f in fragments) if fragments else 30
358
+ max_name_len = min(max_name_len, 50)
359
+
360
+ # Add config file entry at the top
361
+ if toolcfg_path:
362
+ config_name = "⚙ toolcfg.conf"
363
+ config_exists = os.path.isfile(toolcfg_path)
364
+ status = Colors.cyan("[config]") if config_exists else Colors.dim("[config]")
365
+ summary = toolcfg_path if config_exists else "(will be created)"
366
+ lines.append(f"__CONFIG__\t{Colors.cyan(f'{config_name:<{max_name_len}}')} {status} {summary}")
367
+ lines.append(f"---\t{Colors.dim('── Fragments ──')}")
368
+
369
+ # Show enabled fragments first
370
+ enabled = [f for f in fragments if f.is_enabled]
371
+ disabled = [f for f in fragments if not f.is_enabled]
372
+
373
+ # Add builtin prefixes as special entries
374
+ for prefix in builtin_prefixes:
375
+ current_value = enabled_builtins.get(prefix.prefix, "")
376
+ if current_value:
377
+ name = f"{prefix.prefix}/{current_value}"
378
+ status = terminal_color("fragment_enabled", "[enabled]")
379
+ summary = f"Sets {prefix.variable} = {current_value}"
380
+ colored_name = terminal_color("fragment_enabled", f"{name:<{max_name_len}}")
381
+ line = f"{name}\t{colored_name} {status} {summary}"
382
+ else:
383
+ name = f"{prefix.prefix}/..."
384
+ status = Colors.dim("[builtin]")
385
+ summary = f"Sets {prefix.variable} = <value>"
386
+ line = f"{name}\t{Colors.dim(f'{name:<{max_name_len}}')} {status} {summary}"
387
+ lines.append(line)
388
+
389
+ if builtin_prefixes:
390
+ lines.append(f"---\t{Colors.dim('── File Fragments ──')}")
391
+
392
+ # Enabled fragments
393
+ if enabled:
394
+ for f in enabled:
395
+ status = terminal_color("fragment_enabled", "[enabled]")
396
+ colored_name = terminal_color("fragment_enabled", f"{f.name:<{max_name_len}}")
397
+ line = f"{f.name}\t{colored_name} {status} {f.summary[:50]}"
398
+ lines.append(line)
399
+
400
+ # Disabled fragments
401
+ if disabled:
402
+ if enabled:
403
+ lines.append(f"---\t{Colors.dim('── Available ──')}")
404
+ for f in disabled:
405
+ status = Colors.dim("[ ]")
406
+ line = f"{f.name}\t{f.name:<{max_name_len}} {status} {f.summary[:50]}"
407
+ lines.append(line)
408
+
409
+ return "\n".join(lines)
410
+
411
+
412
+ def _fragment_fzf_browser(
413
+ fragments: List[Fragment],
414
+ builtin_prefixes: List[BuiltinPrefix],
415
+ enabled_builtins: Dict[str, str],
416
+ toolcfg_path: str,
417
+ ) -> int:
418
+ """
419
+ Interactive fzf browser for fragments.
420
+
421
+ Key bindings:
422
+ - Enter: Toggle enable/disable
423
+ - Tab: Multi-select fragments
424
+ - e: Enable all selected
425
+ - d: Disable all selected
426
+ - v: View fragment file content
427
+ - c: View/edit toolcfg.conf
428
+ - ?: Toggle preview
429
+ - q: Quit
430
+ """
431
+ if not fragments and not builtin_prefixes:
432
+ print("No fragments found.")
433
+ return 1
434
+
435
+ if not fzf_available():
436
+ # Fall back to text list
437
+ _list_fragments_text(fragments, builtin_prefixes, enabled_builtins, verbose=False)
438
+ return 0
439
+
440
+ # Build preview script
441
+ preview_data = {f.name: {
442
+ "name": f.name,
443
+ "path": f.path,
444
+ "layer": f.layer,
445
+ "domain": f.domain,
446
+ "summary": f.summary,
447
+ "description": f.description,
448
+ "is_enabled": f.is_enabled,
449
+ "is_builtin": f.is_builtin,
450
+ } for f in fragments}
451
+
452
+ # Add builtin info
453
+ for prefix in builtin_prefixes:
454
+ value = enabled_builtins.get(prefix.prefix, "")
455
+ if value:
456
+ key = f"{prefix.prefix}/{value}"
457
+ preview_data[key] = {
458
+ "name": key,
459
+ "path": "",
460
+ "layer": "",
461
+ "domain": "",
462
+ "summary": f"Sets {prefix.variable} = {value}",
463
+ "description": f"Built-in fragment that sets the {prefix.variable} variable.",
464
+ "is_enabled": True,
465
+ "is_builtin": True,
466
+ }
467
+ # Also add the generic entry
468
+ key = f"{prefix.prefix}/..."
469
+ preview_data[key] = {
470
+ "name": key,
471
+ "path": "",
472
+ "layer": "",
473
+ "domain": "",
474
+ "summary": f"Sets {prefix.variable} = <value>",
475
+ "description": f"Built-in fragment. Use '{prefix.prefix}/<value>' to set {prefix.variable}.",
476
+ "is_enabled": False,
477
+ "is_builtin": True,
478
+ }
479
+
480
+ # Add config file entry
481
+ preview_data["__CONFIG__"] = {
482
+ "name": "toolcfg.conf",
483
+ "path": toolcfg_path,
484
+ "layer": "",
485
+ "domain": "",
486
+ "summary": "OE configuration file for fragments",
487
+ "description": "This file stores your enabled fragments (OE_FRAGMENTS variable).",
488
+ "is_enabled": False,
489
+ "is_builtin": False,
490
+ "is_config": True,
491
+ }
492
+
493
+ preview_data_file = tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False)
494
+ json.dump(preview_data, preview_data_file)
495
+ preview_data_file.close()
496
+
497
+ # Create preview script
498
+ preview_script = tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False)
499
+ preview_script.write(f'''#!/usr/bin/env python3
500
+ import json
501
+ import sys
502
+ import os
503
+
504
+ key = sys.argv[1] if len(sys.argv) > 1 else ""
505
+ key = key.strip("'\\"")
506
+
507
+ with open("{preview_data_file.name}", "r") as f:
508
+ data = json.load(f)
509
+
510
+ frag = data.get(key, {{}})
511
+ if not frag:
512
+ print(f"No data for: {{key}}")
513
+ sys.exit(0)
514
+
515
+ # Special handling for config file entry
516
+ if frag.get('is_config'):
517
+ path = frag.get('path', '')
518
+ print("\\033[1mtoolcfg.conf\\033[0m")
519
+ print(f"Path: {{path}}")
520
+ print()
521
+ if path and os.path.isfile(path):
522
+ print("\\033[36mContent:\\033[0m")
523
+ print("-" * 40)
524
+ with open(path, 'r') as f:
525
+ print(f.read())
526
+ else:
527
+ print("\\033[33mFile does not exist yet.\\033[0m")
528
+ print("It will be created when you enable a fragment.")
529
+ sys.exit(0)
530
+
531
+ print(f"\\033[1m{{frag.get('name', '')}}\\033[0m")
532
+ if frag.get('is_enabled'):
533
+ print("\\033[32mStatus: Enabled\\033[0m")
534
+ else:
535
+ print("Status: Disabled")
536
+ print()
537
+
538
+ if frag.get('path'):
539
+ print(f"Path: {{frag['path']}}")
540
+ print(f"Layer: {{frag.get('layer', '')}}")
541
+ if frag.get('domain'):
542
+ print(f"Domain: {{frag['domain']}}")
543
+ print()
544
+
545
+ print("\\033[36mSummary:\\033[0m")
546
+ print(f" {{frag.get('summary', '')}}")
547
+ print()
548
+
549
+ if frag.get('description'):
550
+ print("\\033[36mDescription:\\033[0m")
551
+ for line in frag['description'].split('\\n'):
552
+ print(f" {{line}}")
553
+ print()
554
+
555
+ # Show file content for non-builtin fragments
556
+ if frag.get('path') and os.path.isfile(frag['path']):
557
+ print("\\033[36mContent:\\033[0m")
558
+ print("-" * 40)
559
+ with open(frag['path'], 'r') as f:
560
+ print(f.read())
561
+ ''')
562
+ preview_script.close()
563
+
564
+ preview_cmd = f'python3 "{preview_script.name}" {{1}}'
565
+
566
+ # Header (two lines)
567
+ header = "Enter=toggle | Tab=select | +/e=enable | -/d=disable\nv=view | c=edit conf | ?=preview | q=quit"
568
+
569
+ preview_window = get_preview_window_arg("50%")
570
+
571
+ fzf_args = [
572
+ "fzf",
573
+ "--multi",
574
+ "--ansi",
575
+ "--height", "100%",
576
+ "--layout=reverse-list",
577
+ "--header", header,
578
+ "--prompt", "Fragment: ",
579
+ "--with-nth", "2..",
580
+ "--delimiter", "\t",
581
+ "--preview", preview_cmd,
582
+ "--preview-window", preview_window,
583
+ "--bind", "?:toggle-preview",
584
+ "--bind", "pgup:preview-page-up",
585
+ "--bind", "pgdn:preview-page-down",
586
+ "--bind", "esc:abort",
587
+ "--expect", "e,d,v,c,q,+,-",
588
+ ]
589
+
590
+ fzf_args.extend(get_fzf_preview_resize_bindings())
591
+ fzf_args.extend(get_fzf_color_args())
592
+
593
+ next_selection = None # Track item to select after enable/disable
594
+
595
+ try:
596
+ while True:
597
+ # Rebuild menu with current state
598
+ menu_input = _build_fragment_menu(fragments, builtin_prefixes, enabled_builtins, toolcfg_path)
599
+
600
+ # Build position binding if we have a selection to restore
601
+ run_args = fzf_args.copy()
602
+ if next_selection:
603
+ lines = menu_input.split('\n')
604
+ for i, line in enumerate(lines):
605
+ if line.startswith(next_selection + '\t'):
606
+ run_args.extend(["--bind", f"load:pos({i + 1})"])
607
+ break
608
+ next_selection = None
609
+
610
+ result = subprocess.run(
611
+ run_args,
612
+ input=menu_input,
613
+ stdout=subprocess.PIPE,
614
+ text=True,
615
+ )
616
+
617
+ if result.returncode != 0 or not result.stdout.strip():
618
+ break
619
+
620
+ # Parse output
621
+ lines = result.stdout.strip().split("\n")
622
+ key = lines[0].strip() if lines else ""
623
+ selected = [l.split("\t")[0].strip() for l in lines[1:] if l.strip() and not l.startswith("---")]
624
+
625
+ if key == "q" or not selected:
626
+ break
627
+
628
+ if key == "v":
629
+ # View fragment content
630
+ for name in selected:
631
+ frag = next((f for f in fragments if f.name == name), None)
632
+ if frag and frag.path and os.path.isfile(frag.path):
633
+ pager = os.environ.get("PAGER", "less")
634
+ subprocess.run([pager, frag.path])
635
+ continue
636
+
637
+ if key == "c":
638
+ # View/edit toolcfg.conf
639
+ if os.path.isfile(toolcfg_path):
640
+ editor = os.environ.get("EDITOR", os.environ.get("PAGER", "less"))
641
+ subprocess.run([editor, toolcfg_path])
642
+ # Refresh state after potential edits
643
+ enabled_list = _parse_enabled_fragments(toolcfg_path)
644
+ for f in fragments:
645
+ f.is_enabled = f.name in enabled_list
646
+ enabled_builtins.clear()
647
+ enabled_builtins.update(_get_enabled_builtin_values(enabled_list, builtin_prefixes))
648
+ else:
649
+ print(f"No toolcfg.conf yet (will be created when you enable a fragment)")
650
+ input("Press Enter to continue...")
651
+ continue
652
+
653
+ # Handle enable/disable
654
+ to_enable = []
655
+ to_disable = []
656
+
657
+ for name in selected:
658
+ # Skip separator lines and config entry
659
+ if name == "---" or name.startswith("──") or name == "__CONFIG__":
660
+ continue
661
+
662
+ # Check if this is a builtin prompt (e.g., "machine/...")
663
+ if name.endswith("/..."):
664
+ prefix = name[:-4]
665
+ # Prompt for value
666
+ try:
667
+ value = input(f"Enter value for {prefix}/: ").strip()
668
+ if value:
669
+ to_enable.append(f"{prefix}/{value}")
670
+ except (EOFError, KeyboardInterrupt):
671
+ print()
672
+ continue
673
+
674
+ frag = next((f for f in fragments if f.name == name), None)
675
+ is_enabled = frag.is_enabled if frag else name in enabled_builtins.values()
676
+
677
+ if key in ("e", "+") or (key == "" and not is_enabled):
678
+ to_enable.append(name)
679
+ elif key in ("d", "-") or (key == "" and is_enabled):
680
+ to_disable.append(name)
681
+
682
+ # Update toolcfg.conf
683
+ if to_enable or to_disable:
684
+ modified = _update_oe_fragments(toolcfg_path, to_enable, to_disable, builtin_prefixes)
685
+ if modified:
686
+ # Refresh state
687
+ enabled_list = _parse_enabled_fragments(toolcfg_path)
688
+ for f in fragments:
689
+ f.is_enabled = f.name in enabled_list
690
+ enabled_builtins = _get_enabled_builtin_values(enabled_list, builtin_prefixes)
691
+
692
+ # Preserve selection on the first modified item
693
+ if selected:
694
+ next_selection = selected[0]
695
+
696
+ finally:
697
+ # Clean up temp files
698
+ for f in [preview_data_file.name, preview_script.name]:
699
+ try:
700
+ os.unlink(f)
701
+ except OSError:
702
+ pass
703
+
704
+ return 0
705
+
706
+
707
+ # =============================================================================
708
+ # Text Output
709
+ # =============================================================================
710
+
711
+ def _list_fragments_text(
712
+ fragments: List[Fragment],
713
+ builtin_prefixes: List[BuiltinPrefix],
714
+ enabled_builtins: Dict[str, str],
715
+ verbose: bool = False,
716
+ ) -> None:
717
+ """Print fragments as text output."""
718
+ # Print builtin info
719
+ print("Built-in fragments:")
720
+ for prefix in builtin_prefixes:
721
+ value = enabled_builtins.get(prefix.prefix, "")
722
+ if value:
723
+ status = terminal_color("fragment_enabled", "[enabled]")
724
+ print(f" {prefix.prefix}/{value} {status} Sets {prefix.variable} = {value}")
725
+ else:
726
+ print(f" {prefix.prefix}/... [builtin] Sets {prefix.variable} = <value>")
727
+ print()
728
+
729
+ # Group by layer
730
+ by_layer: Dict[str, List[Fragment]] = {}
731
+ for f in fragments:
732
+ by_layer.setdefault(f.layer, []).append(f)
733
+
734
+ for layer, layer_fragments in sorted(by_layer.items()):
735
+ print(f"Layer: {layer}")
736
+ for f in sorted(layer_fragments, key=lambda x: x.name):
737
+ if f.is_enabled:
738
+ status = terminal_color("fragment_enabled", "[enabled]")
739
+ else:
740
+ status = "[ ]"
741
+ if verbose:
742
+ print(f" {f.name}")
743
+ if f.is_enabled:
744
+ print(f" Status: {terminal_color('fragment_enabled', 'Enabled')}")
745
+ else:
746
+ print(f" Status: Disabled")
747
+ print(f" Path: {f.path}")
748
+ print(f" Summary: {f.summary}")
749
+ if f.description:
750
+ print(f" Description: {f.description}")
751
+ print()
752
+ else:
753
+ print(f" {status} {f.name:<40} {f.summary[:40]}")
754
+ print()
755
+
756
+
757
+ def _show_fragment(name: str, fragments: List[Fragment]) -> int:
758
+ """Show content of a fragment."""
759
+ frag = next((f for f in fragments if f.name == name), None)
760
+
761
+ if not frag:
762
+ print(f"Fragment not found: {name}")
763
+ return 1
764
+
765
+ if not frag.path or not os.path.isfile(frag.path):
766
+ print(f"Fragment has no file: {name}")
767
+ return 1
768
+
769
+ print(f"Fragment: {frag.name}")
770
+ print(f"Path: {frag.path}")
771
+ print(f"Summary: {frag.summary}")
772
+ print()
773
+ print("-" * 60)
774
+ with open(frag.path, "r") as f:
775
+ print(f.read())
776
+
777
+ return 0
778
+
779
+
780
+ # =============================================================================
781
+ # Main Entry Point
782
+ # =============================================================================
783
+
784
+ def run_fragment(args) -> int:
785
+ """
786
+ Main entry point for fragment command.
787
+
788
+ Args:
789
+ args: Parsed command line arguments with:
790
+ - fragment_command: Subcommand (list, enable, disable, show)
791
+ - fragmentname: Fragment name(s) for enable/disable/show
792
+ - confpath: Path to toolcfg.conf
793
+ - verbose: Show descriptions in list mode
794
+ """
795
+ bblayers = getattr(args, "bblayers", None)
796
+ confpath = getattr(args, "confpath", None)
797
+ fragment_command = getattr(args, "fragment_command", None)
798
+ fragmentnames = getattr(args, "fragmentname", None)
799
+ verbose = getattr(args, "verbose", False)
800
+ list_mode = getattr(args, "list", False)
801
+
802
+ # Resolve bblayers.conf
803
+ bblayers_path = resolve_bblayers_path(bblayers)
804
+ if not bblayers_path:
805
+ print("No bblayers.conf found. Run from a Yocto/OE project directory.")
806
+ return 1
807
+
808
+ # Get layer paths
809
+ try:
810
+ layer_paths = extract_layer_paths(bblayers_path)
811
+ except SystemExit:
812
+ return 1
813
+
814
+ # Determine toolcfg.conf path
815
+ toolcfg_path = confpath or _get_toolcfg_path(bblayers_path)
816
+
817
+ # Get enabled fragments
818
+ enabled_list = _parse_enabled_fragments(toolcfg_path) if os.path.exists(toolcfg_path) else []
819
+
820
+ # Discover fragments
821
+ fragments = _discover_fragments(layer_paths)
822
+
823
+ # Mark enabled fragments
824
+ for f in fragments:
825
+ f.is_enabled = f.name in enabled_list
826
+
827
+ # Get builtin prefixes
828
+ builtin_prefixes = DEFAULT_BUILTIN_PREFIXES
829
+ enabled_builtins = _get_enabled_builtin_values(enabled_list, builtin_prefixes)
830
+
831
+ # Handle subcommands
832
+ if fragment_command == "list" or list_mode:
833
+ _list_fragments_text(fragments, builtin_prefixes, enabled_builtins, verbose)
834
+ return 0
835
+
836
+ if fragment_command == "enable":
837
+ if not fragmentnames:
838
+ print("No fragment names specified.")
839
+ return 1
840
+
841
+ # Validate fragments exist
842
+ valid_fragments = []
843
+ for name in fragmentnames:
844
+ # Check if it's a builtin prefix
845
+ prefix = name.split("/", 1)[0] if "/" in name else ""
846
+ is_builtin = prefix in {p.prefix for p in builtin_prefixes}
847
+
848
+ if is_builtin:
849
+ valid_fragments.append(name)
850
+ elif any(f.name == name for f in fragments):
851
+ valid_fragments.append(name)
852
+ else:
853
+ print(f"Fragment not found: {name}")
854
+ return 1
855
+
856
+ modified = _update_oe_fragments(toolcfg_path, valid_fragments, [], builtin_prefixes)
857
+ if modified:
858
+ for name in valid_fragments:
859
+ print(f"Enabled: {name}")
860
+ # Show summary for file fragments
861
+ frag = next((f for f in fragments if f.name == name), None)
862
+ if frag:
863
+ print(f" Summary: {frag.summary}")
864
+ return 0
865
+
866
+ if fragment_command == "disable":
867
+ if not fragmentnames:
868
+ print("No fragment names specified.")
869
+ return 1
870
+
871
+ modified = _update_oe_fragments(toolcfg_path, [], fragmentnames, builtin_prefixes)
872
+ if modified:
873
+ for name in fragmentnames:
874
+ print(f"Disabled: {name}")
875
+ return 0
876
+
877
+ if fragment_command == "show":
878
+ if not fragmentnames:
879
+ print("No fragment name specified.")
880
+ return 1
881
+ return _show_fragment(fragmentnames, fragments)
882
+
883
+ # Default: interactive browser
884
+ return _fragment_fzf_browser(fragments, builtin_prefixes, enabled_builtins, toolcfg_path)