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.
bitbake_project/cli.py ADDED
@@ -0,0 +1,1580 @@
1
+ #!/usr/bin/env python3
2
+ # PYTHON_ARGCOMPLETE_OK
3
+ #
4
+ # Copyright (C) 2025 Bruce Ashfield <bruce.ashfield@gmail.com>
5
+ #
6
+ # SPDX-License-Identifier: GPL-2.0-only
7
+ #
8
+ """
9
+ CLI entry point for bit.
10
+
11
+ Handles argument parsing and command dispatch.
12
+ """
13
+
14
+ import argparse
15
+ import json
16
+ import os
17
+ import signal
18
+ import subprocess
19
+ import sys
20
+ from typing import Dict, List, Optional, Set, Tuple
21
+
22
+ from .core import Colors, fzf_expandable_menu, parse_help_options
23
+ from .commands import fzf_available
24
+
25
+ # Try to import argcomplete for tab completion
26
+ try:
27
+ import argcomplete
28
+ from argcomplete.completers import SuppressCompleter
29
+ HAS_ARGCOMPLETE = True
30
+
31
+ class RepoCompleter:
32
+ """Complete repo names from bblayers.conf."""
33
+ def __init__(self, include_edit=False):
34
+ self.include_edit = include_edit
35
+
36
+ def __call__(self, prefix, parsed_args, **kwargs):
37
+ try:
38
+ completions = []
39
+ # Add 'edit' as a valid option for config command
40
+ if self.include_edit and "edit".startswith(prefix):
41
+ completions.append("edit")
42
+
43
+ # Try to get repos for completion
44
+ bblayers = getattr(parsed_args, 'bblayers', None)
45
+ path = bblayers or "conf/bblayers.conf"
46
+ if not os.path.exists(path):
47
+ path = "build/conf/bblayers.conf"
48
+ if not os.path.exists(path):
49
+ return completions
50
+ # Quick parse for completion
51
+ with open(path) as f:
52
+ content = f.read()
53
+ import re
54
+ # Match both quoted and unquoted paths
55
+ paths = re.findall(r'"([^"]+)"', content)
56
+ paths += re.findall(r'^\s*(/[^\s\\]+)', content, re.MULTILINE)
57
+ repos = set()
58
+ for p in paths:
59
+ if '/' in p and '${' not in p and os.path.isdir(p):
60
+ try:
61
+ toplevel = subprocess.check_output(
62
+ ["git", "-C", p, "rev-parse", "--show-toplevel"],
63
+ stderr=subprocess.DEVNULL, text=True
64
+ ).strip()
65
+ # Try to get custom display name first
66
+ try:
67
+ display = subprocess.check_output(
68
+ ["git", "-C", toplevel, "config", "--get", "bit.display-name"],
69
+ stderr=subprocess.DEVNULL, text=True
70
+ ).strip()
71
+ except:
72
+ # Fall back to deriving from origin URL or basename
73
+ try:
74
+ url = subprocess.check_output(
75
+ ["git", "-C", toplevel, "config", "--get", "remote.origin.url"],
76
+ stderr=subprocess.DEVNULL, text=True
77
+ ).strip()
78
+ display = os.path.basename(url.rstrip('/'))
79
+ if display.endswith('.git'):
80
+ display = display[:-4]
81
+ except:
82
+ display = os.path.basename(toplevel)
83
+ repos.add(display)
84
+ except:
85
+ pass
86
+ completions.extend([r for r in repos if r.lower().startswith(prefix.lower())])
87
+ return completions
88
+ except:
89
+ return []
90
+
91
+ class LayerCompleter:
92
+ """Complete layer names from bblayers.conf."""
93
+ def __call__(self, prefix, parsed_args, **kwargs):
94
+ try:
95
+ bblayers = getattr(parsed_args, 'bblayers', None)
96
+ path = bblayers or "conf/bblayers.conf"
97
+ if not os.path.exists(path):
98
+ path = "build/conf/bblayers.conf"
99
+ if not os.path.exists(path):
100
+ return []
101
+ with open(path) as f:
102
+ content = f.read()
103
+ import re
104
+ # Match both quoted and unquoted paths
105
+ paths = re.findall(r'"([^"]+)"', content)
106
+ paths += re.findall(r'^\s*(/[^\s\\]+)', content, re.MULTILINE)
107
+ layers = []
108
+ for p in paths:
109
+ if '/' in p and '${' not in p and os.path.isdir(p):
110
+ layers.append(os.path.basename(p))
111
+ return [l for l in layers if l.lower().startswith(prefix.lower())]
112
+ except:
113
+ return []
114
+
115
+ except ImportError:
116
+ HAS_ARGCOMPLETE = False
117
+
118
+
119
+ # =============================================================================
120
+ # SINGLE SOURCE OF TRUTH FOR ALL COMMANDS
121
+ # =============================================================================
122
+ # Add new commands/subcommands here. Format:
123
+ # (command, description, [subcommands])
124
+ # where subcommands is a list of (subcommand, description) tuples.
125
+ #
126
+ # This list is used by:
127
+ # - Interactive command menu (bit with no args)
128
+ # - Help browser (bit help)
129
+ # - Bash completion
130
+ # =============================================================================
131
+ COMMAND_TREE = [
132
+ ("branch", "View and switch branches across repos", []),
133
+ ("config", "View and configure repo/layer settings", []),
134
+ ("deps", "Show layer and recipe dependencies", [
135
+ ("deps layers", "Show layer dependency tree"),
136
+ ("deps recipe", "Show recipe dependency tree"),
137
+ ]),
138
+ ("export", "Export patches from layer repos", [
139
+ ("export prep", "Prepare commits for export (reorder/group)"),
140
+ ]),
141
+ ("explore", "Interactively explore commits in layer repos", []),
142
+ ("fragments", "Browse and manage OE configuration fragments", [
143
+ ("fragments list", "List all available fragments"),
144
+ ("fragments enable", "Enable a fragment"),
145
+ ("fragments disable", "Disable a fragment"),
146
+ ("fragments show", "Show fragment content"),
147
+ ]),
148
+ ("help", "Browse help for all commands", []),
149
+ ("init", "Initialize/setup OE/Yocto build environment", [
150
+ ("init shell", "Start a shell with build environment pre-sourced"),
151
+ ("init clone", "Show or clone core Yocto/OE repositories"),
152
+ ]),
153
+ ("projects", "Manage multiple project directories", [
154
+ ("projects add", "Add a project directory"),
155
+ ("projects remove", "Remove a project from list"),
156
+ ("projects list", "List all registered projects"),
157
+ ]),
158
+ ("recipes", "Search and browse BitBake recipes", []),
159
+ ("repos", "List layer repos", [
160
+ ("repos status", "Show one-liner status for each repo"),
161
+ ]),
162
+ ("search", "Search OpenEmbedded Layer Index for layers", []),
163
+ ("status", "Show local commit summary for layer repos", []),
164
+ ("update", "Update git repos referenced by layers in bblayers.conf", []),
165
+ ]
166
+
167
+ # =============================================================================
168
+ # COMMAND CATEGORIES FOR MENU GROUPING
169
+ # =============================================================================
170
+ # Define categories and which commands belong to each.
171
+ # CATEGORY_ORDER determines display order in the menu.
172
+ # =============================================================================
173
+ COMMAND_CATEGORIES = {
174
+ "git": ("Git/Repository", ["explore", "branch", "status", "update", "repos", "export"]),
175
+ "config": ("Configuration", ["config", "fragments", "init", "projects"]),
176
+ "discovery": ("Discovery", ["recipes", "deps", "search"]),
177
+ "help": ("Help", ["help"]),
178
+ }
179
+
180
+ CATEGORY_ORDER = ["git", "config", "discovery", "help"]
181
+
182
+ # Commands grouped by interaction style (alternative grouping)
183
+ INTERACTION_CATEGORIES = {
184
+ "interactive": ("Interactive (fzf browsers)", ["explore", "branch", "recipes", "fragments", "deps", "search", "projects", "help"]),
185
+ "output": ("Output & Exit", ["status", "update", "repos", "config", "init", "export"]),
186
+ }
187
+
188
+ INTERACTION_ORDER = ["interactive", "output"]
189
+
190
+
191
+ def get_menu_sort_mode(defaults_file: str = ".bit.defaults") -> str:
192
+ """
193
+ Get the menu sort mode from defaults file.
194
+
195
+ Returns:
196
+ Sort mode: "category" (default), "alpha", or "interactive"
197
+ """
198
+ try:
199
+ if os.path.exists(defaults_file):
200
+ with open(defaults_file, encoding="utf-8") as f:
201
+ data = json.load(f)
202
+ mode = data.get("menu_sort", "category")
203
+ if mode in ("category", "alpha", "interactive"):
204
+ return mode
205
+ except (json.JSONDecodeError, OSError):
206
+ pass
207
+ return "category"
208
+
209
+
210
+ def sort_commands_by_mode(
211
+ commands: List[Tuple[str, str, List[Tuple[str, str]]]],
212
+ mode: str,
213
+ ) -> Tuple[List[Tuple[str, str, List[Tuple[str, str]]]], Optional[Dict[str, str]]]:
214
+ """
215
+ Sort/group commands based on the specified mode.
216
+
217
+ Args:
218
+ commands: List of (cmd, description, subcommands) tuples
219
+ mode: "alpha", "category", or "interactive"
220
+
221
+ Returns:
222
+ Tuple of (sorted_commands, categories_dict) where categories_dict maps
223
+ command names to their category headers (or None for alpha mode).
224
+ """
225
+ if mode == "alpha":
226
+ return sorted(commands, key=lambda x: x[0]), None
227
+
228
+ # Build command lookup
229
+ cmd_lookup = {cmd: (cmd, desc, subs) for cmd, desc, subs in commands}
230
+
231
+ if mode == "interactive":
232
+ categories = INTERACTION_CATEGORIES
233
+ order = INTERACTION_ORDER
234
+ else: # category (default)
235
+ categories = COMMAND_CATEGORIES
236
+ order = CATEGORY_ORDER
237
+
238
+ sorted_commands: List[Tuple[str, str, List[Tuple[str, str]]]] = []
239
+ cmd_to_category: Dict[str, str] = {}
240
+
241
+ for cat_key in order:
242
+ if cat_key not in categories:
243
+ continue
244
+ cat_label, cat_cmds = categories[cat_key]
245
+ first_in_category = True
246
+ for cmd_name in cat_cmds:
247
+ if cmd_name in cmd_lookup:
248
+ if first_in_category:
249
+ cmd_to_category[cmd_name] = cat_label
250
+ first_in_category = False
251
+ sorted_commands.append(cmd_lookup[cmd_name])
252
+
253
+ # Add any commands not in any category at the end
254
+ categorized = set()
255
+ for cat_key in order:
256
+ if cat_key in categories:
257
+ categorized.update(categories[cat_key][1])
258
+ for cmd, desc, subs in commands:
259
+ if cmd not in categorized:
260
+ sorted_commands.append((cmd, desc, subs))
261
+
262
+ return sorted_commands, cmd_to_category
263
+
264
+ # Flatten for legacy COMMANDS list (used by some completers)
265
+ COMMANDS = [(cmd, desc) for cmd, desc, _ in COMMAND_TREE]
266
+ for cmd, desc, subs in COMMAND_TREE:
267
+ for subcmd, subdesc in subs:
268
+ COMMANDS.append((subcmd, subdesc))
269
+
270
+
271
+ def build_parser() -> Tuple[argparse.ArgumentParser, Dict]:
272
+ """Build the argument parser with all subcommands."""
273
+ parser = argparse.ArgumentParser(
274
+ description="Tools for BitBake projects",
275
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
276
+ )
277
+ parser.add_argument(
278
+ "--completion",
279
+ action="store_true",
280
+ help="Show bash completion setup instructions",
281
+ )
282
+ # Global options available to all subcommands
283
+ parser.add_argument(
284
+ "--bblayers",
285
+ default=None,
286
+ help="Path to bblayers.conf (auto-detects conf/bblayers.conf or build/conf/bblayers.conf if omitted)",
287
+ )
288
+ parser.add_argument(
289
+ "--defaults-file",
290
+ default=".bit.defaults",
291
+ help="Path to per-repo default actions file",
292
+ )
293
+ parser.add_argument(
294
+ "--all", "-a",
295
+ action="store_true",
296
+ help="Discover all layers/repos (slower, finds unconfigured layers)",
297
+ )
298
+ subparsers = parser.add_subparsers(dest="command", metavar="action", title="commands")
299
+
300
+ # ---------- update ----------
301
+ update = subparsers.add_parser(
302
+ "update",
303
+ aliases=["u"],
304
+ help="Update git repos referenced by layers in bblayers.conf",
305
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
306
+ )
307
+ update.add_argument("repo", nargs="?", help="Specific repo to update (by name, index, or path)")
308
+ update.add_argument("--dry-run", action="store_true", help="Show the commands that would run without executing them")
309
+ update.add_argument("--resume", action="store_true", help="Resume from previous run using the resume file")
310
+ update.add_argument(
311
+ "--resume-file",
312
+ default=".bitbake-layers-update.resume",
313
+ help="Path to resume state file",
314
+ )
315
+ update.add_argument(
316
+ "--plain",
317
+ action="store_true",
318
+ help="Use text-based prompts instead of fzf",
319
+ )
320
+
321
+ # ---------- status ----------
322
+ status = subparsers.add_parser(
323
+ "status",
324
+ help="Show local commit summary for layer repos (alias for 'explore --status')",
325
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
326
+ )
327
+ status.add_argument("--max-commits", type=int, default=10, help="Max commits to show with -v")
328
+ status.add_argument(
329
+ "-v", "--verbose",
330
+ action="count",
331
+ default=0,
332
+ help="Increase verbosity: -v shows commits (limited), -vv shows all commits",
333
+ )
334
+ status.add_argument(
335
+ "--fetch",
336
+ action="store_true",
337
+ help="Fetch from origin before checking status (shows accurate upstream changes)",
338
+ )
339
+
340
+ # ---------- repos ----------
341
+ repos = subparsers.add_parser(
342
+ "repos",
343
+ help="List layer repos",
344
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
345
+ )
346
+ repos_sub = repos.add_subparsers(dest="repos_command", metavar="command")
347
+
348
+ repos_status = repos_sub.add_parser(
349
+ "status",
350
+ help="Show one-liner status: commit counts, branch, clean/dirty",
351
+ )
352
+ repos_status.add_argument(
353
+ "--fetch",
354
+ action="store_true",
355
+ help="Fetch from origin before checking",
356
+ )
357
+
358
+ # ---------- init ----------
359
+ init = subparsers.add_parser(
360
+ "init",
361
+ help="Initialize/setup OE/Yocto build environment",
362
+ formatter_class=argparse.RawDescriptionHelpFormatter,
363
+ epilog="""
364
+ Subcommands:
365
+ (none) Show the source command for oe-init-build-env
366
+ shell Start a new shell with build environment pre-sourced
367
+ clone Clone core Yocto/OE repositories for a new project
368
+
369
+ Examples:
370
+ # Show the source command to set up build environment
371
+ bit init
372
+
373
+ # Start a shell ready to run bitbake commands
374
+ bit init shell
375
+
376
+ # Clone core repos then show setup command
377
+ bit init clone --execute
378
+ bit init clone -b scarthgap --execute
379
+ """,
380
+ )
381
+ init_subparsers = init.add_subparsers(dest="init_command")
382
+
383
+ # Default behavior (no subcommand): show source command
384
+ init.add_argument(
385
+ "--layers-dir",
386
+ default="layers",
387
+ help="Directory containing layers (relative to current dir)",
388
+ )
389
+
390
+ # init shell subcommand
391
+ init_shell = init_subparsers.add_parser(
392
+ "shell",
393
+ help="Start a shell with build environment pre-sourced",
394
+ formatter_class=argparse.RawDescriptionHelpFormatter,
395
+ epilog="""
396
+ Start a new interactive shell with the OE/Yocto build environment already
397
+ sourced. You can immediately run bitbake commands without manual setup.
398
+
399
+ The shell prompt will show "(oe)" to indicate the environment is active.
400
+
401
+ Examples:
402
+ # Start a shell ready for bitbake
403
+ bit init shell
404
+
405
+ # With custom layers directory
406
+ bit init shell --layers-dir my-layers
407
+ """,
408
+ )
409
+ init_shell.add_argument(
410
+ "--layers-dir",
411
+ default="layers",
412
+ help="Directory containing layers (relative to current dir)",
413
+ )
414
+
415
+ # init clone subcommand
416
+ init_clone = init_subparsers.add_parser(
417
+ "clone",
418
+ help="Show or clone core Yocto/OE repositories",
419
+ formatter_class=argparse.RawDescriptionHelpFormatter,
420
+ epilog="""
421
+ Clone the three core repositories needed for a Yocto/OE build:
422
+ - bitbake: The build engine
423
+ - openembedded-core: Core metadata and recipes
424
+ - meta-yocto: Yocto Project reference distribution (poky)
425
+
426
+ Examples:
427
+ # Show clone commands without executing
428
+ bit init clone
429
+
430
+ # Clone repos into layers/ directory
431
+ bit init clone --execute
432
+
433
+ # Clone a specific branch (e.g., scarthgap)
434
+ bit init clone -b scarthgap --execute
435
+
436
+ # Clone to a custom layers directory
437
+ bit init clone --execute --layers-dir my-layers
438
+ """,
439
+ )
440
+ init_clone.add_argument(
441
+ "--execute",
442
+ action="store_true",
443
+ help="Actually clone the repos (default: just show commands)",
444
+ )
445
+ init_clone.add_argument(
446
+ "--branch", "-b",
447
+ default="master",
448
+ help="Branch to clone (master, scarthgap, kirkstone, etc.)",
449
+ )
450
+ init_clone.add_argument(
451
+ "--layers-dir",
452
+ default="layers",
453
+ help="Directory to clone repos into",
454
+ )
455
+
456
+ # Keep bootstrap as an alias for backwards compatibility
457
+ bootstrap = subparsers.add_parser(
458
+ "bootstrap",
459
+ help="(alias for 'init clone') Clone core Yocto/OE repositories",
460
+ formatter_class=argparse.RawDescriptionHelpFormatter,
461
+ epilog="""
462
+ NOTE: 'bootstrap' is an alias for 'init clone'. Consider using 'init clone' instead.
463
+
464
+ Examples:
465
+ bit init clone --execute
466
+ bit init clone -b scarthgap --execute
467
+ """,
468
+ )
469
+ bootstrap.add_argument(
470
+ "--clone",
471
+ action="store_true",
472
+ help="Actually clone the repos (default: just show commands)",
473
+ )
474
+ bootstrap.add_argument(
475
+ "--branch", "-b",
476
+ default="master",
477
+ help="Branch to clone (master, scarthgap, kirkstone, etc.)",
478
+ )
479
+ bootstrap.add_argument(
480
+ "--layers-dir",
481
+ default="layers",
482
+ help="Directory to clone repos into",
483
+ )
484
+
485
+ # ---------- search ----------
486
+ search = subparsers.add_parser(
487
+ "search",
488
+ help="Search OpenEmbedded Layer Index for layers",
489
+ formatter_class=argparse.RawDescriptionHelpFormatter,
490
+ epilog="""
491
+ Examples:
492
+ # Browse all layers interactively (fzf)
493
+ bit search
494
+
495
+ # Search for layers matching a term
496
+ bit search security
497
+
498
+ # Clone a layer directly
499
+ bit search -c meta-virtualization
500
+
501
+ # Get layer info for scripting (machine-readable key=value output)
502
+ bit search -i meta-virtualization
503
+ # Output: name=..., url=..., depends=..., optional=...
504
+
505
+ # Use in scripts:
506
+ url=$(bit search -i meta-virt | grep '^url=' | cut -d= -f2)
507
+ """,
508
+ )
509
+ search.add_argument(
510
+ "query",
511
+ nargs="?",
512
+ help="Search term (searches layer names and descriptions)",
513
+ )
514
+ search.add_argument(
515
+ "--branch", "-b",
516
+ default="master",
517
+ help="OE branch to search (master, scarthgap, kirkstone, etc.)",
518
+ )
519
+ search.add_argument(
520
+ "--force", "-f",
521
+ action="store_true",
522
+ help="Force refresh from layer index (ignore cache)",
523
+ )
524
+ search.add_argument(
525
+ "--clone", "-c",
526
+ action="store_true",
527
+ help="Clone the matching layer (requires exact match or single result)",
528
+ )
529
+ search.add_argument(
530
+ "--target", "-t",
531
+ metavar="DIR",
532
+ help="Target directory for clone (default: layers/<name>)",
533
+ )
534
+ search.add_argument(
535
+ "--info", "-i",
536
+ action="store_true",
537
+ help="Show layer info (URL, subdir, dependencies) for scripting",
538
+ )
539
+
540
+ # ---------- export ----------
541
+ export = subparsers.add_parser(
542
+ "export",
543
+ help="Export patches from layer repos",
544
+ formatter_class=argparse.RawDescriptionHelpFormatter,
545
+ epilog="""
546
+ Examples:
547
+ # Export all local commits into one directory with aggregate cover letter
548
+ bit export --target-dir ~/patches
549
+
550
+ # Interactively select commits using fzf (or manual input if fzf unavailable)
551
+ bit export --target-dir ~/patches --pick
552
+
553
+ # Export to per-repo subdirectories, each with its own cover letter
554
+ bit export --target-dir ~/patches --layout per-repo
555
+
556
+ # Combine: interactive selection + per-repo subdirectories
557
+ bit export --target-dir ~/patches --layout per-repo --pick
558
+
559
+ # Force overwrite existing export directory
560
+ bit export --target-dir ~/patches --force
561
+
562
+ # Export as version 2 of a patch series
563
+ bit export --target-dir ~/patches -v 2
564
+ # Produces: [OE-core][PATCH v2 01/05] commit title
565
+
566
+ # Create pull branches in each repo (for git pull requests)
567
+ bit export --target-dir ~/patches --branch feature-xyz
568
+ # Creates 'feature-xyz' branch in each repo, cover letter includes pull URLs
569
+
570
+ Options explained:
571
+ --layout flat (default):
572
+ All patches written to --target-dir with one aggregate cover letter.
573
+
574
+ --layout per-repo:
575
+ Each repo gets its own subdirectory with its own cover letter.
576
+
577
+ --pick:
578
+ Interactive commit selection with fzf (Tab to mark, Enter to confirm).
579
+ Without --pick, exports all commits from origin/<branch>..HEAD.
580
+
581
+ -v N, --series-version N:
582
+ Add version to patch subjects: [PATCH v2 01/05] instead of [PATCH 01/05].
583
+
584
+ -b NAME, --branch NAME:
585
+ Create a pull branch in each repo with the selected commits.
586
+ Branch is based on origin/<branch> with commits cherry-picked.
587
+ Cover letter includes 'git pull <url> <branch>' commands.
588
+ Use --force to overwrite existing branches.
589
+ """,
590
+ )
591
+ export.add_argument(
592
+ "--layout",
593
+ choices=["flat", "per-repo"],
594
+ default="flat",
595
+ help="'flat': all patches in one directory with aggregate cover letter. "
596
+ "'per-repo': each repo gets its own subdirectory with own cover letter",
597
+ )
598
+ export.add_argument(
599
+ "--target-dir",
600
+ help="Directory to place exported patches (created if needed)",
601
+ )
602
+ export.add_argument(
603
+ "--force",
604
+ action="store_true",
605
+ help="Remove existing contents of target directory before export",
606
+ )
607
+ export.add_argument(
608
+ "--pick",
609
+ action="store_true",
610
+ help="Interactively select commits using fzf (or manual input). "
611
+ "Without this flag, exports all commits from origin/<branch>..HEAD",
612
+ )
613
+ export.add_argument(
614
+ "-v", "--series-version",
615
+ type=int,
616
+ metavar="N",
617
+ help="Version number for patch series (e.g., -v 2 produces [PATCH v2 1/5])",
618
+ )
619
+ export.add_argument(
620
+ "-b", "--branch",
621
+ metavar="NAME",
622
+ help="Create a branch with selected commits in each repo for pulling. "
623
+ "Branch is based on origin/<current-branch> with selected commits cherry-picked. "
624
+ "Use --force to overwrite existing branches. "
625
+ "Cover letter will include 'git pull' URLs.",
626
+ )
627
+ export.add_argument(
628
+ "--from-branch",
629
+ metavar="NAME",
630
+ help="Export commits from specified branch instead of HEAD. "
631
+ "Useful after 'export prep --branch' to export from the prep branch.",
632
+ )
633
+ export.add_argument(
634
+ "--export-state-file",
635
+ default=".bit.export-state.json",
636
+ help="JSON file to remember previous export choices per repo. "
637
+ "Stores HEAD SHA, include/skip decision, and range for each repo. "
638
+ "On next export, if HEAD matches, previous choices become defaults",
639
+ )
640
+
641
+ # Export subcommands
642
+ export_sub = export.add_subparsers(dest="export_command", metavar="command")
643
+
644
+ export_prep = export_sub.add_parser(
645
+ "prep",
646
+ help="Group commits for upstream via rebase",
647
+ formatter_class=argparse.RawDescriptionHelpFormatter,
648
+ epilog="""
649
+ Examples:
650
+ # Interactively select commits to group for upstream in each repo
651
+ bit export prep
652
+
653
+ # Create backup branches before rebasing
654
+ bit export prep --backup
655
+
656
+ # Create a branch for PR submission at the cut point
657
+ bit export prep --branch zedd/kernel
658
+
659
+ # Preview what would happen without making changes
660
+ bit export prep --dry-run
661
+
662
+ Workflow:
663
+ For each repo with local commits:
664
+ 1. Shows commits between origin/<branch> and HEAD
665
+ 2. User selects which commits are destined for upstream (Tab=toggle, Space=range)
666
+ 3. User selects insertion point showing selected commit count
667
+ 4. Reorders: commits before insertion -> upstream commits -> remaining
668
+ 5. Optionally creates a branch at the cut point (via --branch or b/B keys)
669
+
670
+ After all repos are processed, prompts to proceed with export.
671
+ Saves prep state for automatic use by subsequent 'export' command.
672
+
673
+ This is useful before running 'export' when you have scattered commits
674
+ that need to be grouped together for upstream submission.
675
+ """,
676
+ )
677
+ export_prep.add_argument(
678
+ "--backup",
679
+ action="store_true",
680
+ help="Create backup branch (e.g., <branch>-backup-<timestamp>) before rebasing",
681
+ )
682
+ export_prep.add_argument(
683
+ "--dry-run",
684
+ action="store_true",
685
+ help="Show what would happen without making changes",
686
+ )
687
+ export_prep.add_argument(
688
+ "--plain",
689
+ action="store_true",
690
+ help="Use text-based prompts instead of fzf",
691
+ )
692
+ export_prep.add_argument(
693
+ "--branch",
694
+ metavar="NAME",
695
+ help="Create branch at last upstream commit for PR submission",
696
+ )
697
+
698
+ # ---------- explore ----------
699
+ explore = subparsers.add_parser(
700
+ "explore",
701
+ aliases=["x"],
702
+ help="Interactively explore commits in layer repos",
703
+ formatter_class=argparse.RawDescriptionHelpFormatter,
704
+ epilog="""
705
+ Examples:
706
+ # Interactive exploration (fzf)
707
+ bit explore
708
+ bit explore OE-core
709
+
710
+ # Text status output (like 'status' command)
711
+ bit explore --status
712
+ bit explore --status -v
713
+ bit explore --status --refresh
714
+
715
+ Repo list navigation:
716
+ Enter/-> Explore commits in selected repo
717
+ u Update (pull --rebase)
718
+ m Merge (pull)
719
+ r Refresh (fetch from origin)
720
+ v Toggle verbose display
721
+ s Show detailed status
722
+ q Quit
723
+
724
+ Commit browser navigation:
725
+ Tab Toggle single commit selection
726
+ Space Mark range endpoints (select all between)
727
+ ? Toggle preview pane
728
+ d Toggle diff mode (stat vs full patch)
729
+ c Copy commit hash to clipboard
730
+ e Export selected commit(s) as .patch
731
+ Esc/b/<- Back to repo list
732
+ q Quit entirely
733
+ """,
734
+ )
735
+ explore_repo_arg = explore.add_argument(
736
+ "repo",
737
+ nargs="?",
738
+ help="Jump directly to specific repo (by index, name, or path)",
739
+ )
740
+ explore.add_argument(
741
+ "--upstream-count",
742
+ type=int,
743
+ default=20,
744
+ help="Number of upstream commits to show for context",
745
+ )
746
+ explore.add_argument(
747
+ "--status",
748
+ action="store_true",
749
+ help="Print text status summary instead of interactive fzf",
750
+ )
751
+ explore.add_argument(
752
+ "--refresh",
753
+ action="store_true",
754
+ help="Fetch from origin before showing status",
755
+ )
756
+ explore.add_argument(
757
+ "-v", "--verbose",
758
+ action="count",
759
+ default=0,
760
+ help="Increase verbosity: -v shows commits (limited), -vv shows all commits",
761
+ )
762
+ explore.add_argument(
763
+ "--max-commits",
764
+ type=int,
765
+ default=10,
766
+ help="Max commits to show with -v",
767
+ )
768
+ if HAS_ARGCOMPLETE:
769
+ explore_repo_arg.completer = RepoCompleter()
770
+
771
+ # ---------- config ----------
772
+ config = subparsers.add_parser(
773
+ "config",
774
+ aliases=["c"],
775
+ help="View and configure repo/layer settings",
776
+ formatter_class=argparse.RawDescriptionHelpFormatter,
777
+ epilog="""
778
+ Examples:
779
+ # Interactive config (fzf interface)
780
+ bit config
781
+
782
+ # CLI: View config for a specific repo
783
+ bit config 1
784
+ bit config OE-core
785
+
786
+ # CLI: Set display name
787
+ bit config 1 --display-name "OE-core"
788
+ bit config 1 --display-name "" # clear
789
+
790
+ # CLI: Set update default
791
+ bit config 1 --update-default skip
792
+
793
+ # CLI: Edit layer.conf
794
+ bit config edit meta-mylayer
795
+
796
+ Interactive keybindings:
797
+ Enter/-> Configure repo (submenu with all options)
798
+ d Set display name (prompts for input)
799
+ r Set default to rebase
800
+ m Set default to merge
801
+ s Set default to skip
802
+ e Edit layer.conf in $EDITOR
803
+ q Quit
804
+ """,
805
+ )
806
+ config_repo_arg = config.add_argument(
807
+ "repo",
808
+ nargs="?",
809
+ help="Repo index (from list), name, or path to configure. Omit to list all repos. Use 'edit <layer>' to edit layer.conf.",
810
+ )
811
+ config_extra_arg = config.add_argument(
812
+ "extra_arg",
813
+ nargs="?",
814
+ help=argparse.SUPPRESS, # Hidden - used for 'edit <layer>' syntax
815
+ )
816
+ if HAS_ARGCOMPLETE:
817
+ config_repo_arg.completer = RepoCompleter(include_edit=True)
818
+ config_extra_arg.completer = LayerCompleter() # For 'edit <layer>'
819
+ config.add_argument(
820
+ "--display-name",
821
+ metavar="NAME",
822
+ help="Set custom display name for patch subjects (empty string to clear)",
823
+ )
824
+ config.add_argument(
825
+ "--update-default",
826
+ metavar="ACTION",
827
+ choices=["rebase", "merge", "skip"],
828
+ help="Set update default action for the repo (rebase, merge, or skip)",
829
+ )
830
+ config.add_argument(
831
+ "-e", "--edit",
832
+ action="store_true",
833
+ help="Open interactive config menu for the specified repo",
834
+ )
835
+
836
+ # ---------- deps ----------
837
+ deps = subparsers.add_parser(
838
+ "deps",
839
+ aliases=["d"],
840
+ help="Show layer and recipe dependencies",
841
+ formatter_class=argparse.RawDescriptionHelpFormatter,
842
+ epilog="""
843
+ Examples:
844
+ # Interactive layer dependency browser
845
+ bit deps
846
+ bit deps layers
847
+
848
+ # Show dependency tree for a specific layer
849
+ bit deps layers meta-oe
850
+ bit deps layers meta-networking
851
+
852
+ # Show reverse dependencies (what depends on this layer)
853
+ bit deps layers --reverse meta-oe
854
+ bit deps layers -r core
855
+
856
+ # Output formats
857
+ bit deps layers meta-oe --format=tree # ASCII tree (default)
858
+ bit deps layers meta-oe --format=dot # DOT graph (for graphviz)
859
+ bit deps layers meta-oe --format=list # Simple list
860
+
861
+ # Text list mode (no fzf)
862
+ bit deps layers --list
863
+ bit deps layers --list -v
864
+
865
+ Key bindings (in fzf browser):
866
+ Enter Show dependency tree
867
+ ctrl-r Show reverse dependencies
868
+ ctrl-d Output DOT format
869
+ ctrl-a Full graph DOT output
870
+ ? Toggle preview
871
+ q Quit
872
+ """,
873
+ )
874
+ deps.add_argument(
875
+ "--list", "-l",
876
+ action="store_true",
877
+ help="List all layers (text output, no fzf)",
878
+ )
879
+ deps.add_argument(
880
+ "-v", "--verbose",
881
+ action="store_true",
882
+ help="Verbose output (with --list)",
883
+ )
884
+
885
+ deps_sub = deps.add_subparsers(dest="deps_command", metavar="command")
886
+
887
+ # deps layers
888
+ deps_layers = deps_sub.add_parser("layers", help="Show layer dependency tree")
889
+ deps_layers.add_argument(
890
+ "layer",
891
+ nargs="?",
892
+ help="Specific layer name (optional, for tree view)",
893
+ )
894
+ deps_layers.add_argument(
895
+ "-r", "--reverse",
896
+ action="store_true",
897
+ help="Show reverse dependencies (what depends on this layer)",
898
+ )
899
+ deps_layers.add_argument(
900
+ "--format",
901
+ choices=["tree", "dot", "list"],
902
+ default="tree",
903
+ help="Output format (default: tree)",
904
+ )
905
+ deps_layers.add_argument(
906
+ "--list", "-l",
907
+ action="store_true",
908
+ help="List all layers (text output, no fzf)",
909
+ )
910
+ deps_layers.add_argument(
911
+ "-v", "--verbose",
912
+ action="store_true",
913
+ help="Verbose output (with --list)",
914
+ )
915
+
916
+ # deps recipe (placeholder for future)
917
+ deps_recipe = deps_sub.add_parser("recipe", help="Show recipe dependency tree")
918
+ deps_recipe.add_argument(
919
+ "recipe",
920
+ help="Recipe name",
921
+ )
922
+ deps_recipe.add_argument(
923
+ "-r", "--rdepends",
924
+ action="store_true",
925
+ help="Include runtime dependencies (RDEPENDS)",
926
+ )
927
+ deps_recipe.add_argument(
928
+ "--format",
929
+ choices=["tree", "dot", "list"],
930
+ default="tree",
931
+ help="Output format (default: tree)",
932
+ )
933
+
934
+ # ---------- branch ----------
935
+ branch = subparsers.add_parser(
936
+ "branch",
937
+ aliases=["b"],
938
+ help="View and switch branches across repos",
939
+ formatter_class=argparse.RawDescriptionHelpFormatter,
940
+ epilog="""
941
+ Examples:
942
+ # Interactive branch management (fzf interface)
943
+ bit branch
944
+
945
+ # CLI: View current branch for a repo
946
+ bit branch 1
947
+ bit branch OE-core
948
+
949
+ # CLI: Switch a single repo to a branch
950
+ bit branch 1 kirkstone
951
+ bit branch OE-core kirkstone
952
+
953
+ # CLI: Switch all repos to the same branch
954
+ bit branch --all kirkstone
955
+
956
+ Interactive keybindings:
957
+ Enter/-> Select repo and pick branch
958
+ <- Back to repo list
959
+ q Quit
960
+
961
+ Only clean repos can be switched. Dirty repos are skipped with a warning.
962
+ """,
963
+ )
964
+ branch_repo_arg = branch.add_argument(
965
+ "repo",
966
+ nargs="?",
967
+ help="Repo index (from list) or path. Omit to list all repos.",
968
+ )
969
+ branch.add_argument(
970
+ "target_branch",
971
+ nargs="?",
972
+ metavar="BRANCH",
973
+ help="Branch to checkout",
974
+ )
975
+ branch.add_argument(
976
+ "--all",
977
+ dest="all_repos",
978
+ action="store_true",
979
+ help="Switch all repos to the specified branch",
980
+ )
981
+ if HAS_ARGCOMPLETE:
982
+ branch_repo_arg.completer = RepoCompleter()
983
+
984
+ # ---------- help ----------
985
+ help_cmd = subparsers.add_parser(
986
+ "help",
987
+ aliases=["h"],
988
+ help="Browse help for all commands (interactive)",
989
+ formatter_class=argparse.RawDescriptionHelpFormatter,
990
+ description="""\
991
+ Interactive help browser with preview pane.
992
+
993
+ Browse all commands with live preview of their help text.
994
+ Select a command and press Enter to run it.
995
+
996
+ Keybindings:
997
+ Enter Run selected command
998
+ \\ Expand/collapse subcommands
999
+ ? Toggle preview pane
1000
+ q Quit
1001
+ """,
1002
+ )
1003
+
1004
+ # ---------- projects ----------
1005
+ projects = subparsers.add_parser(
1006
+ "projects",
1007
+ aliases=["p"],
1008
+ help="Manage multiple bit working directories",
1009
+ formatter_class=argparse.RawDescriptionHelpFormatter,
1010
+ description="""\
1011
+ Manage and switch between multiple Yocto/OE project directories.
1012
+
1013
+ Run without subcommands for an interactive project picker.
1014
+
1015
+ Projects are stored in ~/.config/bit/projects.json.
1016
+ """,
1017
+ )
1018
+ projects_sub = projects.add_subparsers(dest="projects_command", metavar="command")
1019
+
1020
+ # projects add
1021
+ projects_add = projects_sub.add_parser(
1022
+ "add",
1023
+ help="Add a project directory",
1024
+ )
1025
+ projects_add.add_argument(
1026
+ "path",
1027
+ nargs="?",
1028
+ help="Path to project directory (default: current directory)",
1029
+ )
1030
+ projects_add.add_argument(
1031
+ "-n", "--name",
1032
+ help="Display name for the project",
1033
+ )
1034
+ projects_add.add_argument(
1035
+ "-d", "--description",
1036
+ help="Optional description",
1037
+ )
1038
+
1039
+ # projects remove
1040
+ projects_remove = projects_sub.add_parser(
1041
+ "remove",
1042
+ help="Remove a project from the list",
1043
+ )
1044
+ projects_remove.add_argument(
1045
+ "path",
1046
+ nargs="?",
1047
+ help="Path to project directory (interactive if omitted)",
1048
+ )
1049
+
1050
+ # projects list
1051
+ projects_sub.add_parser(
1052
+ "list",
1053
+ help="List all registered projects",
1054
+ )
1055
+
1056
+ # projects shell (alias for init shell)
1057
+ projects_shell = projects_sub.add_parser(
1058
+ "shell",
1059
+ help="Start a shell with build environment (alias for 'init shell')",
1060
+ )
1061
+ projects_shell.add_argument(
1062
+ "--layers-dir",
1063
+ default="layers",
1064
+ help="Directory containing layers (relative to current dir)",
1065
+ )
1066
+
1067
+ # ---------- recipes ----------
1068
+ recipes = subparsers.add_parser(
1069
+ "recipes",
1070
+ aliases=["r"],
1071
+ help="Search and browse BitBake recipes",
1072
+ formatter_class=argparse.RawDescriptionHelpFormatter,
1073
+ epilog="""
1074
+ Examples:
1075
+ # Source selection menu, then search/browser
1076
+ bit recipes
1077
+
1078
+ # Browse ALL local recipes (full list, navigate with fzf search)
1079
+ bit recipes --browse
1080
+
1081
+ # Search with a query
1082
+ bit recipes linux
1083
+
1084
+ # Skip menu, search configured layers only
1085
+ bit recipes --configured linux
1086
+
1087
+ # Skip menu, search all local (configured + discovered)
1088
+ bit recipes --local linux
1089
+
1090
+ # Skip menu, search layer index API
1091
+ bit recipes --index linux
1092
+
1093
+ # Filter to specific layer
1094
+ bit recipes --layer meta-oe
1095
+
1096
+ # Filter by SECTION
1097
+ bit recipes --section kernel
1098
+
1099
+ # Text output (no fzf)
1100
+ bit recipes --list linux
1101
+
1102
+ # Rebuild cache
1103
+ bit recipes --force
1104
+
1105
+ Key bindings (in fzf browser):
1106
+ Enter View recipe in $PAGER or $EDITOR
1107
+ ctrl-e Edit recipe in $EDITOR (local only)
1108
+ alt-c Copy path to clipboard
1109
+ alt-d Show recipe dependencies
1110
+ alt-s Switch source (Configured → Local → Index)
1111
+ ? Toggle preview
1112
+ q Quit
1113
+ """,
1114
+ )
1115
+ recipes.add_argument(
1116
+ "query",
1117
+ nargs="?",
1118
+ help="Search term (searches recipe names, summaries, descriptions)",
1119
+ )
1120
+ recipes.add_argument(
1121
+ "--browse",
1122
+ action="store_true",
1123
+ help="Browse ALL local recipes (no query filter, full list)",
1124
+ )
1125
+ recipes.add_argument(
1126
+ "--configured",
1127
+ action="store_true",
1128
+ help="Skip menu, search configured layers only",
1129
+ )
1130
+ recipes.add_argument(
1131
+ "--local",
1132
+ action="store_true",
1133
+ help="Skip menu, search all local (configured + discovered)",
1134
+ )
1135
+ recipes.add_argument(
1136
+ "--index",
1137
+ action="store_true",
1138
+ help="Skip menu, search layer index API",
1139
+ )
1140
+ recipes.add_argument(
1141
+ "--layer",
1142
+ metavar="NAME",
1143
+ help="Filter to specific layer",
1144
+ )
1145
+ recipes.add_argument(
1146
+ "--section",
1147
+ metavar="SEC",
1148
+ help="Filter by SECTION (base, kernel, multimedia, etc.)",
1149
+ )
1150
+ recipes.add_argument(
1151
+ "--list",
1152
+ action="store_true",
1153
+ help="Text output (no fzf)",
1154
+ )
1155
+ recipes.add_argument(
1156
+ "--force",
1157
+ action="store_true",
1158
+ help="Rebuild cache",
1159
+ )
1160
+ recipes.add_argument(
1161
+ "--branch", "-b",
1162
+ default="master",
1163
+ help="Branch for layer index API (master, scarthgap, kirkstone, etc.)",
1164
+ )
1165
+ # Search field options
1166
+ recipes.add_argument(
1167
+ "--name", "-n",
1168
+ action="store_true",
1169
+ help="Search recipe names (default if no field specified)",
1170
+ )
1171
+ recipes.add_argument(
1172
+ "--summary", "-s",
1173
+ action="store_true",
1174
+ help="Search SUMMARY field",
1175
+ )
1176
+ recipes.add_argument(
1177
+ "--description", "-d",
1178
+ action="store_true",
1179
+ help="Search DESCRIPTION field",
1180
+ )
1181
+ recipes.add_argument(
1182
+ "--sort",
1183
+ choices=["name", "layer"],
1184
+ default=None,
1185
+ help="Sort results by name (default) or layer",
1186
+ )
1187
+
1188
+ # ---------- fragments ----------
1189
+ fragments = subparsers.add_parser(
1190
+ "fragments",
1191
+ aliases=["f", "frags"],
1192
+ help="Browse and manage OE configuration fragments",
1193
+ formatter_class=argparse.RawDescriptionHelpFormatter,
1194
+ epilog="""
1195
+ Examples:
1196
+ # Interactive fragment browser with fzf
1197
+ bit fragments
1198
+
1199
+ # List all available fragments
1200
+ bit fragments list
1201
+ bit fragments list -v
1202
+
1203
+ # Enable a fragment
1204
+ bit fragments enable meta/yocto/sstate-mirror-cdn
1205
+ bit fragments enable machine/qemuarm64
1206
+
1207
+ # Disable a fragment
1208
+ bit fragments disable meta/yocto/sstate-mirror-cdn
1209
+
1210
+ # Show fragment content
1211
+ bit fragments show meta/yocto/sstate-mirror-cdn
1212
+
1213
+ Key bindings (in fzf browser):
1214
+ Enter Toggle enable/disable
1215
+ Tab Multi-select fragments
1216
+ e Enable all selected
1217
+ d Disable all selected
1218
+ v View fragment file
1219
+ c Edit toolcfg.conf
1220
+ ? Toggle preview
1221
+ q Quit
1222
+ """,
1223
+ )
1224
+ fragments.add_argument(
1225
+ "--confpath",
1226
+ default=None,
1227
+ help="Path to toolcfg.conf (default: conf/toolcfg.conf)",
1228
+ )
1229
+ fragments.add_argument(
1230
+ "--list", "-l",
1231
+ action="store_true",
1232
+ help="List all fragments (text output, no fzf)",
1233
+ )
1234
+
1235
+ fragments_sub = fragments.add_subparsers(dest="fragment_command", metavar="command")
1236
+
1237
+ # fragments list
1238
+ fragments_list = fragments_sub.add_parser("list", help="List all available fragments")
1239
+ fragments_list.add_argument("-v", "--verbose", action="store_true", help="Show descriptions")
1240
+
1241
+ # fragments enable
1242
+ fragments_enable = fragments_sub.add_parser("enable", help="Enable a fragment")
1243
+ fragments_enable.add_argument("fragmentname", nargs="+", help="Fragment name(s) to enable")
1244
+
1245
+ # fragments disable
1246
+ fragments_disable = fragments_sub.add_parser("disable", help="Disable a fragment")
1247
+ fragments_disable.add_argument("fragmentname", nargs="+", help="Fragment name(s) to disable")
1248
+
1249
+ # fragments show
1250
+ fragments_show = fragments_sub.add_parser("show", help="Show fragment content")
1251
+ fragments_show.add_argument("fragmentname", help="Fragment name to show")
1252
+
1253
+ return parser, {"export": export}
1254
+
1255
+
1256
+ def _has_valid_project_context() -> bool:
1257
+ """
1258
+ Check if we're in a valid bitbake project directory.
1259
+ Returns True if bblayers.conf exists or layers can be discovered.
1260
+ """
1261
+ # Check for bblayers.conf
1262
+ candidates = ["conf/bblayers.conf", "build/conf/bblayers.conf"]
1263
+ for cand in candidates:
1264
+ if os.path.exists(cand):
1265
+ return True
1266
+
1267
+ # Try to discover layers (look for conf/layer.conf files)
1268
+ try:
1269
+ for root, dirs, files in os.walk(".", topdown=True):
1270
+ # Skip hidden dirs and common non-layer dirs
1271
+ dirs[:] = [d for d in dirs if not d.startswith(".") and d not in ("build", "downloads", "sstate-cache", "tmp")]
1272
+ if "conf" in dirs:
1273
+ layer_conf = os.path.join(root, "conf", "layer.conf")
1274
+ if os.path.exists(layer_conf):
1275
+ return True
1276
+ # Don't go too deep
1277
+ if root.count(os.sep) > 3:
1278
+ dirs.clear()
1279
+ except (OSError, PermissionError):
1280
+ pass
1281
+
1282
+ return False
1283
+
1284
+
1285
+ SORT_MODES = ["category", "alpha", "interactive"]
1286
+ SORT_MODE_LABELS = {
1287
+ "category": "Category",
1288
+ "alpha": "A-Z",
1289
+ "interactive": "Interactive",
1290
+ }
1291
+
1292
+
1293
+ def set_menu_sort_mode(mode: str, defaults_file: str = ".bit.defaults") -> bool:
1294
+ """
1295
+ Save menu sort mode to defaults file.
1296
+
1297
+ Args:
1298
+ mode: Sort mode ("category", "alpha", or "interactive")
1299
+ defaults_file: Path to defaults file
1300
+
1301
+ Returns:
1302
+ True if saved successfully
1303
+ """
1304
+ if mode not in SORT_MODES:
1305
+ return False
1306
+
1307
+ try:
1308
+ data = {}
1309
+ if os.path.exists(defaults_file):
1310
+ with open(defaults_file, encoding="utf-8") as f:
1311
+ data = json.load(f)
1312
+
1313
+ data["menu_sort"] = mode
1314
+
1315
+ with open(defaults_file, "w", encoding="utf-8") as f:
1316
+ json.dump(data, f, indent=2)
1317
+ return True
1318
+ except (json.JSONDecodeError, OSError):
1319
+ return False
1320
+
1321
+
1322
+ def fzf_command_menu(defaults_file: str = ".bit.defaults") -> Optional[str]:
1323
+ """Show fzf menu to select a subcommand. Returns command name or None if cancelled."""
1324
+ # Get sort mode from defaults
1325
+ sort_mode = get_menu_sort_mode(defaults_file)
1326
+ current_selection: Optional[str] = None
1327
+
1328
+ while True:
1329
+ # Sort/group commands according to mode
1330
+ sorted_commands, categories = sort_commands_by_mode(COMMAND_TREE, sort_mode)
1331
+
1332
+ # Build header with current sort mode indicator
1333
+ mode_label = SORT_MODE_LABELS.get(sort_mode, sort_mode)
1334
+ header = f"Enter=run | \\=expand | ctrl-s=sort ({mode_label}) | q=quit"
1335
+
1336
+ result = fzf_expandable_menu(
1337
+ sorted_commands,
1338
+ header=header,
1339
+ prompt="bit ",
1340
+ height="~80%",
1341
+ categories=categories,
1342
+ sort_key="ctrl-s",
1343
+ initial_selection=current_selection,
1344
+ )
1345
+
1346
+ # Handle sort mode cycling
1347
+ if isinstance(result, tuple) and result[0] == "SORT":
1348
+ # Preserve cursor position if we have a valid command
1349
+ if result[1]:
1350
+ current_selection = result[1]
1351
+ # Cycle to next sort mode
1352
+ current_idx = SORT_MODES.index(sort_mode) if sort_mode in SORT_MODES else 0
1353
+ sort_mode = SORT_MODES[(current_idx + 1) % len(SORT_MODES)]
1354
+ # Save the new mode
1355
+ set_menu_sort_mode(sort_mode, defaults_file)
1356
+ continue
1357
+
1358
+ # Regular selection or cancel
1359
+ return result
1360
+
1361
+
1362
+ def fzf_help_browser() -> Optional[str]:
1363
+ """
1364
+ Interactive help browser with preview pane.
1365
+ Returns command name to run, or None to exit.
1366
+ """
1367
+ # Get the script path for preview commands
1368
+ script_path = os.path.abspath(sys.argv[0])
1369
+
1370
+ # Add (general) to the command tree for help browser
1371
+ commands = [("(general)", "Overview and global options", [])] + COMMAND_TREE
1372
+
1373
+ # Build preview command dynamically from COMMAND_TREE
1374
+ # Collect all subcommands that need special handling (have spaces)
1375
+ subcommand_cases = []
1376
+ subcommand_to_parent = {}
1377
+ for cmd, _, subs in COMMAND_TREE:
1378
+ for subcmd, _ in subs:
1379
+ # Subcommands like "init clone" need explicit elif
1380
+ subcommand_cases.append(
1381
+ f'elif [ "$cmd" = "{subcmd}" ]; then "{script_path}" {subcmd} --help 2>&1; '
1382
+ )
1383
+ # Map subcommand to parent for return value
1384
+ subcommand_to_parent[subcmd] = cmd
1385
+
1386
+ preview_cmd = (
1387
+ f'cmd={{1}}; '
1388
+ f'if [ "$cmd" = "(general)" ]; then "{script_path}" --help 2>&1; '
1389
+ + "".join(subcommand_cases)
1390
+ + f'else "{script_path}" "$cmd" --help 2>&1; fi'
1391
+ )
1392
+
1393
+ # Options provider: parse --help output for command options
1394
+ def get_options(cmd: str) -> List[Tuple[str, str]]:
1395
+ if cmd == "(general)":
1396
+ return parse_help_options(script_path, "")
1397
+ return parse_help_options(script_path, cmd)
1398
+
1399
+ selected = fzf_expandable_menu(
1400
+ commands,
1401
+ header="Enter=run | \\=expand | v=options | ?=preview | q=quit",
1402
+ prompt="Select command: ",
1403
+ height="100%",
1404
+ preview_cmd=preview_cmd,
1405
+ preview_window="right,60%,wrap",
1406
+ options_provider=get_options,
1407
+ )
1408
+
1409
+ if not selected:
1410
+ return None
1411
+
1412
+ # Don't try to "run" general help
1413
+ if selected == "(general)":
1414
+ return None
1415
+
1416
+ # For subcommands, return the parent command
1417
+ if selected in subcommand_to_parent:
1418
+ return subcommand_to_parent[selected]
1419
+
1420
+ return selected
1421
+
1422
+
1423
+ def main(argv=None) -> int:
1424
+ """Main entry point."""
1425
+ def handle_sigint(signum, frame):
1426
+ print("\nInterrupted, exiting.")
1427
+ sys.exit(1)
1428
+
1429
+ signal.signal(signal.SIGINT, handle_sigint)
1430
+
1431
+ parser, subparsers = build_parser()
1432
+ if HAS_ARGCOMPLETE:
1433
+ argcomplete.autocomplete(parser)
1434
+ args = parser.parse_args(argv)
1435
+
1436
+ # Import commands module here to avoid circular imports
1437
+ from . import commands
1438
+
1439
+ if args.completion:
1440
+ print("Bash completion setup for bit")
1441
+ print("=" * 42)
1442
+ print()
1443
+ print("1. Install argcomplete:")
1444
+ print(" pip install argcomplete")
1445
+ print()
1446
+ print("2. Add to your ~/.bashrc:")
1447
+ print(' eval "$(register-python-argcomplete bit)"')
1448
+ print()
1449
+ print("3. Reload your shell:")
1450
+ print(" source ~/.bashrc")
1451
+ print()
1452
+ print("Usage:")
1453
+ print(" bit config m<TAB> # complete repo names")
1454
+ print(" bit config e<TAB> # complete to 'edit'")
1455
+ print(" bit -<TAB> # complete options")
1456
+ print()
1457
+ if HAS_ARGCOMPLETE:
1458
+ print("Status: argcomplete is installed")
1459
+ else:
1460
+ print("Status: argcomplete is NOT installed")
1461
+ return 0
1462
+
1463
+ # Check for current project and change to it if set
1464
+ # (skip for 'projects' command itself to avoid confusion)
1465
+ if args.command not in ("projects", "p"):
1466
+ current_project = commands.get_current_project()
1467
+ if current_project and os.path.isdir(current_project):
1468
+ # Only change if we're not already in the project or a subdirectory
1469
+ cwd = os.path.abspath(os.getcwd())
1470
+ proj = os.path.abspath(current_project)
1471
+ if not cwd.startswith(proj):
1472
+ os.chdir(current_project)
1473
+
1474
+ if not args.command:
1475
+ # Check if we're in a valid project context
1476
+ # If not, offer to select/add a project
1477
+ if not _has_valid_project_context():
1478
+ # No project context - show projects picker
1479
+ print("No bblayers.conf found and no layers discovered.")
1480
+ print("Use 'projects' to select or add a project directory.\n")
1481
+ if fzf_available():
1482
+ result = commands.run_projects(args, from_auto_prompt=True)
1483
+ if result == 2:
1484
+ # User selected a project and wants to see command menu
1485
+ # Change to the selected project directory
1486
+ current_project = commands.get_current_project()
1487
+ if current_project and os.path.isdir(current_project):
1488
+ os.chdir(current_project)
1489
+ print() # Blank line before menu
1490
+ # Fall through to show command menu
1491
+ else:
1492
+ return result
1493
+ else:
1494
+ print("Run: bit projects add /path/to/project")
1495
+ return 1
1496
+
1497
+ # Show fzf menu if available, otherwise print help
1498
+ if fzf_available():
1499
+ selected = fzf_command_menu()
1500
+ if selected == "help":
1501
+ # Show interactive help browser
1502
+ cmd_to_run = fzf_help_browser()
1503
+ if cmd_to_run:
1504
+ # Split in case of subcommands like "init clone", "export prep"
1505
+ return main(cmd_to_run.split())
1506
+ return 0
1507
+ elif selected:
1508
+ # Re-invoke main with the selected command to get proper arg defaults
1509
+ # Split in case of subcommands like "init clone"
1510
+ return main(selected.split())
1511
+ else:
1512
+ return 0 # Cancelled
1513
+ else:
1514
+ print("Note: Install 'fzf' for interactive menus. Showing help instead.\n")
1515
+ parser.print_help()
1516
+ return 0
1517
+
1518
+ if args.command == "export":
1519
+ export_cmd = getattr(args, "export_command", None)
1520
+ if export_cmd == "prep":
1521
+ return commands.run_prepare_export(args)
1522
+ if not args.target_dir:
1523
+ subparsers["export"].print_help()
1524
+ return 1
1525
+ return commands.run_export(args)
1526
+
1527
+ if args.command in ("update", "u"):
1528
+ return commands.run_update(args)
1529
+ if args.command == "status":
1530
+ # status is an alias for explore --status
1531
+ args.status = True
1532
+ args.refresh = getattr(args, 'fetch', False) # map old --fetch to --refresh
1533
+ args.repo = None
1534
+ args.upstream_count = 20
1535
+ return commands.run_explore(args)
1536
+ if args.command == "repos":
1537
+ return commands.run_repos(args)
1538
+ if args.command == "init":
1539
+ if getattr(args, "init_command", None) == "clone":
1540
+ # Map init clone args to bootstrap args
1541
+ args.clone = getattr(args, "execute", False)
1542
+ return commands.run_bootstrap(args)
1543
+ if getattr(args, "init_command", None) == "shell":
1544
+ return commands.run_init_shell(args)
1545
+ return commands.run_init(args)
1546
+ if args.command == "bootstrap":
1547
+ # Legacy alias - bootstrap uses --clone, init clone uses --execute
1548
+ return commands.run_bootstrap(args)
1549
+ if args.command == "search":
1550
+ return commands.run_search(args)
1551
+ if args.command in ("config", "c"):
1552
+ return commands.run_config(args)
1553
+ if args.command in ("deps", "d"):
1554
+ return commands.run_deps(args)
1555
+ if args.command in ("branch", "b"):
1556
+ return commands.run_branch(args)
1557
+ if args.command in ("explore", "x"):
1558
+ return commands.run_explore(args)
1559
+ if args.command in ("help", "h"):
1560
+ if fzf_available():
1561
+ cmd_to_run = fzf_help_browser()
1562
+ if cmd_to_run:
1563
+ return main(cmd_to_run.split())
1564
+ return 0
1565
+ else:
1566
+ parser.print_help()
1567
+ return 0
1568
+ if args.command in ("projects", "p"):
1569
+ return commands.run_projects(args)
1570
+ if args.command in ("recipes", "r"):
1571
+ return commands.run_recipe(args)
1572
+ if args.command in ("fragments", "f", "frags"):
1573
+ return commands.run_fragment(args)
1574
+
1575
+ parser.error(f"Unknown command: {args.command}")
1576
+ return 1
1577
+
1578
+
1579
+ if __name__ == "__main__":
1580
+ sys.exit(main())