bitp 1.0.7__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,1505 @@
1
+ #
2
+ # Copyright (C) 2025 Bruce Ashfield <bruce.ashfield@gmail.com>
3
+ #
4
+ # SPDX-License-Identifier: GPL-2.0-only
5
+ #
6
+ """Projects command - manage multiple bit working directories."""
7
+
8
+ import json
9
+ import os
10
+ import shutil
11
+ import subprocess
12
+ import sys
13
+ from typing import Dict, List, Optional
14
+
15
+ from ..core import Colors, get_fzf_color_args, fzf_available
16
+
17
+
18
+ def _get_projects_file() -> str:
19
+ """Get path to global projects config file."""
20
+ config_dir = os.path.expanduser("~/.config/bit")
21
+ os.makedirs(config_dir, exist_ok=True)
22
+ return os.path.join(config_dir, "projects.json")
23
+
24
+
25
+ def load_projects() -> Dict[str, dict]:
26
+ """
27
+ Load projects from config file.
28
+
29
+ Returns dict mapping path -> {name, description, last_used}.
30
+ Excludes internal keys like __current_project__, __directory_browser__, etc.
31
+ """
32
+ projects_file = _get_projects_file()
33
+ if not os.path.isfile(projects_file):
34
+ return {}
35
+ try:
36
+ with open(projects_file, "r") as f:
37
+ data = json.load(f)
38
+ # Filter out internal keys (start with __)
39
+ return {k: v for k, v in data.items() if not k.startswith("__")}
40
+ except (json.JSONDecodeError, OSError):
41
+ return {}
42
+
43
+
44
+ def get_current_project() -> Optional[str]:
45
+ """Get the currently active project path, or None if not set."""
46
+ config_file = _get_projects_file()
47
+ if not os.path.isfile(config_file):
48
+ return None
49
+ try:
50
+ with open(config_file, "r") as f:
51
+ data = json.load(f)
52
+ path = data.get("__current_project__")
53
+ # Verify it still exists
54
+ if path and os.path.isdir(path):
55
+ return path
56
+ return None
57
+ except (json.JSONDecodeError, OSError):
58
+ return None
59
+
60
+
61
+ def set_current_project(path: Optional[str]) -> None:
62
+ """Set the currently active project path (or None to clear)."""
63
+ config_file = _get_projects_file()
64
+ data = {}
65
+ if os.path.isfile(config_file):
66
+ try:
67
+ with open(config_file, "r") as f:
68
+ data = json.load(f)
69
+ except (json.JSONDecodeError, OSError):
70
+ pass
71
+
72
+ if path:
73
+ data["__current_project__"] = os.path.abspath(path)
74
+ elif "__current_project__" in data:
75
+ del data["__current_project__"]
76
+
77
+ try:
78
+ with open(config_file, "w") as f:
79
+ json.dump(data, f, indent=2)
80
+ except OSError:
81
+ pass
82
+
83
+
84
+ def save_projects(projects: Dict[str, dict]) -> None:
85
+ """Save projects to config file."""
86
+ projects_file = _get_projects_file()
87
+ try:
88
+ with open(projects_file, "w") as f:
89
+ json.dump(projects, f, indent=2)
90
+ except OSError as e:
91
+ print(f"Warning: Could not save projects: {e}", file=sys.stderr)
92
+
93
+
94
+ def add_project(path: str, name: Optional[str] = None, description: str = "") -> bool:
95
+ """
96
+ Add a project to the list.
97
+
98
+ Args:
99
+ path: Absolute path to the project directory
100
+ name: Display name (defaults to directory basename)
101
+ description: Optional description
102
+
103
+ Returns:
104
+ True if added, False if already exists or invalid
105
+ """
106
+ path = os.path.abspath(path)
107
+ if not os.path.isdir(path):
108
+ print(f"Error: Not a directory: {path}", file=sys.stderr)
109
+ return False
110
+
111
+ projects = load_projects()
112
+ if path in projects:
113
+ print(f"Project already registered: {path}")
114
+ return False
115
+
116
+ if not name:
117
+ name = os.path.basename(path)
118
+
119
+ projects[path] = {
120
+ "name": name,
121
+ "description": description,
122
+ "last_used": None,
123
+ }
124
+ save_projects(projects)
125
+ print(f"Added project: {name} ({path})")
126
+ return True
127
+
128
+
129
+ def remove_project(path: str) -> bool:
130
+ """Remove a project from the list."""
131
+ path = os.path.abspath(path)
132
+ projects = load_projects()
133
+ if path not in projects:
134
+ print(f"Project not found: {path}", file=sys.stderr)
135
+ return False
136
+
137
+ name = projects[path].get("name", path)
138
+ del projects[path]
139
+ save_projects(projects)
140
+ print(f"Removed project: {name}")
141
+ return True
142
+
143
+
144
+ def _detect_project_info(path: str) -> dict:
145
+ """Detect info about a potential project directory."""
146
+ info = {
147
+ "has_bblayers": False,
148
+ "has_defaults": False,
149
+ "layer_count": 0,
150
+ "branch": None,
151
+ }
152
+
153
+ # Check for bblayers.conf in build/conf or any conf directory
154
+ for root, dirs, files in os.walk(path):
155
+ depth = root[len(path):].count(os.sep)
156
+ if depth > 3:
157
+ dirs[:] = []
158
+ continue
159
+ if "bblayers.conf" in files:
160
+ info["has_bblayers"] = True
161
+ break
162
+
163
+ # Check for defaults file
164
+ if os.path.isfile(os.path.join(path, ".bit.defaults")):
165
+ info["has_defaults"] = True
166
+
167
+ # Count layers
168
+ for root, dirs, files in os.walk(path):
169
+ depth = root[len(path):].count(os.sep)
170
+ if depth > 4:
171
+ dirs[:] = []
172
+ continue
173
+ if "layer.conf" in files and "conf" in root:
174
+ info["layer_count"] += 1
175
+
176
+ # Get git branch if in a git repo
177
+ try:
178
+ result = subprocess.run(
179
+ ["git", "-C", path, "rev-parse", "--abbrev-ref", "HEAD"],
180
+ capture_output=True,
181
+ text=True,
182
+ )
183
+ if result.returncode == 0:
184
+ info["branch"] = result.stdout.strip()
185
+ except Exception:
186
+ pass
187
+
188
+ return info
189
+
190
+
191
+ def _remove_project_interactive() -> bool:
192
+ """Show picker to select a project to stop tracking."""
193
+ if not fzf_available():
194
+ return False
195
+
196
+ projects = load_projects()
197
+ if not projects:
198
+ print("No projects to remove.")
199
+ return False
200
+
201
+ menu_lines = []
202
+ for path, info in sorted(projects.items(), key=lambda x: x[1].get("name", x[0]).lower()):
203
+ name = info.get("name", os.path.basename(path))
204
+ exists = " " if os.path.isdir(path) else Colors.red("! ")
205
+ menu_lines.append(f"{path}\t{exists}{name:<20} {Colors.dim(path)}")
206
+
207
+ menu_input = "\n".join(menu_lines)
208
+
209
+ fzf_args = [
210
+ "fzf",
211
+ "--no-multi",
212
+ "--no-sort",
213
+ "--ansi",
214
+ "--height", "~40%",
215
+ "--layout", "reverse",
216
+ "--header", "Select project to stop tracking (Enter=remove, ←/q=cancel)",
217
+ "--prompt", "Remove: ",
218
+ "--with-nth", "2..",
219
+ "--delimiter", "\t",
220
+ "--bind", "q:abort",
221
+ "--bind", "left:abort",
222
+ ]
223
+
224
+ fzf_args.extend(get_fzf_color_args())
225
+
226
+ try:
227
+ result = subprocess.run(
228
+ fzf_args,
229
+ input=menu_input,
230
+ stdout=subprocess.PIPE,
231
+ text=True,
232
+ )
233
+ except FileNotFoundError:
234
+ return False
235
+
236
+ if result.returncode != 0 or not result.stdout.strip():
237
+ return False
238
+
239
+ selected = result.stdout.strip().split("\t")[0]
240
+ if selected in projects:
241
+ return remove_project(selected)
242
+
243
+ return False
244
+
245
+
246
+ def fzf_project_picker(include_browse: bool = True, show_exit_hint: bool = False) -> Optional[str]:
247
+ """
248
+ Show fzf menu to select a project.
249
+
250
+ Args:
251
+ include_browse: Include option to browse for new project
252
+ show_exit_hint: Show hint about Enter exiting to command menu
253
+
254
+ Returns:
255
+ Selected project path, or special commands like:
256
+ - "SELECT:<path>" - Space was pressed, set as active but stay in picker
257
+ - "EXIT:<path>" - Enter was pressed, exit and optionally chain to command menu
258
+ - Other special values for menu options
259
+ - None if cancelled
260
+ """
261
+ if not fzf_available():
262
+ return None
263
+
264
+ projects = load_projects()
265
+ current_project = get_current_project()
266
+
267
+ if not projects and not include_browse:
268
+ print("No projects registered. Use 'bit projects add' to add one.")
269
+ return None
270
+
271
+ # Build menu
272
+ menu_lines = []
273
+
274
+ for path, info in sorted(projects.items(), key=lambda x: x[1].get("name", x[0]).lower()):
275
+ name = info.get("name", os.path.basename(path))
276
+ desc = info.get("description", "")
277
+
278
+ # Check if path still exists and if it's current
279
+ is_current = (path == current_project)
280
+ if os.path.isdir(path):
281
+ if is_current:
282
+ status = Colors.cyan("●") # Current project
283
+ else:
284
+ status = Colors.green("○")
285
+ else:
286
+ status = Colors.red("!")
287
+
288
+ # Detect current state
289
+ proj_info = _detect_project_info(path) if os.path.isdir(path) else {}
290
+ layers = proj_info.get("layer_count", 0)
291
+ branch = proj_info.get("branch", "")
292
+
293
+ extra = []
294
+ if is_current:
295
+ extra.append("ACTIVE")
296
+ if layers:
297
+ extra.append(f"{layers} layers")
298
+ if branch:
299
+ extra.append(branch)
300
+ extra_str = f" ({', '.join(extra)})" if extra else ""
301
+
302
+ display = f"{status} {name:<20} {Colors.dim(path)}{Colors.cyan(extra_str)}"
303
+ if desc:
304
+ display += f" - {desc}"
305
+
306
+ menu_lines.append(f"{path}\t{display}")
307
+
308
+ # Add browse/add/remove options
309
+ if include_browse:
310
+ menu_lines.append("---\t" + "─" * 50)
311
+ menu_lines.append("ADD_CWD\t+ Add current directory")
312
+ menu_lines.append("ADD_PATH\t+ Enter path manually...")
313
+ menu_lines.append("BROWSE\t+ Browse for directory...")
314
+ if projects:
315
+ menu_lines.append("REMOVE\t- Remove project tracking...")
316
+ if current_project:
317
+ menu_lines.append("CLEAR\t✖ Clear active project")
318
+ # Settings
319
+ browser = get_directory_browser()
320
+ browser_desc = {"auto": "auto (broot>ranger>nnn>fzf)", "nnn": "nnn", "fzf": "fzf", "broot": "broot", "ranger": "ranger"}.get(browser, browser)
321
+ menu_lines.append(f"SETTINGS\t⚙ Settings: browser={browser_desc}")
322
+
323
+ menu_input = "\n".join(menu_lines)
324
+
325
+ # Build header based on context
326
+ if show_exit_hint:
327
+ header = "Space=activate Enter=done (→menu) +=browse -=remove c=clear s=settings q=quit"
328
+ else:
329
+ header = "Space=activate Enter=done +=browse -=remove c=clear s=settings q=quit"
330
+
331
+ fzf_args = [
332
+ "fzf",
333
+ "--no-multi",
334
+ "--no-sort",
335
+ "--ansi",
336
+ "--height", "~50%",
337
+ "--header", header,
338
+ "--prompt", "Project: ",
339
+ "--with-nth", "2..",
340
+ "--delimiter", "\t",
341
+ "--bind", "q:abort",
342
+ "--bind", "left:abort",
343
+ "--expect", "space,+,-,c,s",
344
+ ]
345
+
346
+ fzf_args.extend(get_fzf_color_args())
347
+
348
+ try:
349
+ result = subprocess.run(
350
+ fzf_args,
351
+ input=menu_input,
352
+ stdout=subprocess.PIPE,
353
+ text=True,
354
+ )
355
+ except FileNotFoundError:
356
+ return None
357
+
358
+ if result.returncode != 0 or not result.stdout.strip():
359
+ return None
360
+
361
+ # Parse output - with --expect, first line is key (or empty if Enter), second is selection
362
+ # Don't strip() first as it removes the leading newline when Enter is pressed
363
+ lines = result.stdout.split("\n")
364
+ key = lines[0].strip() if lines else ""
365
+ selected = lines[1].split("\t")[0].strip() if len(lines) > 1 else ""
366
+
367
+ # Handle key shortcuts
368
+ if key == "+":
369
+ return "BROWSE"
370
+ elif key == "space" and selected and selected not in ("---", "ADD_CWD", "ADD_PATH", "BROWSE", "REMOVE", "CLEAR", "SETTINGS"):
371
+ # Space = select/activate the project but stay in picker
372
+ return f"SELECT:{selected}"
373
+ elif key == "-" and selected and selected not in ("---", "ADD_CWD", "ADD_PATH", "BROWSE", "REMOVE", "CLEAR", "SETTINGS"):
374
+ # Remove the highlighted project directly
375
+ return f"REMOVE:{selected}"
376
+ elif key == "c":
377
+ return "CLEAR"
378
+ elif key == "s":
379
+ return "SETTINGS"
380
+
381
+ # No special key (Enter) - return the selected item
382
+ if not selected or selected == "---":
383
+ return fzf_project_picker(include_browse, show_exit_hint)
384
+
385
+ # Enter on a project - signal to exit
386
+ if selected not in ("ADD_CWD", "ADD_PATH", "BROWSE", "REMOVE", "CLEAR", "SETTINGS"):
387
+ return f"EXIT:{selected}"
388
+
389
+ return selected
390
+
391
+
392
+ def get_directory_browser() -> str:
393
+ """Get configured directory browser preference."""
394
+ config_file = _get_projects_file()
395
+ if os.path.isfile(config_file):
396
+ try:
397
+ with open(config_file, "r") as f:
398
+ data = json.load(f)
399
+ return data.get("__directory_browser__", "auto")
400
+ except (json.JSONDecodeError, OSError):
401
+ pass
402
+ return "auto"
403
+
404
+
405
+ def set_directory_browser(browser: str) -> None:
406
+ """Set directory browser preference (auto, nnn, fzf)."""
407
+ config_file = _get_projects_file()
408
+ data = {}
409
+ if os.path.isfile(config_file):
410
+ try:
411
+ with open(config_file, "r") as f:
412
+ data = json.load(f)
413
+ except (json.JSONDecodeError, OSError):
414
+ pass
415
+ data["__directory_browser__"] = browser
416
+ try:
417
+ with open(config_file, "w") as f:
418
+ json.dump(data, f, indent=2)
419
+ except OSError:
420
+ pass
421
+
422
+
423
+ def get_git_viewer() -> str:
424
+ """Get configured git history viewer preference."""
425
+ config_file = _get_projects_file()
426
+ if os.path.isfile(config_file):
427
+ try:
428
+ with open(config_file, "r") as f:
429
+ data = json.load(f)
430
+ return data.get("__git_viewer__", "auto")
431
+ except (json.JSONDecodeError, OSError):
432
+ pass
433
+ return "auto"
434
+
435
+
436
+ def set_git_viewer(viewer: str) -> None:
437
+ """Set git history viewer preference."""
438
+ config_file = _get_projects_file()
439
+ data = {}
440
+ if os.path.isfile(config_file):
441
+ try:
442
+ with open(config_file, "r") as f:
443
+ data = json.load(f)
444
+ except (json.JSONDecodeError, OSError):
445
+ pass
446
+ data["__git_viewer__"] = viewer
447
+ try:
448
+ with open(config_file, "w") as f:
449
+ json.dump(data, f, indent=2)
450
+ except OSError:
451
+ pass
452
+
453
+
454
+ def get_graph_renderer() -> str:
455
+ """Get configured graph renderer preference for dependency visualization."""
456
+ config_file = _get_projects_file()
457
+ if os.path.isfile(config_file):
458
+ try:
459
+ with open(config_file, "r") as f:
460
+ data = json.load(f)
461
+ return data.get("__graph_renderer__", "auto")
462
+ except (json.JSONDecodeError, OSError):
463
+ pass
464
+ return "auto"
465
+
466
+
467
+ def set_graph_renderer(renderer: str) -> None:
468
+ """Set graph renderer preference (auto, graph-easy, ascii)."""
469
+ config_file = _get_projects_file()
470
+ data = {}
471
+ if os.path.isfile(config_file):
472
+ try:
473
+ with open(config_file, "r") as f:
474
+ data = json.load(f)
475
+ except (json.JSONDecodeError, OSError):
476
+ pass
477
+ data["__graph_renderer__"] = renderer
478
+ try:
479
+ with open(config_file, "w") as f:
480
+ json.dump(data, f, indent=2)
481
+ except OSError:
482
+ pass
483
+
484
+
485
+ def get_preferred_graph_renderer() -> str:
486
+ """
487
+ Get the preferred graph renderer that's actually available.
488
+
489
+ Returns: 'graph-easy', 'ascii', or the configured preference.
490
+ """
491
+ preference = get_graph_renderer()
492
+
493
+ if preference == "auto":
494
+ # Prefer graph-easy if available
495
+ if shutil.which("graph-easy"):
496
+ return "graph-easy"
497
+ return "ascii"
498
+ elif preference == "graph-easy":
499
+ if shutil.which("graph-easy"):
500
+ return "graph-easy"
501
+ return "ascii" # Fall back if not available
502
+ else:
503
+ return preference
504
+
505
+
506
+ def get_preferred_git_viewer() -> Optional[str]:
507
+ """
508
+ Get the preferred git viewer that's actually available.
509
+
510
+ Returns the command name (tig, lazygit, gitk) or None if none available.
511
+ """
512
+ preference = get_git_viewer()
513
+
514
+ # Define viewer priority
515
+ viewers = ["tig", "lazygit", "gitk"]
516
+
517
+ if preference == "auto":
518
+ # Return first available
519
+ for viewer in viewers:
520
+ if shutil.which(viewer):
521
+ return viewer
522
+ return None
523
+ elif preference in viewers and shutil.which(preference):
524
+ return preference
525
+ else:
526
+ # Configured viewer not available, fall back to auto
527
+ for viewer in viewers:
528
+ if shutil.which(viewer):
529
+ return viewer
530
+ return None
531
+
532
+
533
+ def _pick_git_viewer() -> None:
534
+ """Pick git history viewer."""
535
+ if not fzf_available():
536
+ return
537
+
538
+ current = get_git_viewer()
539
+
540
+ options = [
541
+ ("auto", "Auto-detect (tig > lazygit > gitk)"),
542
+ ("tig", "tig - ncurses git interface"),
543
+ ("lazygit", "lazygit - terminal UI for git"),
544
+ ("gitk", "gitk - graphical git browser"),
545
+ ]
546
+
547
+ menu_lines = []
548
+ for value, desc in options:
549
+ marker = "● " if value == current else " "
550
+ available = ""
551
+ if value in ("tig", "lazygit", "gitk") and not shutil.which(value):
552
+ available = Colors.dim(" (not installed)")
553
+ menu_lines.append(f"{value}\t{marker}{desc}{available}")
554
+
555
+ menu_input = "\n".join(menu_lines)
556
+
557
+ fzf_args = [
558
+ "fzf",
559
+ "--no-multi",
560
+ "--no-sort",
561
+ "--ansi",
562
+ "--height", "~20%",
563
+ "--layout", "reverse",
564
+ "--header", "Select git history viewer (Enter=select, ←/q=back)",
565
+ "--prompt", "Viewer: ",
566
+ "--with-nth", "2..",
567
+ "--delimiter", "\t",
568
+ "--bind", "q:abort",
569
+ "--bind", "left:abort",
570
+ ]
571
+
572
+ fzf_args.extend(get_fzf_color_args())
573
+
574
+ try:
575
+ result = subprocess.run(
576
+ fzf_args,
577
+ input=menu_input,
578
+ stdout=subprocess.PIPE,
579
+ text=True,
580
+ )
581
+ except FileNotFoundError:
582
+ return
583
+
584
+ if result.returncode != 0 or not result.stdout.strip():
585
+ return
586
+
587
+ selected = result.stdout.strip().split("\t")[0]
588
+ if selected in ("auto", "tig", "lazygit", "gitk"):
589
+ set_git_viewer(selected)
590
+
591
+
592
+ def get_preview_layout() -> str:
593
+ """Get configured preview pane layout preference."""
594
+ config_file = _get_projects_file()
595
+ if os.path.isfile(config_file):
596
+ try:
597
+ with open(config_file, "r") as f:
598
+ data = json.load(f)
599
+ return data.get("__preview_layout__", "down")
600
+ except (json.JSONDecodeError, OSError):
601
+ pass
602
+ return "down"
603
+
604
+
605
+ def set_preview_layout(layout: str) -> None:
606
+ """Set preview pane layout preference."""
607
+ config_file = _get_projects_file()
608
+ data = {}
609
+ if os.path.isfile(config_file):
610
+ try:
611
+ with open(config_file, "r") as f:
612
+ data = json.load(f)
613
+ except (json.JSONDecodeError, OSError):
614
+ pass
615
+ data["__preview_layout__"] = layout
616
+ try:
617
+ with open(config_file, "w") as f:
618
+ json.dump(data, f, indent=2)
619
+ except OSError:
620
+ pass
621
+
622
+
623
+ def get_preview_window_arg(size: str = "50%") -> str:
624
+ """Get the fzf --preview-window argument based on configured layout."""
625
+ layout = get_preview_layout()
626
+ if layout == "right":
627
+ return f"right:{size}:wrap"
628
+ elif layout == "up":
629
+ return f"up,{size},wrap"
630
+ else: # down (default)
631
+ return f"down,{size},wrap"
632
+
633
+
634
+ def _pick_preview_layout() -> None:
635
+ """Pick preview pane layout."""
636
+ if not fzf_available():
637
+ return
638
+
639
+ current = get_preview_layout()
640
+
641
+ options = [
642
+ ("down", "Bottom - preview below commit list"),
643
+ ("right", "Right - preview beside commit list (side-by-side)"),
644
+ ("up", "Top - preview above commit list"),
645
+ ]
646
+
647
+ menu_lines = []
648
+ for value, desc in options:
649
+ marker = "● " if value == current else " "
650
+ menu_lines.append(f"{value}\t{marker}{desc}")
651
+
652
+ menu_input = "\n".join(menu_lines)
653
+
654
+ fzf_args = [
655
+ "fzf",
656
+ "--no-multi",
657
+ "--no-sort",
658
+ "--ansi",
659
+ "--height", "~20%",
660
+ "--layout", "reverse",
661
+ "--header", "Select preview pane layout (Enter=select, ←/q=back)",
662
+ "--prompt", "Layout: ",
663
+ "--with-nth", "2..",
664
+ "--delimiter", "\t",
665
+ "--bind", "q:abort",
666
+ "--bind", "left:abort",
667
+ ]
668
+
669
+ fzf_args.extend(get_fzf_color_args())
670
+
671
+ try:
672
+ result = subprocess.run(
673
+ fzf_args,
674
+ input=menu_input,
675
+ stdout=subprocess.PIPE,
676
+ text=True,
677
+ )
678
+ except FileNotFoundError:
679
+ return
680
+
681
+ if result.returncode != 0 or not result.stdout.strip():
682
+ return
683
+
684
+ selected = result.stdout.strip().split("\t")[0]
685
+ if selected in ("down", "right", "up"):
686
+ set_preview_layout(selected)
687
+
688
+
689
+ def get_recipe_use_bitbake_layers() -> bool:
690
+ """Get whether to use bitbake-layers for recipe scanning (default: True)."""
691
+ config_file = _get_projects_file()
692
+ if os.path.isfile(config_file):
693
+ try:
694
+ with open(config_file, "r") as f:
695
+ data = json.load(f)
696
+ value = data.get("__recipe_use_bitbake_layers__", True)
697
+ # Handle string values from config
698
+ if isinstance(value, str):
699
+ return value.lower() not in ("false", "no", "0")
700
+ return bool(value)
701
+ except (json.JSONDecodeError, OSError):
702
+ pass
703
+ return True
704
+
705
+
706
+ def set_recipe_use_bitbake_layers(value: bool) -> None:
707
+ """Set whether to use bitbake-layers for recipe scanning."""
708
+ config_file = _get_projects_file()
709
+ data = {}
710
+ if os.path.isfile(config_file):
711
+ try:
712
+ with open(config_file, "r") as f:
713
+ data = json.load(f)
714
+ except (json.JSONDecodeError, OSError):
715
+ pass
716
+ data["__recipe_use_bitbake_layers__"] = value
717
+ try:
718
+ with open(config_file, "w") as f:
719
+ json.dump(data, f, indent=2)
720
+ except OSError:
721
+ pass
722
+
723
+
724
+ def _pick_recipe_use_bitbake_layers() -> None:
725
+ """Pick whether to use bitbake-layers for recipe scanning."""
726
+ if not fzf_available():
727
+ return
728
+
729
+ current = get_recipe_use_bitbake_layers()
730
+
731
+ options = [
732
+ (True, "bitbake-layers - Use bitbake-layers show-recipes (more accurate, slower)"),
733
+ (False, "File scan - Scan .bb files directly (faster, no bitbake env needed)"),
734
+ ]
735
+
736
+ menu_lines = []
737
+ for value, desc in options:
738
+ marker = "● " if value == current else " "
739
+ key = "true" if value else "false"
740
+ menu_lines.append(f"{key}\t{marker}{desc}")
741
+
742
+ menu_input = "\n".join(menu_lines)
743
+
744
+ fzf_args = [
745
+ "fzf",
746
+ "--no-multi",
747
+ "--no-sort",
748
+ "--ansi",
749
+ "--height", "~20%",
750
+ "--layout", "reverse",
751
+ "--header", "Recipe scan method (Enter=select, ←/q=back)",
752
+ "--prompt", "Method: ",
753
+ "--with-nth", "2..",
754
+ "--delimiter", "\t",
755
+ "--bind", "q:abort",
756
+ "--bind", "left:abort",
757
+ ]
758
+
759
+ fzf_args.extend(get_fzf_color_args())
760
+
761
+ try:
762
+ result = subprocess.run(
763
+ fzf_args,
764
+ input=menu_input,
765
+ stdout=subprocess.PIPE,
766
+ text=True,
767
+ )
768
+ except FileNotFoundError:
769
+ return
770
+
771
+ if result.returncode != 0 or not result.stdout.strip():
772
+ return
773
+
774
+ selected = result.stdout.strip().split("\t")[0]
775
+ if selected == "true":
776
+ set_recipe_use_bitbake_layers(True)
777
+ elif selected == "false":
778
+ set_recipe_use_bitbake_layers(False)
779
+
780
+
781
+ def _pick_directory_browser() -> None:
782
+ """Show settings menu for directory browser preferences."""
783
+ if not fzf_available():
784
+ return
785
+
786
+ while True:
787
+ current_browser = get_directory_browser()
788
+ current_colors = get_nnn_colors()
789
+
790
+ # Color descriptions
791
+ color_names = {
792
+ "0": "black", "1": "red", "2": "green", "3": "yellow",
793
+ "4": "blue", "5": "magenta", "6": "cyan", "7": "white"
794
+ }
795
+ color_desc = f"dirs={color_names.get(current_colors[0:1], '?')}" if current_colors else "default"
796
+
797
+ menu_lines = [
798
+ f"BROWSER\tBrowser: {current_browser}",
799
+ f"NNN_COLORS\tnnn colors: {color_desc} ({current_colors})",
800
+ ]
801
+
802
+ menu_input = "\n".join(menu_lines)
803
+
804
+ fzf_args = [
805
+ "fzf",
806
+ "--no-multi",
807
+ "--no-sort",
808
+ "--ansi",
809
+ "--height", "~20%",
810
+ "--layout", "reverse",
811
+ "--header", "Projects settings (Enter=edit, ←/q=back)",
812
+ "--prompt", "Setting: ",
813
+ "--with-nth", "2..",
814
+ "--delimiter", "\t",
815
+ "--bind", "q:abort",
816
+ "--bind", "left:abort",
817
+ ]
818
+
819
+ fzf_args.extend(get_fzf_color_args())
820
+
821
+ try:
822
+ result = subprocess.run(
823
+ fzf_args,
824
+ input=menu_input,
825
+ stdout=subprocess.PIPE,
826
+ text=True,
827
+ )
828
+ except FileNotFoundError:
829
+ return
830
+
831
+ if result.returncode != 0 or not result.stdout.strip():
832
+ return
833
+
834
+ selected = result.stdout.strip().split("\t")[0]
835
+
836
+ if selected == "BROWSER":
837
+ _pick_browser_option()
838
+ elif selected == "NNN_COLORS":
839
+ _pick_nnn_colors()
840
+
841
+
842
+ def _pick_browser_option() -> None:
843
+ """Pick directory browser."""
844
+ current = get_directory_browser()
845
+
846
+ options = [
847
+ ("auto", "Auto-detect (broot > ranger > nnn > fzf)"),
848
+ ("broot", "broot - fuzzy search, type paths directly"),
849
+ ("ranger", "ranger - vim-like file manager"),
850
+ ("nnn", "nnn - fast, minimal file manager"),
851
+ ("fzf", "fzf built-in browser"),
852
+ ]
853
+
854
+ menu_lines = []
855
+ for value, desc in options:
856
+ marker = "● " if value == current else " "
857
+ available = ""
858
+ if value in ("broot", "ranger", "nnn") and not shutil.which(value):
859
+ available = Colors.dim(" (not installed)")
860
+ menu_lines.append(f"{value}\t{marker}{desc}{available}")
861
+
862
+ menu_input = "\n".join(menu_lines)
863
+
864
+ fzf_args = [
865
+ "fzf",
866
+ "--no-multi",
867
+ "--no-sort",
868
+ "--ansi",
869
+ "--height", "~20%",
870
+ "--layout", "reverse",
871
+ "--header", "Select directory browser (Enter=select, ←/q=back)",
872
+ "--prompt", "Browser: ",
873
+ "--with-nth", "2..",
874
+ "--delimiter", "\t",
875
+ "--bind", "q:abort",
876
+ "--bind", "left:abort",
877
+ ]
878
+
879
+ fzf_args.extend(get_fzf_color_args())
880
+
881
+ try:
882
+ result = subprocess.run(
883
+ fzf_args,
884
+ input=menu_input,
885
+ stdout=subprocess.PIPE,
886
+ text=True,
887
+ )
888
+ except FileNotFoundError:
889
+ return
890
+
891
+ if result.returncode != 0 or not result.stdout.strip():
892
+ return
893
+
894
+ selected = result.stdout.strip().split("\t")[0]
895
+ if selected in ("auto", "fzf", "nnn", "broot", "ranger"):
896
+ set_directory_browser(selected)
897
+
898
+
899
+ def _pick_nnn_colors() -> None:
900
+ """Pick nnn color scheme."""
901
+ # NNN_COLORS format: up to 8 chars, each is a color 0-7
902
+ # We'll focus on the directory color (3rd char) which is usually the issue
903
+ current = get_nnn_colors()
904
+
905
+ presets = [
906
+ ("6234", "cyan dirs (default)"),
907
+ ("2234", "green dirs"),
908
+ ("3234", "yellow dirs"),
909
+ ("7234", "white dirs"),
910
+ ("5234", "magenta dirs"),
911
+ ("1234", "red dirs"),
912
+ ("4234", "blue dirs (nnn default)"),
913
+ ]
914
+
915
+ menu_lines = []
916
+ for value, desc in presets:
917
+ marker = "● " if value == current else " "
918
+ # Show color sample
919
+ color_code = {"1": "31", "2": "32", "3": "33", "4": "34", "5": "35", "6": "36", "7": "37"}.get(value[0], "0")
920
+ sample = f"\033[{color_code}m■■■\033[0m"
921
+ menu_lines.append(f"{value}\t{marker}{sample} {desc}")
922
+
923
+ menu_input = "\n".join(menu_lines)
924
+
925
+ fzf_args = [
926
+ "fzf",
927
+ "--no-multi",
928
+ "--no-sort",
929
+ "--ansi",
930
+ "--height", "~25%",
931
+ "--layout", "reverse",
932
+ "--header", "Select nnn directory color (Enter=select, ←/q=back)",
933
+ "--prompt", "Color: ",
934
+ "--with-nth", "2..",
935
+ "--delimiter", "\t",
936
+ "--bind", "q:abort",
937
+ "--bind", "left:abort",
938
+ ]
939
+
940
+ fzf_args.extend(get_fzf_color_args())
941
+
942
+ try:
943
+ result = subprocess.run(
944
+ fzf_args,
945
+ input=menu_input,
946
+ stdout=subprocess.PIPE,
947
+ text=True,
948
+ )
949
+ except FileNotFoundError:
950
+ return
951
+
952
+ if result.returncode != 0 or not result.stdout.strip():
953
+ return
954
+
955
+ selected = result.stdout.strip().split("\t")[0]
956
+ if selected:
957
+ set_nnn_colors(selected)
958
+
959
+
960
+ def browse_for_directory() -> Optional[str]:
961
+ """
962
+ Browse filesystem to select a directory.
963
+
964
+ Uses configured browser preference, or auto-detects.
965
+ Priority: broot > ranger > nnn > fzf
966
+ """
967
+ browser = get_directory_browser()
968
+
969
+ if browser == "auto":
970
+ # Auto-detect best available
971
+ if shutil.which("broot"):
972
+ return _browse_with_broot()
973
+ elif shutil.which("ranger"):
974
+ return _browse_with_ranger()
975
+ elif shutil.which("nnn"):
976
+ return _browse_with_nnn()
977
+ elif browser == "broot" and shutil.which("broot"):
978
+ return _browse_with_broot()
979
+ elif browser == "ranger" and shutil.which("ranger"):
980
+ return _browse_with_ranger()
981
+ elif browser == "nnn" and shutil.which("nnn"):
982
+ return _browse_with_nnn()
983
+
984
+ # Fall back to fzf
985
+ if fzf_available():
986
+ return _browse_with_fzf_walker()
987
+
988
+ return None
989
+
990
+
991
+ def _browse_with_broot() -> Optional[str]:
992
+ """Browse for directory using broot."""
993
+ import tempfile
994
+
995
+ start_dir = os.path.expanduser("~")
996
+
997
+ with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.broot') as f:
998
+ out_file = f.name
999
+
1000
+ try:
1001
+ print()
1002
+ print(f"{Colors.bold('broot directory browser')}")
1003
+ print(f" Type to fuzzy search /path : jump to absolute path")
1004
+ print(f" Enter : enter dir Alt+Enter : {Colors.cyan('SELECT and quit')}")
1005
+ print(f" Esc/q : cancel ? : help")
1006
+ print()
1007
+ input("Press Enter to open broot...")
1008
+
1009
+ # --only-folders: show only directories
1010
+ # --cmd: initial command (none)
1011
+ # We use :print_path verb bound to alt-enter
1012
+ result = subprocess.run(
1013
+ ["broot", "--only-folders", "--print-path", "-o", out_file, start_dir],
1014
+ )
1015
+
1016
+ if os.path.isfile(out_file):
1017
+ with open(out_file, "r") as f:
1018
+ selected = f.read().strip()
1019
+ if selected:
1020
+ # broot might output a file, get its directory
1021
+ if os.path.isfile(selected):
1022
+ return os.path.dirname(selected)
1023
+ elif os.path.isdir(selected):
1024
+ return selected
1025
+
1026
+ return None
1027
+ finally:
1028
+ try:
1029
+ os.unlink(out_file)
1030
+ except OSError:
1031
+ pass
1032
+
1033
+
1034
+ def _browse_with_ranger() -> Optional[str]:
1035
+ """Browse for directory using ranger with fzf integration."""
1036
+ import tempfile
1037
+
1038
+ start_dir = os.path.expanduser("~")
1039
+
1040
+ # Create temp directory for our ranger config
1041
+ temp_dir = tempfile.mkdtemp(prefix='ranger_bbp_')
1042
+ choosedir_file = os.path.join(temp_dir, 'choosedir')
1043
+ commands_file = os.path.join(temp_dir, 'commands.py')
1044
+ rc_file = os.path.join(temp_dir, 'rc.conf')
1045
+
1046
+ try:
1047
+ # Write custom commands.py with fzf_select
1048
+ commands_content = '''
1049
+ import subprocess
1050
+ import os.path
1051
+ from ranger.api.commands import Command
1052
+
1053
+ class fzf_select(Command):
1054
+ """Find a file or directory using fzf and jump to it."""
1055
+ def execute(self):
1056
+ # Use fd if available, otherwise find
1057
+ if subprocess.run(["which", "fd"], capture_output=True).returncode == 0:
1058
+ command = "fd --type d --hidden --exclude .git 2>/dev/null | fzf +m"
1059
+ else:
1060
+ command = "find . -type d -not -path '*/.*' 2>/dev/null | fzf +m"
1061
+ fzf = self.fm.execute_command(command, stdout=subprocess.PIPE)
1062
+ stdout, stderr = fzf.communicate()
1063
+ if fzf.returncode == 0:
1064
+ fzf_file = os.path.abspath(stdout.decode('utf-8').strip())
1065
+ if os.path.isdir(fzf_file):
1066
+ self.fm.cd(fzf_file)
1067
+ else:
1068
+ self.fm.select_file(fzf_file)
1069
+
1070
+ class fzf_cd(Command):
1071
+ """fzf to any directory from root."""
1072
+ def execute(self):
1073
+ start = self.arg(1) or os.path.expanduser("~")
1074
+ if subprocess.run(["which", "fd"], capture_output=True).returncode == 0:
1075
+ command = f"fd --type d --hidden --exclude .git . {start} 2>/dev/null | fzf +m"
1076
+ else:
1077
+ command = f"find {start} -type d -not -path '*/.*' 2>/dev/null | fzf +m"
1078
+ fzf = self.fm.execute_command(command, stdout=subprocess.PIPE)
1079
+ stdout, stderr = fzf.communicate()
1080
+ if fzf.returncode == 0:
1081
+ fzf_file = os.path.abspath(stdout.decode('utf-8').strip())
1082
+ if os.path.isdir(fzf_file):
1083
+ self.fm.cd(fzf_file)
1084
+ '''
1085
+ with open(commands_file, 'w') as f:
1086
+ f.write(commands_content)
1087
+
1088
+ # Write rc.conf with our mappings
1089
+ # Source user's rc.conf first if it exists
1090
+ user_rc = os.path.expanduser("~/.config/ranger/rc.conf")
1091
+ rc_content = f'''
1092
+ # Source user config if exists
1093
+ {"source " + user_rc if os.path.isfile(user_rc) else "# no user rc.conf"}
1094
+
1095
+ # bit mappings
1096
+ map <C-f> fzf_select
1097
+ map <C-g> fzf_cd ~
1098
+ map g console cd%space
1099
+ '''
1100
+ with open(rc_file, 'w') as f:
1101
+ f.write(rc_content)
1102
+
1103
+ print()
1104
+ print(f"{Colors.bold('ranger directory browser')} (with fzf)")
1105
+ print(f" {Colors.bold('Ctrl+f')} : fzf search from current dir")
1106
+ print(f" {Colors.bold('Ctrl+g')} : fzf search from home")
1107
+ print(f" {Colors.bold('g')} : type path directly (tab completes)")
1108
+ print(f" :cd /path : jump to path")
1109
+ print(f" q : {Colors.cyan('SELECT current dir and quit')}")
1110
+ print()
1111
+ input("Press Enter to open ranger...")
1112
+
1113
+ env = os.environ.copy()
1114
+ env['RANGER_LOAD_DEFAULT_RC'] = 'FALSE'
1115
+
1116
+ result = subprocess.run(
1117
+ ["ranger",
1118
+ f"--choosedir={choosedir_file}",
1119
+ f"--cmd=source {rc_file}",
1120
+ f"--cmd=source {commands_file}",
1121
+ start_dir],
1122
+ env=env,
1123
+ )
1124
+
1125
+ if os.path.isfile(choosedir_file):
1126
+ with open(choosedir_file, "r") as f:
1127
+ selected = f.read().strip()
1128
+ if selected and os.path.isdir(selected):
1129
+ return selected
1130
+
1131
+ return None
1132
+ finally:
1133
+ # Cleanup temp files
1134
+ try:
1135
+ os.unlink(choosedir_file)
1136
+ except OSError:
1137
+ pass
1138
+ try:
1139
+ os.unlink(commands_file)
1140
+ except OSError:
1141
+ pass
1142
+ try:
1143
+ os.unlink(rc_file)
1144
+ except OSError:
1145
+ pass
1146
+ try:
1147
+ os.rmdir(temp_dir)
1148
+ except OSError:
1149
+ pass
1150
+
1151
+
1152
+ def get_nnn_colors() -> str:
1153
+ """Get configured nnn color scheme."""
1154
+ config_file = _get_projects_file()
1155
+ if os.path.isfile(config_file):
1156
+ try:
1157
+ with open(config_file, "r") as f:
1158
+ data = json.load(f)
1159
+ return data.get("__nnn_colors__", "6234")
1160
+ except (json.JSONDecodeError, OSError):
1161
+ pass
1162
+ # Default: cyan dirs (6), green sel (2), yellow ctx (3), blue files (4)
1163
+ return "6234"
1164
+
1165
+
1166
+ def set_nnn_colors(colors: str) -> None:
1167
+ """Set nnn color scheme."""
1168
+ config_file = _get_projects_file()
1169
+ data = {}
1170
+ if os.path.isfile(config_file):
1171
+ try:
1172
+ with open(config_file, "r") as f:
1173
+ data = json.load(f)
1174
+ except (json.JSONDecodeError, OSError):
1175
+ pass
1176
+ data["__nnn_colors__"] = colors
1177
+ try:
1178
+ with open(config_file, "w") as f:
1179
+ json.dump(data, f, indent=2)
1180
+ except OSError:
1181
+ pass
1182
+
1183
+
1184
+ def _browse_with_nnn() -> Optional[str]:
1185
+ """Browse for directory using nnn file manager."""
1186
+ import tempfile
1187
+
1188
+ start_dir = os.path.expanduser("~")
1189
+
1190
+ with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.nnn') as f:
1191
+ picker_file = f.name
1192
+
1193
+ try:
1194
+ env = os.environ.copy()
1195
+ # Set colors - default avoids hard-to-read blue for directories
1196
+ # Format: 4 chars for contexts (cursor line, selection, dir, file)
1197
+ # 1=red, 2=green, 3=yellow, 4=blue, 5=magenta, 6=cyan, 7=white
1198
+ env["NNN_COLORS"] = get_nnn_colors()
1199
+
1200
+ print()
1201
+ print(f"{Colors.bold('nnn directory browser')} (type-to-nav enabled)")
1202
+ print(f" hjkl/arrows : navigate {Colors.bold('/')} : filter current dir")
1203
+ print(f" Enter/l : enter dir {Colors.bold('~')} : jump to home")
1204
+ print(f" Backspace/h : go up Just type: jump to path (e.g. /opt)")
1205
+ print(f" {Colors.bold('p')} : {Colors.cyan('SELECT and quit')}")
1206
+ print(f" q : cancel {Colors.bold('?')} : full help")
1207
+ print()
1208
+ input("Press Enter to open nnn...")
1209
+
1210
+ result = subprocess.run(
1211
+ ["nnn", "-n", "-p", picker_file, start_dir],
1212
+ env=env,
1213
+ )
1214
+
1215
+ # Read selected path (written when user presses 'p')
1216
+ if os.path.isfile(picker_file):
1217
+ with open(picker_file, "r") as f:
1218
+ selected = f.read().strip()
1219
+ if selected and os.path.isdir(selected):
1220
+ return selected
1221
+ elif selected and os.path.isfile(selected):
1222
+ # If a file was selected, use its directory
1223
+ return os.path.dirname(selected)
1224
+
1225
+ return None
1226
+ finally:
1227
+ try:
1228
+ os.unlink(picker_file)
1229
+ except OSError:
1230
+ pass
1231
+
1232
+
1233
+ def _browse_with_fzf_walker() -> Optional[str]:
1234
+ """Browse for directory using fzf with manual directory navigation."""
1235
+ start_dir = os.path.expanduser("~")
1236
+ current_dir = start_dir
1237
+
1238
+ while True:
1239
+ # List directories in current location
1240
+ try:
1241
+ entries = sorted(os.listdir(current_dir))
1242
+ except OSError:
1243
+ entries = []
1244
+
1245
+ dirs = [d for d in entries if os.path.isdir(os.path.join(current_dir, d)) and not d.startswith(".")]
1246
+
1247
+ menu_lines = []
1248
+ menu_lines.append(f".\t{Colors.green('● Select this directory')}: {current_dir}")
1249
+ if current_dir != "/":
1250
+ menu_lines.append(f"..\t{Colors.cyan('↑ Go up to')}: {os.path.dirname(current_dir)}")
1251
+
1252
+ for d in dirs:
1253
+ full_path = os.path.join(current_dir, d)
1254
+ # Check for indicators this might be a Yocto project
1255
+ has_layers = os.path.isdir(os.path.join(full_path, "layers"))
1256
+ has_build = os.path.isdir(os.path.join(full_path, "build"))
1257
+ has_poky = os.path.isdir(os.path.join(full_path, "poky"))
1258
+ has_bblayers = any(
1259
+ os.path.isfile(os.path.join(full_path, sub, "conf", "bblayers.conf"))
1260
+ for sub in ["", "build"]
1261
+ )
1262
+
1263
+ indicator = ""
1264
+ if has_layers or has_build or has_poky or has_bblayers:
1265
+ indicator = Colors.green(" [yocto?]")
1266
+
1267
+ menu_lines.append(f"{d}\t {d}/{indicator}")
1268
+
1269
+ menu_input = "\n".join(menu_lines)
1270
+
1271
+ fzf_args = [
1272
+ "fzf",
1273
+ "--no-multi",
1274
+ "--no-sort",
1275
+ "--ansi",
1276
+ "--height", "~60%",
1277
+ "--layout", "reverse",
1278
+ "--header", f"Browse: {current_dir}\nEnter=enter dir, Tab=select, ←/q=cancel",
1279
+ "--prompt", "Dir: ",
1280
+ "--with-nth", "2..",
1281
+ "--delimiter", "\t",
1282
+ "--bind", "q:abort",
1283
+ "--bind", "left:abort",
1284
+ "--expect", "tab",
1285
+ ]
1286
+
1287
+ fzf_args.extend(get_fzf_color_args())
1288
+
1289
+ try:
1290
+ result = subprocess.run(
1291
+ fzf_args,
1292
+ input=menu_input,
1293
+ stdout=subprocess.PIPE,
1294
+ text=True,
1295
+ )
1296
+ except FileNotFoundError:
1297
+ return None
1298
+
1299
+ if result.returncode != 0 or not result.stdout.strip():
1300
+ return None
1301
+
1302
+ lines = result.stdout.strip().split("\n")
1303
+ key = lines[0] if lines else ""
1304
+ selected = lines[1].split("\t")[0] if len(lines) > 1 else ""
1305
+
1306
+ # Tab means select current highlighted item as the project
1307
+ if key == "tab" and selected and selected not in (".", ".."):
1308
+ return os.path.join(current_dir, selected)
1309
+
1310
+ if selected == ".":
1311
+ return current_dir
1312
+ elif selected == "..":
1313
+ current_dir = os.path.dirname(current_dir)
1314
+ elif selected:
1315
+ new_dir = os.path.join(current_dir, selected)
1316
+ if os.path.isdir(new_dir):
1317
+ current_dir = new_dir
1318
+
1319
+
1320
+ def run_projects(args, from_auto_prompt: bool = False) -> int:
1321
+ """
1322
+ Main entry point for projects command.
1323
+
1324
+ Args:
1325
+ args: Parsed command line arguments
1326
+ from_auto_prompt: True if invoked automatically because no project context was found.
1327
+ In this case, after selecting a project with Enter, show command menu.
1328
+
1329
+ Returns:
1330
+ 0 for success, 1 for error, 2 to signal "chain to command menu"
1331
+ """
1332
+ subcommand = getattr(args, "projects_command", None)
1333
+
1334
+ if subcommand == "add":
1335
+ path = getattr(args, "path", None) or os.getcwd()
1336
+ name = getattr(args, "name", None)
1337
+ desc = getattr(args, "description", "") or ""
1338
+ if add_project(path, name, desc):
1339
+ return 0
1340
+ return 1
1341
+
1342
+ elif subcommand == "remove":
1343
+ path = getattr(args, "path", None)
1344
+ if not path:
1345
+ # Interactive selection
1346
+ selected = fzf_project_picker(include_browse=False)
1347
+ if not selected:
1348
+ return 1
1349
+ path = selected
1350
+ if remove_project(path):
1351
+ return 0
1352
+ return 1
1353
+
1354
+ elif subcommand == "shell":
1355
+ # Alias for 'init shell' - change to current project first
1356
+ current_project = get_current_project()
1357
+ if current_project and os.path.isdir(current_project):
1358
+ os.chdir(current_project)
1359
+ from .init import run_init_shell
1360
+ return run_init_shell(args)
1361
+
1362
+ elif subcommand == "list":
1363
+ projects = load_projects()
1364
+ current = get_current_project()
1365
+
1366
+ if not projects:
1367
+ print("No projects registered.")
1368
+ print("Use 'bit projects add' to add one.")
1369
+ return 0
1370
+
1371
+ print(f"\n{Colors.bold('Registered projects:')}\n")
1372
+ for path, info in sorted(projects.items(), key=lambda x: x[1].get("name", x[0]).lower()):
1373
+ name = info.get("name", os.path.basename(path))
1374
+ desc = info.get("description", "")
1375
+ exists = os.path.isdir(path)
1376
+ is_current = (path == current)
1377
+
1378
+ if is_current:
1379
+ status = Colors.cyan("●")
1380
+ label = f" {Colors.cyan('(ACTIVE)')}"
1381
+ elif exists:
1382
+ status = Colors.green("○")
1383
+ label = ""
1384
+ else:
1385
+ status = Colors.red("!")
1386
+ label = ""
1387
+
1388
+ print(f" {status} {Colors.cyan(name)}{label}")
1389
+ print(f" {path}")
1390
+ if desc:
1391
+ print(f" {Colors.dim(desc)}")
1392
+ if not exists:
1393
+ print(f" {Colors.red('(directory not found)')}")
1394
+ print()
1395
+ return 0
1396
+
1397
+ else:
1398
+ # Default: interactive picker
1399
+ selected = fzf_project_picker(include_browse=True, show_exit_hint=from_auto_prompt)
1400
+
1401
+ if not selected:
1402
+ return 0
1403
+
1404
+ if selected == "ADD_CWD":
1405
+ cwd = os.getcwd()
1406
+ name = input(f"Name for {cwd} [{os.path.basename(cwd)}]: ").strip()
1407
+ if not name:
1408
+ name = os.path.basename(cwd)
1409
+ add_project(cwd, name)
1410
+ return run_projects(args, from_auto_prompt) # Show picker again
1411
+
1412
+ elif selected == "ADD_PATH":
1413
+ try:
1414
+ path = input("Enter project path: ").strip()
1415
+ if path:
1416
+ path = os.path.expanduser(path)
1417
+ path = os.path.abspath(path)
1418
+ if os.path.isdir(path):
1419
+ name = input(f"Name for {path} [{os.path.basename(path)}]: ").strip()
1420
+ if not name:
1421
+ name = os.path.basename(path)
1422
+ add_project(path, name)
1423
+ else:
1424
+ print(f"Not a directory: {path}")
1425
+ except (EOFError, KeyboardInterrupt):
1426
+ print()
1427
+ return run_projects(args, from_auto_prompt) # Show picker again
1428
+
1429
+ elif selected == "BROWSE":
1430
+ path = browse_for_directory()
1431
+ if path:
1432
+ name = input(f"Name for {path} [{os.path.basename(path)}]: ").strip()
1433
+ if not name:
1434
+ name = os.path.basename(path)
1435
+ add_project(path, name)
1436
+ return run_projects(args, from_auto_prompt) # Show picker again
1437
+
1438
+ elif selected == "SETTINGS":
1439
+ _pick_directory_browser()
1440
+ return run_projects(args, from_auto_prompt) # Show picker again
1441
+
1442
+ elif selected == "REMOVE":
1443
+ _remove_project_interactive()
1444
+ return run_projects(args, from_auto_prompt) # Show picker again
1445
+
1446
+ elif selected.startswith("REMOVE:"):
1447
+ # Direct removal of highlighted project
1448
+ path = selected[7:] # Strip "REMOVE:" prefix
1449
+ remove_project(path)
1450
+ return run_projects(args, from_auto_prompt) # Show picker again
1451
+
1452
+ elif selected.startswith("SELECT:"):
1453
+ # Space pressed - set as active but stay in picker
1454
+ path = selected[7:] # Strip "SELECT:" prefix
1455
+ if os.path.isdir(path):
1456
+ set_current_project(path)
1457
+ projects = load_projects()
1458
+ name = projects.get(path, {}).get("name", os.path.basename(path))
1459
+ print(f"{Colors.green('✓')} Activated: {Colors.bold(name)}")
1460
+ return run_projects(args, from_auto_prompt) # Show picker again
1461
+
1462
+ elif selected == "CLEAR":
1463
+ set_current_project(None)
1464
+ print(f"{Colors.yellow('Cleared')} active project. Commands will use current directory.")
1465
+ return run_projects(args, from_auto_prompt) # Show picker again
1466
+
1467
+ elif selected.startswith("EXIT:"):
1468
+ # Enter pressed on a project - exit and optionally chain to command menu
1469
+ path = selected[5:] # Strip "EXIT:" prefix
1470
+ if not os.path.isdir(path):
1471
+ print(f"Error: Project directory not found: {path}", file=sys.stderr)
1472
+ return 1
1473
+
1474
+ # Set as active if not already
1475
+ current = get_current_project()
1476
+ if path != current:
1477
+ set_current_project(path)
1478
+ projects = load_projects()
1479
+ name = projects.get(path, {}).get("name", os.path.basename(path))
1480
+ print(f"{Colors.green('✓')} Active project: {Colors.bold(name)}")
1481
+ print(f" {Colors.dim(path)}")
1482
+
1483
+ if from_auto_prompt:
1484
+ # Signal to cli.py to show command menu
1485
+ return 2
1486
+ else:
1487
+ print()
1488
+ print(f"All bit commands will now operate on this project.")
1489
+ # Copy to clipboard if possible
1490
+ try:
1491
+ if shutil.which("xclip"):
1492
+ subprocess.run(["xclip", "-selection", "clipboard"],
1493
+ input=f"cd {path}".encode(), check=True)
1494
+ print(Colors.dim(f"(cd command copied to clipboard)"))
1495
+ elif shutil.which("xsel"):
1496
+ subprocess.run(["xsel", "--clipboard", "--input"],
1497
+ input=f"cd {path}".encode(), check=True)
1498
+ print(Colors.dim(f"(cd command copied to clipboard)"))
1499
+ except Exception:
1500
+ pass
1501
+ return 0
1502
+
1503
+ else:
1504
+ # Fallback for any unhandled menu items (shouldn't happen)
1505
+ return 0