dev-bubble 0.6.2__tar.gz → 0.6.3__tar.gz

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.
Files changed (113) hide show
  1. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/CHANGELOG.md +6 -0
  2. {dev_bubble-0.6.2/dev_bubble.egg-info → dev_bubble-0.6.3}/PKG-INFO +1 -1
  3. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/__init__.py +1 -1
  4. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/cli.py +2 -2
  5. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/commands/settings.py +16 -0
  6. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/config.py +60 -19
  7. {dev_bubble-0.6.2 → dev_bubble-0.6.3/dev_bubble.egg-info}/PKG-INFO +1 -1
  8. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/tests/test_claude_projects_symlink.py +137 -46
  9. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/.claude/CLAUDE.md +0 -0
  10. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/.github/workflows/ci.yml +0 -0
  11. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/.github/workflows/publish.yml +0 -0
  12. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/.gitignore +0 -0
  13. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/.vscode/claude-prompt.txt +0 -0
  14. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/.vscode/settings.json +0 -0
  15. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/.vscode/tasks.json +0 -0
  16. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/LICENSE +0 -0
  17. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/README.md +0 -0
  18. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/__main__.py +0 -0
  19. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/auth_proxy.py +0 -0
  20. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/automation.py +0 -0
  21. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/claude.py +0 -0
  22. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/clean.py +0 -0
  23. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/clone.py +0 -0
  24. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/cloud.py +0 -0
  25. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/cloud_types.py +0 -0
  26. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/commands/__init__.py +0 -0
  27. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/commands/cloud_cmd.py +0 -0
  28. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/commands/doctor.py +0 -0
  29. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/commands/images.py +0 -0
  30. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/commands/infrastructure.py +0 -0
  31. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/commands/lifecycle.py +0 -0
  32. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/commands/list_cmd.py +0 -0
  33. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/commands/relay_cmd.py +0 -0
  34. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/commands/remote_cmd.py +0 -0
  35. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/container_helpers.py +0 -0
  36. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/data/skill.md +0 -0
  37. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/default_repos.json +0 -0
  38. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/finalization.py +0 -0
  39. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/git_store.py +0 -0
  40. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/github_token.py +0 -0
  41. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/hooks/__init__.py +0 -0
  42. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/hooks/lean.py +0 -0
  43. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/image_management.py +0 -0
  44. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/images/__init__.py +0 -0
  45. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/images/builder.py +0 -0
  46. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/images/scripts/base.sh +0 -0
  47. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/images/scripts/cloud-init.sh +0 -0
  48. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/images/scripts/lean-toolchain.sh +0 -0
  49. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/images/scripts/lean.sh +0 -0
  50. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/images/scripts/tools/claude.sh +0 -0
  51. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/images/scripts/tools/codex.sh +0 -0
  52. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/images/scripts/tools/elan.sh +0 -0
  53. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/images/scripts/tools/emacs.sh +0 -0
  54. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/images/scripts/tools/gh.sh +0 -0
  55. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/images/scripts/tools/neovim.sh +0 -0
  56. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/images/scripts/tools/pins.json +0 -0
  57. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/images/scripts/tools/vscode.sh +0 -0
  58. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/lifecycle.py +0 -0
  59. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/naming.py +0 -0
  60. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/native.py +0 -0
  61. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/network.py +0 -0
  62. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/provisioning.py +0 -0
  63. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/relay.py +0 -0
  64. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/remote.py +0 -0
  65. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/repo_registry.py +0 -0
  66. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/runtime/__init__.py +0 -0
  67. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/runtime/base.py +0 -0
  68. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/runtime/colima.py +0 -0
  69. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/runtime/incus.py +0 -0
  70. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/security.py +0 -0
  71. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/setup.py +0 -0
  72. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/skill.py +0 -0
  73. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/target.py +0 -0
  74. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/tools.py +0 -0
  75. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/tunnel.py +0 -0
  76. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/bubble/vscode.py +0 -0
  77. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/config/com.bubble.git-update.plist +0 -0
  78. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/config/com.bubble.image-refresh.plist +0 -0
  79. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/config/com.bubble.relay-daemon.plist +0 -0
  80. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/dev_bubble.egg-info/SOURCES.txt +0 -0
  81. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/dev_bubble.egg-info/dependency_links.txt +0 -0
  82. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/dev_bubble.egg-info/entry_points.txt +0 -0
  83. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/dev_bubble.egg-info/requires.txt +0 -0
  84. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/dev_bubble.egg-info/top_level.txt +0 -0
  85. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/pyproject.toml +0 -0
  86. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/setup.cfg +0 -0
  87. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/tests/conftest.py +0 -0
  88. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/tests/test_auth_proxy.py +0 -0
  89. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/tests/test_build_lock.py +0 -0
  90. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/tests/test_claude.py +0 -0
  91. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/tests/test_cloud.py +0 -0
  92. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/tests/test_config.py +0 -0
  93. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/tests/test_customize.py +0 -0
  94. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/tests/test_editor.py +0 -0
  95. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/tests/test_git_store.py +0 -0
  96. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/tests/test_github_token.py +0 -0
  97. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/tests/test_hooks.py +0 -0
  98. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/tests/test_integration.py +0 -0
  99. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/tests/test_lifecycle.py +0 -0
  100. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/tests/test_list_remote.py +0 -0
  101. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/tests/test_mounts.py +0 -0
  102. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/tests/test_naming.py +0 -0
  103. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/tests/test_native.py +0 -0
  104. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/tests/test_network.py +0 -0
  105. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/tests/test_relay.py +0 -0
  106. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/tests/test_remote.py +0 -0
  107. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/tests/test_repo_registry.py +0 -0
  108. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/tests/test_security.py +0 -0
  109. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/tests/test_skill.py +0 -0
  110. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/tests/test_target.py +0 -0
  111. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/tests/test_tools.py +0 -0
  112. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/tests/test_tunnel.py +0 -0
  113. {dev_bubble-0.6.2 → dev_bubble-0.6.3}/tests/test_vscode.py +0 -0
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.3 — 2026-03-12
4
+ - Don't prompt interactively during `bubble open` (#88)
5
+ - `maybe_symlink_claude_projects()` now prints an informational message instead of blocking on a `[y/N]` prompt
6
+ - New `bubble config symlink-claude-projects` command to perform the symlink manually
7
+ - Suppress the hint with `claude_projects_symlink = "no"` in `~/.bubble/config.toml`
8
+
3
9
  ## 0.6.2 — 2026-03-12
4
10
  - SSH tunnel auth proxy for remote/cloud bubbles (#81)
5
11
  - Remote and cloud bubbles now use the same repo-scoped auth proxy as local bubbles
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dev-bubble
3
- Version: 0.6.2
3
+ Version: 0.6.3
4
4
  Summary: Containerized development environments powered by Incus
5
5
  Author-email: Kim Morrison <kim@tqft.net>
6
6
  License-Expression: Apache-2.0
@@ -1,3 +1,3 @@
1
1
  """bubble: Containerized development environments."""
2
2
 
3
- __version__ = "0.6.2"
3
+ __version__ = "0.6.3"
@@ -659,9 +659,9 @@ def open_cmd(
659
659
  # Suppress auto mounts that overlap with user mounts (exact or ancestry)
660
660
  user_targets = {Path(m.target) for m in mount_specs}
661
661
  cc_mounts = [m for m in cc_mounts if not mount_overlaps(Path(m.target), user_targets)]
662
- # Offer to symlink ~/.bubble/claude-projects/ to ~/.claude/projects/
662
+ # Hint about symlinking ~/.bubble/claude-projects/ to ~/.claude/projects/
663
663
  if not machine_readable:
664
- maybe_symlink_claude_projects()
664
+ maybe_symlink_claude_projects(config)
665
665
 
666
666
  # Editor config mounts (emacs/neovim only — suppress if user mounts overlap)
667
667
  ec_mounts = editor_config_mounts(editor)
@@ -396,3 +396,19 @@ def register_settings_commands(main):
396
396
  click.echo(f"Accepted {len(changed)} risk(s). On-by-default warnings silenced.")
397
397
  else:
398
398
  click.echo("No auto-defaulting-to-on settings to accept.")
399
+
400
+ @config_group.command("symlink-claude-projects")
401
+ def config_symlink_claude_projects():
402
+ """Replace ~/.bubble/claude-projects/ with a symlink to ~/.claude/projects/.
403
+
404
+ If ~/.claude/projects/ is inside a git repo, this command merges any
405
+ existing session data from ~/.bubble/claude-projects/ into
406
+ ~/.claude/projects/ and replaces the directory with a symlink.
407
+
408
+ This lets bubble session state live inside the git-tracked directory
409
+ and get synced across machines automatically.
410
+ """
411
+ from ..config import do_symlink_claude_projects
412
+
413
+ if not do_symlink_claude_projects():
414
+ raise SystemExit(1)
@@ -420,15 +420,18 @@ def _is_inside_git_repo(path: Path) -> bool:
420
420
  return False
421
421
 
422
422
 
423
- def maybe_symlink_claude_projects() -> None:
424
- """Offer to replace ~/.bubble/claude-projects/ with a symlink to ~/.claude/projects/.
423
+ def maybe_symlink_claude_projects(config: dict | None = None) -> None:
424
+ """Print an informational message if claude-projects could be symlinked.
425
425
 
426
426
  If ~/.claude/projects/ is inside a git repo and ~/.bubble/claude-projects/ is a real
427
- directory (not already a symlink), prompt the user to replace it with a symlink.
428
- This lets bubble session state live inside the git-tracked directory and get
429
- synced across machines automatically.
427
+ directory (not already a symlink), print a one-time hint about the
428
+ ``bubble config symlink-claude-projects`` command. Never prompts interactively.
429
+
430
+ Suppressed when ``claude_projects_symlink = "no"`` is set in config.
430
431
  """
431
- import sys
432
+ # Respect opt-out config
433
+ if config and config.get("claude_projects_symlink") == "no":
434
+ return
432
435
 
433
436
  claude_projects = CLAUDE_CONFIG_DIR / "projects"
434
437
  bubble_projects = CLAUDE_PROJECTS_DIR
@@ -447,29 +450,65 @@ def maybe_symlink_claude_projects() -> None:
447
450
  bubble_projects.symlink_to(claude_projects)
448
451
  return
449
452
 
450
- # Don't prompt if stdin is not a TTY (scripted/CI usage)
451
- if not sys.stdin.isatty():
452
- return
453
+ # It's a real directory print informational message (no prompt)
454
+ import click
455
+
456
+ click.echo(
457
+ "~/.claude/projects is git-tracked. Claude sessions within bubbles are stored\n"
458
+ "in ~/.bubble/claude-projects. To replace that directory with a symlink (so\n"
459
+ "session state is tracked in git), run:\n"
460
+ "\n"
461
+ " bubble config symlink-claude-projects\n"
462
+ "\n"
463
+ 'To suppress this message, set claude_projects_symlink = "no" in '
464
+ "~/.bubble/config.toml.",
465
+ err=True,
466
+ )
467
+
453
468
 
454
- # It's a real directory — prompt the user
469
+ def do_symlink_claude_projects() -> bool:
470
+ """Replace ~/.bubble/claude-projects/ with a symlink to ~/.claude/projects/.
471
+
472
+ Merges existing contents from bubble-projects into claude-projects before
473
+ replacing. Returns True if the symlink was created, False otherwise.
474
+ """
455
475
  import click
456
476
 
457
- if not click.confirm(
458
- f"{claude_projects} is git-tracked. Replace {bubble_projects}\n"
459
- f"with a symlink to {claude_projects} so bubble session state is tracked too?",
460
- default=False,
461
- ):
462
- return
477
+ claude_projects = CLAUDE_CONFIG_DIR / "projects"
478
+ bubble_projects = CLAUDE_PROJECTS_DIR
479
+
480
+ if not claude_projects.is_dir():
481
+ click.echo(f"{claude_projects} does not exist.", err=True)
482
+ return False
483
+
484
+ if not _is_inside_git_repo(claude_projects):
485
+ click.echo(f"{claude_projects} is not inside a git repository.", err=True)
486
+ return False
487
+
488
+ if bubble_projects.is_symlink():
489
+ target = bubble_projects.resolve()
490
+ if target == claude_projects.resolve():
491
+ click.echo(f"{bubble_projects} is already a symlink to {claude_projects}.")
492
+ return True
493
+ click.echo(f"{bubble_projects} is a symlink to {target}, not {claude_projects}.", err=True)
494
+ return False
495
+
496
+ if not bubble_projects.exists():
497
+ bubble_projects.parent.mkdir(parents=True, exist_ok=True)
498
+ bubble_projects.symlink_to(claude_projects)
499
+ click.echo(f"Created symlink: {bubble_projects} -> {claude_projects}")
500
+ return True
501
+
502
+ if not bubble_projects.is_dir():
503
+ click.echo(f"{bubble_projects} exists but is not a directory.", err=True)
504
+ return False
463
505
 
464
506
  # Merge existing contents into ~/.claude/projects/
465
- # Move unique items; for conflicts, copy bubble-only files into the
466
- # destination so nothing is silently lost.
467
507
  for child in bubble_projects.iterdir():
468
508
  dest = claude_projects / child.name
469
509
  if not dest.exists():
470
510
  shutil.move(str(child), str(dest))
471
511
  elif child.is_dir() and dest.is_dir():
472
- # Recursively merge directory contents that only exist in bubble
473
512
  _merge_dir(child, dest)
474
513
  else:
475
514
  click.echo(f" Skipping {child.name} (already exists in {claude_projects})")
@@ -477,6 +516,8 @@ def maybe_symlink_claude_projects() -> None:
477
516
  # Replace with symlink — use rmtree since conflicts may leave remnants
478
517
  shutil.rmtree(str(bubble_projects))
479
518
  bubble_projects.symlink_to(claude_projects)
519
+ click.echo(f"Created symlink: {bubble_projects} -> {claude_projects}")
520
+ return True
480
521
 
481
522
 
482
523
  def _merge_dir(src: Path, dest: Path) -> None:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dev-bubble
3
- Version: 0.6.2
3
+ Version: 0.6.3
4
4
  Summary: Containerized development environments powered by Incus
5
5
  Author-email: Kim Morrison <kim@tqft.net>
6
6
  License-Expression: Apache-2.0
@@ -4,7 +4,7 @@ from unittest.mock import patch
4
4
 
5
5
  import pytest
6
6
 
7
- from bubble.config import maybe_symlink_claude_projects
7
+ from bubble.config import do_symlink_claude_projects, maybe_symlink_claude_projects
8
8
 
9
9
 
10
10
  @pytest.fixture
@@ -82,8 +82,54 @@ class TestMaybeSymlinkClaudeProjects:
82
82
  assert bubble_projects.is_symlink()
83
83
  assert bubble_projects.resolve() == claude_projects.resolve()
84
84
 
85
- def test_prompts_and_replaces_on_accept(self, setup_dirs):
86
- """Prompts user and creates symlink when accepted."""
85
+ def test_prints_hint_when_real_dir_exists(self, setup_dirs, capsys):
86
+ """Prints informational message when ~/.bubble/claude-projects/ is a real dir."""
87
+ claude_projects, bubble_projects = setup_dirs
88
+ bubble_projects.mkdir()
89
+
90
+ with patch("bubble.config._is_inside_git_repo", return_value=True):
91
+ maybe_symlink_claude_projects()
92
+
93
+ # Directory is NOT replaced (no prompt, no auto-replace)
94
+ assert bubble_projects.is_dir()
95
+ assert not bubble_projects.is_symlink()
96
+
97
+ # Message was printed to stderr
98
+ captured = capsys.readouterr()
99
+ assert "symlink-claude-projects" in captured.err
100
+ assert "claude_projects_symlink" in captured.err
101
+
102
+ def test_suppressed_by_config(self, setup_dirs, capsys):
103
+ """No message when claude_projects_symlink = 'no' in config."""
104
+ claude_projects, bubble_projects = setup_dirs
105
+ bubble_projects.mkdir()
106
+
107
+ with patch("bubble.config._is_inside_git_repo", return_value=True):
108
+ maybe_symlink_claude_projects(config={"claude_projects_symlink": "no"})
109
+
110
+ assert bubble_projects.is_dir()
111
+ assert not bubble_projects.is_symlink()
112
+
113
+ captured = capsys.readouterr()
114
+ assert captured.err == ""
115
+
116
+ def test_handles_empty_bubble_projects_dir(self, setup_dirs, capsys):
117
+ """Prints hint when ~/.bubble/claude-projects/ exists but is empty."""
118
+ claude_projects, bubble_projects = setup_dirs
119
+ bubble_projects.mkdir()
120
+
121
+ with patch("bubble.config._is_inside_git_repo", return_value=True):
122
+ maybe_symlink_claude_projects()
123
+
124
+ # Not replaced — just a hint
125
+ assert bubble_projects.is_dir()
126
+ assert not bubble_projects.is_symlink()
127
+ assert "symlink-claude-projects" in capsys.readouterr().err
128
+
129
+
130
+ class TestDoSymlinkClaudeProjects:
131
+ def test_replaces_dir_with_symlink(self, setup_dirs):
132
+ """Replaces ~/.bubble/claude-projects/ with a symlink and merges contents."""
87
133
  claude_projects, bubble_projects = setup_dirs
88
134
  bubble_projects.mkdir()
89
135
 
@@ -91,48 +137,46 @@ class TestMaybeSymlinkClaudeProjects:
91
137
  (bubble_projects / "session-a").mkdir()
92
138
  (bubble_projects / "session-a" / "data.jsonl").write_text("data")
93
139
 
94
- with (
95
- patch("bubble.config._is_inside_git_repo", return_value=True),
96
- patch("sys.stdin", **{"isatty.return_value": True}),
97
- patch("click.confirm", return_value=True),
98
- ):
99
- maybe_symlink_claude_projects()
140
+ with patch("bubble.config._is_inside_git_repo", return_value=True):
141
+ result = do_symlink_claude_projects()
100
142
 
143
+ assert result is True
101
144
  assert bubble_projects.is_symlink()
102
145
  assert bubble_projects.resolve() == claude_projects.resolve()
103
146
  # Content was moved
104
147
  assert (claude_projects / "session-a" / "data.jsonl").read_text() == "data"
105
148
 
106
- def test_prompts_and_skips_on_deny(self, setup_dirs):
107
- """No changes when user denies the prompt."""
149
+ def test_creates_symlink_when_bubble_dir_missing(self, setup_dirs):
150
+ """Creates symlink directly when ~/.bubble/claude-projects/ doesn't exist."""
108
151
  claude_projects, bubble_projects = setup_dirs
109
- bubble_projects.mkdir()
110
- (bubble_projects / "keep-me").write_text("data")
111
152
 
112
- with (
113
- patch("bubble.config._is_inside_git_repo", return_value=True),
114
- patch("sys.stdin", **{"isatty.return_value": True}),
115
- patch("click.confirm", return_value=False),
116
- ):
117
- maybe_symlink_claude_projects()
153
+ with patch("bubble.config._is_inside_git_repo", return_value=True):
154
+ result = do_symlink_claude_projects()
118
155
 
119
- assert bubble_projects.is_dir()
120
- assert not bubble_projects.is_symlink()
121
- assert (bubble_projects / "keep-me").read_text() == "data"
156
+ assert result is True
157
+ assert bubble_projects.is_symlink()
158
+ assert bubble_projects.resolve() == claude_projects.resolve()
122
159
 
123
- def test_noop_when_not_tty(self, setup_dirs):
124
- """No prompt when stdin is not a TTY."""
160
+ def test_noop_when_already_symlink(self, setup_dirs):
161
+ """Returns True when already a symlink."""
162
+ claude_projects, bubble_projects = setup_dirs
163
+ bubble_projects.symlink_to(claude_projects)
164
+
165
+ with patch("bubble.config._is_inside_git_repo", return_value=True):
166
+ result = do_symlink_claude_projects()
167
+
168
+ assert result is True
169
+ assert bubble_projects.is_symlink()
170
+
171
+ def test_fails_when_not_git_tracked(self, setup_dirs):
172
+ """Returns False when ~/.claude/projects/ is not in a git repo."""
125
173
  claude_projects, bubble_projects = setup_dirs
126
174
  bubble_projects.mkdir()
127
- (bubble_projects / "keep-me").write_text("data")
128
175
 
129
- with (
130
- patch("bubble.config._is_inside_git_repo", return_value=True),
131
- patch("sys.stdin", **{"isatty.return_value": False}),
132
- ):
133
- maybe_symlink_claude_projects()
176
+ with patch("bubble.config._is_inside_git_repo", return_value=False):
177
+ result = do_symlink_claude_projects()
134
178
 
135
- assert bubble_projects.is_dir()
179
+ assert result is False
136
180
  assert not bubble_projects.is_symlink()
137
181
 
138
182
  def test_merges_without_overwriting_conflicts(self, setup_dirs):
@@ -150,12 +194,8 @@ class TestMaybeSymlinkClaudeProjects:
150
194
  (bubble_projects / "unique").mkdir()
151
195
  (bubble_projects / "unique" / "data.txt").write_text("unique-data")
152
196
 
153
- with (
154
- patch("bubble.config._is_inside_git_repo", return_value=True),
155
- patch("sys.stdin", **{"isatty.return_value": True}),
156
- patch("click.confirm", return_value=True),
157
- ):
158
- maybe_symlink_claude_projects()
197
+ with patch("bubble.config._is_inside_git_repo", return_value=True):
198
+ do_symlink_claude_projects()
159
199
 
160
200
  assert bubble_projects.is_symlink()
161
201
  # Original was preserved (not overwritten)
@@ -165,20 +205,71 @@ class TestMaybeSymlinkClaudeProjects:
165
205
  # Unique content was moved
166
206
  assert (claude_projects / "unique" / "data.txt").read_text() == "unique-data"
167
207
 
168
- def test_handles_empty_bubble_projects_dir(self, setup_dirs):
169
- """Works when ~/.bubble/claude-projects/ exists but is empty."""
208
+ def test_fails_when_bubble_projects_is_file(self, setup_dirs):
209
+ """Returns False when ~/.bubble/claude-projects is a file, not a dir."""
210
+ claude_projects, bubble_projects = setup_dirs
211
+ bubble_projects.write_text("not a directory")
212
+
213
+ with patch("bubble.config._is_inside_git_repo", return_value=True):
214
+ result = do_symlink_claude_projects()
215
+
216
+ assert result is False
217
+ assert not bubble_projects.is_symlink()
218
+
219
+ def test_fails_when_symlink_points_elsewhere(self, setup_dirs):
220
+ """Returns False when existing symlink points to wrong target."""
221
+ claude_projects, bubble_projects = setup_dirs
222
+ other_dir = bubble_projects.parent / "other"
223
+ other_dir.mkdir()
224
+ bubble_projects.symlink_to(other_dir)
225
+
226
+ with patch("bubble.config._is_inside_git_repo", return_value=True):
227
+ result = do_symlink_claude_projects()
228
+
229
+ assert result is False
230
+
231
+ def test_succeeds_when_symlink_points_to_claude_projects(self, setup_dirs):
232
+ """Returns True when existing symlink already points to correct target."""
233
+ claude_projects, bubble_projects = setup_dirs
234
+ bubble_projects.symlink_to(claude_projects)
235
+
236
+ with patch("bubble.config._is_inside_git_repo", return_value=True):
237
+ result = do_symlink_claude_projects()
238
+
239
+ assert result is True
240
+
241
+
242
+ class TestSymlinkClaudeProjectsCLI:
243
+ def test_exit_code_on_failure(self, setup_dirs):
244
+ """CLI command exits with code 1 on failure."""
245
+ from click.testing import CliRunner
246
+
247
+ from bubble.cli import main
248
+
170
249
  claude_projects, bubble_projects = setup_dirs
171
250
  bubble_projects.mkdir()
172
251
 
173
- with (
174
- patch("bubble.config._is_inside_git_repo", return_value=True),
175
- patch("sys.stdin", **{"isatty.return_value": True}),
176
- patch("click.confirm", return_value=True),
177
- ):
178
- maybe_symlink_claude_projects()
252
+ runner = CliRunner()
253
+ with patch("bubble.config._is_inside_git_repo", return_value=False):
254
+ result = runner.invoke(main, ["config", "symlink-claude-projects"])
255
+
256
+ assert result.exit_code != 0
257
+
258
+ def test_exit_code_on_success(self, setup_dirs):
259
+ """CLI command exits with code 0 on success."""
260
+ from click.testing import CliRunner
261
+
262
+ from bubble.cli import main
179
263
 
264
+ claude_projects, bubble_projects = setup_dirs
265
+ # Don't create bubble_projects — simplest success path
266
+
267
+ runner = CliRunner()
268
+ with patch("bubble.config._is_inside_git_repo", return_value=True):
269
+ result = runner.invoke(main, ["config", "symlink-claude-projects"])
270
+
271
+ assert result.exit_code == 0
180
272
  assert bubble_projects.is_symlink()
181
- assert bubble_projects.resolve() == claude_projects.resolve()
182
273
 
183
274
 
184
275
  class TestIsInsideGitRepo:
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes