dev-bubble 0.7.8__tar.gz → 0.7.9__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 (131) hide show
  1. {dev_bubble-0.7.8/dev_bubble.egg-info → dev_bubble-0.7.9}/PKG-INFO +7 -9
  2. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/README.md +6 -8
  3. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/SPEC.md +4 -5
  4. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/bubble/__init__.py +1 -1
  5. dev_bubble-0.7.8/bubble/claude.py → dev_bubble-0.7.9/bubble/ai.py +136 -43
  6. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/bubble/auth_proxy.py +52 -52
  7. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/bubble/cli.py +99 -64
  8. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/bubble/commands/settings.py +67 -47
  9. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/bubble/config.py +24 -19
  10. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/bubble/data/skill.md +11 -1
  11. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/bubble/finalization.py +10 -5
  12. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/bubble/images/scripts/tools/vscode.sh +65 -135
  13. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/bubble/provisioning.py +2 -2
  14. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/bubble/remote.py +9 -9
  15. {dev_bubble-0.7.8 → dev_bubble-0.7.9/dev_bubble.egg-info}/PKG-INFO +7 -9
  16. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/dev_bubble.egg-info/SOURCES.txt +2 -2
  17. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/tests/conftest.py +2 -2
  18. dev_bubble-0.7.8/tests/test_claude.py → dev_bubble-0.7.9/tests/test_ai.py +239 -85
  19. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/tests/test_auth_proxy.py +17 -0
  20. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/tests/test_branch_no_target.py +1 -1
  21. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/tests/test_claude_projects_symlink.py +28 -28
  22. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/tests/test_config.py +64 -137
  23. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/tests/test_github_token.py +156 -14
  24. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/tests/test_mounts.py +8 -8
  25. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/tests/test_remote.py +6 -6
  26. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/tests/test_tools.py +0 -12
  27. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/.claude/CLAUDE.md +0 -0
  28. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/.github/workflows/ci.yml +0 -0
  29. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/.github/workflows/publish.yml +0 -0
  30. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/.gitignore +0 -0
  31. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/CHANGELOG.md +0 -0
  32. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/LICENSE +0 -0
  33. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/bubble/__main__.py +0 -0
  34. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/bubble/automation.py +0 -0
  35. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/bubble/clean.py +0 -0
  36. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/bubble/clone.py +0 -0
  37. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/bubble/cloud.py +0 -0
  38. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/bubble/cloud_types.py +0 -0
  39. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/bubble/commands/__init__.py +0 -0
  40. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/bubble/commands/cloud_cmd.py +0 -0
  41. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/bubble/commands/completion.py +0 -0
  42. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/bubble/commands/doctor.py +0 -0
  43. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/bubble/commands/images.py +0 -0
  44. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/bubble/commands/infrastructure.py +0 -0
  45. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/bubble/commands/lifecycle.py +0 -0
  46. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/bubble/commands/list_cmd.py +0 -0
  47. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/bubble/commands/relay_cmd.py +0 -0
  48. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/bubble/commands/remote_cmd.py +0 -0
  49. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/bubble/commands/security_cmd.py +0 -0
  50. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/bubble/commands/status_cmd.py +0 -0
  51. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/bubble/container_helpers.py +0 -0
  52. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/bubble/default_repos.json +0 -0
  53. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/bubble/git_store.py +0 -0
  54. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/bubble/github_token.py +0 -0
  55. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/bubble/graphql_validator.py +0 -0
  56. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/bubble/hooks/__init__.py +0 -0
  57. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/bubble/hooks/lean.py +0 -0
  58. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/bubble/hooks/python.py +0 -0
  59. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/bubble/image_management.py +0 -0
  60. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/bubble/images/__init__.py +0 -0
  61. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/bubble/images/builder.py +0 -0
  62. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/bubble/images/scripts/base.sh +0 -0
  63. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/bubble/images/scripts/cloud-init.sh +0 -0
  64. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/bubble/images/scripts/lean-toolchain.sh +0 -0
  65. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/bubble/images/scripts/lean.sh +0 -0
  66. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/bubble/images/scripts/python.sh +0 -0
  67. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/bubble/images/scripts/tools/claude.sh +0 -0
  68. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/bubble/images/scripts/tools/codex.sh +0 -0
  69. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/bubble/images/scripts/tools/elan.sh +0 -0
  70. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/bubble/images/scripts/tools/emacs.sh +0 -0
  71. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/bubble/images/scripts/tools/gh.sh +0 -0
  72. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/bubble/images/scripts/tools/neovim.sh +0 -0
  73. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/bubble/images/scripts/tools/pins.json +0 -0
  74. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/bubble/lean.py +0 -0
  75. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/bubble/lifecycle.py +0 -0
  76. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/bubble/naming.py +0 -0
  77. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/bubble/native.py +0 -0
  78. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/bubble/network.py +0 -0
  79. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/bubble/notices.py +0 -0
  80. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/bubble/output.py +0 -0
  81. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/bubble/relay.py +0 -0
  82. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/bubble/repo_registry.py +0 -0
  83. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/bubble/runtime/__init__.py +0 -0
  84. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/bubble/runtime/base.py +0 -0
  85. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/bubble/runtime/colima.py +0 -0
  86. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/bubble/runtime/incus.py +0 -0
  87. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/bubble/security.py +0 -0
  88. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/bubble/setup.py +0 -0
  89. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/bubble/skill.py +0 -0
  90. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/bubble/spinner.py +0 -0
  91. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/bubble/target.py +0 -0
  92. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/bubble/token_store.py +0 -0
  93. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/bubble/tools.py +0 -0
  94. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/bubble/tunnel.py +0 -0
  95. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/bubble/vscode.py +0 -0
  96. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/config/com.bubble.git-update.plist +0 -0
  97. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/config/com.bubble.image-refresh.plist +0 -0
  98. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/config/com.bubble.relay-daemon.plist +0 -0
  99. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/conftest.py +0 -0
  100. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/dev_bubble.egg-info/dependency_links.txt +0 -0
  101. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/dev_bubble.egg-info/entry_points.txt +0 -0
  102. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/dev_bubble.egg-info/requires.txt +0 -0
  103. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/dev_bubble.egg-info/top_level.txt +0 -0
  104. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/pyproject.toml +0 -0
  105. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/setup.cfg +0 -0
  106. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/tests/test_build_lock.py +0 -0
  107. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/tests/test_cloud.py +0 -0
  108. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/tests/test_completion.py +0 -0
  109. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/tests/test_customize.py +0 -0
  110. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/tests/test_editor.py +0 -0
  111. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/tests/test_git_store.py +0 -0
  112. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/tests/test_graphql_validator.py +0 -0
  113. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/tests/test_hooks.py +0 -0
  114. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/tests/test_integration.py +0 -0
  115. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/tests/test_lifecycle.py +0 -0
  116. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/tests/test_list_columns.py +0 -0
  117. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/tests/test_list_remote.py +0 -0
  118. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/tests/test_multi_target.py +0 -0
  119. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/tests/test_naming.py +0 -0
  120. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/tests/test_native.py +0 -0
  121. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/tests/test_network.py +0 -0
  122. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/tests/test_notices.py +0 -0
  123. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/tests/test_relay.py +0 -0
  124. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/tests/test_repo_registry.py +0 -0
  125. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/tests/test_security.py +0 -0
  126. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/tests/test_skill.py +0 -0
  127. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/tests/test_spinner.py +0 -0
  128. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/tests/test_status.py +0 -0
  129. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/tests/test_target.py +0 -0
  130. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/tests/test_tunnel.py +0 -0
  131. {dev_bubble-0.7.8 → dev_bubble-0.7.9}/tests/test_vscode.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dev-bubble
3
- Version: 0.7.8
3
+ Version: 0.7.9
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
@@ -43,7 +43,7 @@ We assume that you work using VSCode, emacs, or neovim, and that you collaborate
43
43
 
44
44
  ```bash
45
45
  # Install
46
- uv tool install dev-bubble
46
+ uv tool install git+https://github.com/kim-em/bubble.git
47
47
 
48
48
  # Open a bubble for a GitHub PR — just paste the URL, and you get a containerized VSCode window!
49
49
  bubble https://github.com/leanprover-community/mathlib4/pull/35219
@@ -124,11 +124,9 @@ bubble pop mathlib4-pr-35219
124
124
  ## Development Install
125
125
 
126
126
  ```bash
127
- # Install from GitHub
128
- uv tool install git+https://github.com/kim-em/bubble.git
129
-
130
- # For development
131
- uv pip install -e '.[dev]'
127
+ # Install from a local clone (editable — always uses latest code)
128
+ git clone https://github.com/kim-em/bubble.git
129
+ uv tool install --force --editable bubble
132
130
  ```
133
131
 
134
132
  ## How It Works
@@ -263,10 +261,10 @@ bubble mathlib4/issues/42
263
261
 
264
262
  Claude is instructed to read the issue, implement a fix on the `issue-<number>` branch, and open a PR. This turns `bubble 42` (for an issue) into an autonomous coding agent workflow.
265
263
 
266
- You can also provide a custom prompt for any bubble via the `BUBBLE_CLAUDE_PROMPT` environment variable:
264
+ You can also provide a custom prompt for any bubble via the `BUBBLE_AI_PROMPT` environment variable:
267
265
 
268
266
  ```bash
269
- BUBBLE_CLAUDE_PROMPT="Refactor the parser module" bubble leanprover/lean4
267
+ BUBBLE_AI_PROMPT="Refactor the parser module" bubble leanprover/lean4
270
268
  ```
271
269
 
272
270
  Requirements: Claude Code must be installed in the container (see tool settings above), the `gh` CLI must be available on the host (for fetching issue/PR metadata), and the default VS Code editor must be used. With `--shell` or `--no-interactive`, the prompt is not injected. If `gh` is unavailable or the API call fails, bubble proceeds without injecting a prompt.
@@ -9,7 +9,7 @@ We assume that you work using VSCode, emacs, or neovim, and that you collaborate
9
9
 
10
10
  ```bash
11
11
  # Install
12
- uv tool install dev-bubble
12
+ uv tool install git+https://github.com/kim-em/bubble.git
13
13
 
14
14
  # Open a bubble for a GitHub PR — just paste the URL, and you get a containerized VSCode window!
15
15
  bubble https://github.com/leanprover-community/mathlib4/pull/35219
@@ -90,11 +90,9 @@ bubble pop mathlib4-pr-35219
90
90
  ## Development Install
91
91
 
92
92
  ```bash
93
- # Install from GitHub
94
- uv tool install git+https://github.com/kim-em/bubble.git
95
-
96
- # For development
97
- uv pip install -e '.[dev]'
93
+ # Install from a local clone (editable — always uses latest code)
94
+ git clone https://github.com/kim-em/bubble.git
95
+ uv tool install --force --editable bubble
98
96
  ```
99
97
 
100
98
  ## How It Works
@@ -229,10 +227,10 @@ bubble mathlib4/issues/42
229
227
 
230
228
  Claude is instructed to read the issue, implement a fix on the `issue-<number>` branch, and open a PR. This turns `bubble 42` (for an issue) into an autonomous coding agent workflow.
231
229
 
232
- You can also provide a custom prompt for any bubble via the `BUBBLE_CLAUDE_PROMPT` environment variable:
230
+ You can also provide a custom prompt for any bubble via the `BUBBLE_AI_PROMPT` environment variable:
233
231
 
234
232
  ```bash
235
- BUBBLE_CLAUDE_PROMPT="Refactor the parser module" bubble leanprover/lean4
233
+ BUBBLE_AI_PROMPT="Refactor the parser module" bubble leanprover/lean4
236
234
  ```
237
235
 
238
236
  Requirements: Claude Code must be installed in the container (see tool settings above), the `gh` CLI must be available on the host (for fetching issue/PR metadata), and the default VS Code editor must be used. With `--shell` or `--no-interactive`, the prompt is not injected. If `gh` is unavailable or the API call fails, bubble proceeds without injecting a prompt.
@@ -56,7 +56,7 @@ equivalent to `bubble open <url>`.
56
56
  | `-b`, `--new-branch` | string | | Create a new branch |
57
57
  | `--base` | string | | Base branch for `-b` |
58
58
  | `--mount` | string (repeatable) | | Mount host dir into container |
59
- | `--claude-config/--no-claude-config` | flag | enabled | Mount ~/.claude config read-only |
59
+ | `--ai-config/--no-ai-config` | flag | enabled | Mount AI provider configs read-only |
60
60
  | `--claude-credentials/--no-claude-credentials` | flag | enabled | Mount Claude credentials |
61
61
  | `--codex-credentials/--no-codex-credentials` | flag | enabled | Mount Codex credentials |
62
62
  | `--ssh HOST` | string | | Run on remote host |
@@ -912,7 +912,7 @@ validation already confirmed the repo exists).
912
912
 
913
913
  ### 10.1 Claude Code config mounting
914
914
 
915
- When `--claude-config` is enabled (default), mount specific items from
915
+ When `--ai-config` is enabled (default), mount specific items from
916
916
  `~/.claude/` into `/home/user/.claude/` read-only:
917
917
  - `CLAUDE.md`, `settings.json`, `skills/`, `keybindings.json`, `commands/`
918
918
 
@@ -1087,11 +1087,10 @@ Configuration is managed through dedicated subcommands rather than a generic
1087
1087
  get/set interface:
1088
1088
 
1089
1089
  - `bubble tools set TOOL yes|no|auto` — configure tool installation
1090
- - `bubble claude credentials on|off` — toggle Claude credential mounting
1091
- - `bubble codex credentials on|off` — toggle Codex credential mounting
1090
+ - `bubble ai credentials on|off [--provider claude|codex]` — toggle AI credential mounting
1092
1091
  - `bubble security set NAME on|off|auto` — configure security settings
1093
1092
  - `bubble config set KEY VALUE` — set security settings (alias)
1094
- - `bubble config symlink-claude-projects` — symlink Claude projects directory
1093
+ - `bubble config symlink-ai-projects` — symlink AI projects directory
1095
1094
 
1096
1095
  ---
1097
1096
 
@@ -1,3 +1,3 @@
1
1
  """bubble: Containerized development environments."""
2
2
 
3
- __version__ = "0.7.8"
3
+ __version__ = "0.7.9"
@@ -1,20 +1,52 @@
1
- """Claude Code integration for bubble containers."""
1
+ """AI provider integration for bubble containers.
2
+
3
+ Handles prompt generation and task injection for the configured preferred
4
+ AI provider (default: Claude Code). Provider-neutral where possible;
5
+ provider-specific details (binary names, env vars, prompt file paths)
6
+ are dispatched via the ``[ai] preferred`` config key.
7
+ """
2
8
 
3
9
  import json
4
10
  import re
5
11
  import shlex
6
12
  import subprocess
13
+ from pathlib import Path
7
14
 
8
15
  from .config import DATA_DIR
9
16
  from .runtime.base import ContainerRuntime
10
17
 
11
- # Claude task command: reads prompt from file, runs Claude with skip-permissions,
12
- # then deletes the prompt file so reopening is clean.
13
- CLAUDE_TASK_COMMAND = (
14
- "test -f .vscode/claude-prompt.txt && ANTHROPIC_API_KEY= CLAUDECODE="
15
- ' claude --dangerously-skip-permissions "$(cat .vscode/claude-prompt.txt)"'
16
- " && rm -f .vscode/claude-prompt.txt"
17
- )
18
+ # Provider-specific task commands.
19
+ # Each reads the prompt from .vscode/ai-prompt.txt, runs the AI tool
20
+ # with autonomous permissions, then deletes the prompt file.
21
+ _TASK_COMMANDS = {
22
+ "claude": (
23
+ "test -f .vscode/ai-prompt.txt && ANTHROPIC_API_KEY= CLAUDECODE="
24
+ ' claude --dangerously-skip-permissions "$(cat .vscode/ai-prompt.txt)"'
25
+ " && rm -f .vscode/ai-prompt.txt"
26
+ ),
27
+ "codex": (
28
+ "test -f .vscode/ai-prompt.txt &&"
29
+ ' codex --approval-mode full-auto "$(cat .vscode/ai-prompt.txt)"'
30
+ " && rm -f .vscode/ai-prompt.txt"
31
+ ),
32
+ }
33
+
34
+ SUPPORTED_PROVIDERS = frozenset(_TASK_COMMANDS)
35
+
36
+
37
+ def _task_command_for(provider: str) -> str:
38
+ """Return the VS Code task shell command for the given AI provider.
39
+
40
+ Raises ``ValueError`` for unknown providers so typos are caught early.
41
+ """
42
+ try:
43
+ return _TASK_COMMANDS[provider]
44
+ except KeyError:
45
+ supported = ", ".join(sorted(SUPPORTED_PROVIDERS))
46
+ raise ValueError(f"Unknown AI provider {provider!r} (supported: {supported})") from None
47
+
48
+
49
+ AI_TASK_COMMAND = _TASK_COMMANDS["claude"]
18
50
 
19
51
  TEMPLATES_DIR = DATA_DIR / "templates"
20
52
 
@@ -173,7 +205,7 @@ def generate_issue_prompt(
173
205
  second_opinion: str = "auto",
174
206
  config: dict | None = None,
175
207
  ) -> str | None:
176
- """Fetch GitHub issue details and generate a Claude prompt.
208
+ """Fetch GitHub issue details and generate an AI prompt.
177
209
 
178
210
  Returns the prompt string, or None if the issue can't be fetched.
179
211
  Uses a custom template from ~/.bubble/templates/issue.txt if present,
@@ -235,7 +267,7 @@ def generate_issue_prompt(
235
267
 
236
268
 
237
269
  def generate_pr_prompt(owner: str, repo: str, pr_num: str, branch: str) -> str | None:
238
- """Fetch GitHub PR details and generate a Claude prompt.
270
+ """Fetch GitHub PR details and generate an AI prompt.
239
271
 
240
272
  Returns the prompt string, or None if the PR can't be fetched.
241
273
  Uses a custom template from ~/.bubble/templates/pr.txt if present,
@@ -260,17 +292,92 @@ def generate_pr_prompt(owner: str, repo: str, pr_num: str, branch: str) -> str |
260
292
  )
261
293
 
262
294
 
263
- def inject_claude_task(
264
- runtime: ContainerRuntime, container: str, project_dir: str, prompt: str, quiet: bool = False
295
+ # Known-safe keys to copy from the host's ~/.claude.json.
296
+ # Only cosmetic/UX settings no credentials, MCP config, or host-specific paths.
297
+ _CLAUDE_JSON_SAFE_KEYS = frozenset(
298
+ {
299
+ "theme",
300
+ "hasCompletedOnboarding",
301
+ "numStartups",
302
+ "preferredNotifChannel",
303
+ "autoUpdaterStatus",
304
+ }
305
+ )
306
+
307
+
308
+ def setup_claude_settings(
309
+ runtime: ContainerRuntime,
310
+ container: str,
311
+ project_dir: str,
312
+ ):
313
+ """Pre-populate ~/.claude.json in the container to skip the first-run wizard.
314
+
315
+ Copies allowlisted settings (theme, onboarding state, etc.) from the host's
316
+ ~/.claude.json if it exists. Always ensures hasCompletedOnboarding=True
317
+ and pre-trusts the project directory. This runs for ALL bubbles, not just
318
+ those with AI task injection.
319
+
320
+ Best-effort: failures are logged but do not abort bubble creation.
321
+ """
322
+ host_claude_json = Path.home() / ".claude.json"
323
+
324
+ # Extract only allowlisted keys from host settings
325
+ settings: dict = {}
326
+ if host_claude_json.is_file():
327
+ try:
328
+ host_data = json.loads(host_claude_json.read_text())
329
+ if isinstance(host_data, dict):
330
+ settings = {k: v for k, v in host_data.items() if k in _CLAUDE_JSON_SAFE_KEYS}
331
+ except (OSError, json.JSONDecodeError):
332
+ pass
333
+
334
+ # Ensure onboarding is marked complete
335
+ settings["hasCompletedOnboarding"] = True
336
+ n = settings.get("numStartups", 0)
337
+ settings["numStartups"] = (n if isinstance(n, int) else 0) + 1
338
+
339
+ # Pre-trust the project directory
340
+ settings["projects"] = {
341
+ project_dir: {"hasTrustDialogAccepted": True, "allowedTools": []},
342
+ }
343
+
344
+ # Write to container (best-effort — don't abort bubble creation on failure)
345
+ settings_json = shlex.quote(json.dumps(settings, indent=2))
346
+ try:
347
+ runtime.exec(
348
+ container,
349
+ ["su", "-", "user", "-c", f"printf '%s' {settings_json} > ~/.claude.json"],
350
+ )
351
+ except Exception:
352
+ from .output import detail
353
+
354
+ detail("Warning: could not pre-populate Claude Code settings.", err=True)
355
+
356
+
357
+ def inject_ai_task(
358
+ runtime: ContainerRuntime,
359
+ container: str,
360
+ project_dir: str,
361
+ prompt: str,
362
+ config: dict | None = None,
363
+ quiet: bool = False,
265
364
  ):
266
- """Inject Claude auto-start task into a container's VS Code configuration.
365
+ """Inject AI auto-start task into a container's VS Code configuration.
366
+
367
+ Dispatches to the configured preferred AI provider (default: Claude).
267
368
 
268
- - Writes prompt to .vscode/claude-prompt.txt
269
- - Creates/updates .vscode/tasks.json with Claude task (runOn: folderOpen)
369
+ - Writes prompt to .vscode/ai-prompt.txt
370
+ - Creates/updates .vscode/tasks.json with AI task (runOn: folderOpen)
270
371
  - Configures .vscode/settings.json for automatic tasks
271
372
  - Adds generated files to git exclude
272
- - Pre-trusts the project directory in .claude.json
373
+ - Pre-trusts the project directory in the preferred provider's config
273
374
  """
375
+ provider = "claude"
376
+ if config:
377
+ provider = config.get("ai", {}).get("preferred", "claude")
378
+
379
+ task_command = _task_command_for(provider)
380
+
274
381
  q_dir = shlex.quote(project_dir)
275
382
  q_prompt = shlex.quote(prompt)
276
383
 
@@ -280,20 +387,21 @@ def inject_claude_task(
280
387
  # Write prompt to file
281
388
  runtime.exec(
282
389
  container,
283
- ["su", "-", "user", "-c", f"printf '%s' {q_prompt} > {q_dir}/.vscode/claude-prompt.txt"],
390
+ ["su", "-", "user", "-c", f"printf '%s' {q_prompt} > {q_dir}/.vscode/ai-prompt.txt"],
284
391
  )
285
392
 
286
- # Create or update tasks.json with Claude task
287
- claude_task = {
288
- "label": "Claude",
393
+ # Create or update tasks.json with AI task
394
+ ai_task = {
395
+ "label": "AI",
289
396
  "type": "shell",
290
- "command": CLAUDE_TASK_COMMAND,
397
+ "command": task_command,
291
398
  "runOptions": {"runOn": "folderOpen"},
292
399
  "presentation": {"reveal": "always", "panel": "dedicated"},
293
400
  }
294
- tasks_json_str = shlex.quote(json.dumps(claude_task))
401
+ tasks_json_str = shlex.quote(json.dumps(ai_task))
295
402
 
296
- # Script: if tasks.json exists, add Claude task; otherwise create new file
403
+ # Script: if tasks.json exists, add AI task (removing old Claude and AI labels);
404
+ # otherwise create new file
297
405
  script = (
298
406
  f"cd {q_dir} && "
299
407
  f"if [ -f .vscode/tasks.json ]; then "
@@ -301,7 +409,7 @@ def inject_claude_task(
301
409
  f"import json,sys; "
302
410
  f"t=json.load(open('.vscode/tasks.json')); "
303
411
  f"t['tasks']=[x for x in t.get('tasks',[])"
304
- f" if x.get('label')!='Claude']+[json.loads(sys.argv[1])]; "
412
+ f" if x.get('label') not in ('Claude','AI')]+[json.loads(sys.argv[1])]; "
305
413
  f"json.dump(t,open('.vscode/tasks.json','w'),indent=2)"
306
414
  f'" {tasks_json_str}; '
307
415
  f"else "
@@ -332,31 +440,16 @@ def inject_claude_task(
332
440
  f"cd {q_dir} && "
333
441
  f"GIT_DIR=$(git rev-parse --git-dir) && "
334
442
  f"mkdir -p $GIT_DIR/info && "
335
- f"for f in .vscode/claude-prompt.txt .vscode/settings.json .vscode/tasks.json; do "
443
+ f"for f in .vscode/ai-prompt.txt .vscode/claude-prompt.txt"
444
+ f" .vscode/settings.json .vscode/tasks.json; do "
336
445
  f' grep -qxF "$f" $GIT_DIR/info/exclude 2>/dev/null'
337
446
  f' || echo "$f" >> $GIT_DIR/info/exclude; '
338
447
  f"done"
339
448
  )
340
449
  runtime.exec(container, ["su", "-", "user", "-c", exclude_script])
341
450
 
342
- # Pre-trust the project directory and skip onboarding in .claude.json
343
- trust_script = (
344
- f'python3 -c "'
345
- f"import json,os; "
346
- f"p=os.path.expanduser('~/.claude.json'); "
347
- f"d=json.load(open(p)) if os.path.exists(p) else {{}}; "
348
- f"d['hasCompletedOnboarding']=True; "
349
- f"n=d.get('numStartups',0); d['numStartups']=(n if isinstance(n,int) else 0)+1; "
350
- f"d.setdefault('projects',{{}}); "
351
- f"proj=d['projects'].setdefault({shlex.quote(project_dir)!r},{{}}); " # noqa: E501
352
- f"proj['hasTrustDialogAccepted']=True; "
353
- f"proj.setdefault('allowedTools',[]); "
354
- f"json.dump(d,open(p,'w'),indent=2)"
355
- f'"'
356
- )
357
- runtime.exec(container, ["su", "-", "user", "-c", trust_script])
358
-
359
451
  if not quiet:
360
452
  from .output import detail
361
453
 
362
- detail("Claude Code task injected (will start on VS Code folder open).")
454
+ label = provider.capitalize()
455
+ detail(f"{label} task injected (will start on VS Code folder open).")
@@ -326,19 +326,21 @@ def validate_api_path(
326
326
  # ---------------------------------------------------------------------------
327
327
 
328
328
 
329
- def _skip_braced_block(text: str, start: int) -> int:
330
- """Skip a balanced { ... } block starting at position start.
329
+ def _skip_braced_tokens(tokens: list, start: int) -> int:
330
+ """Skip a balanced { ... } block in a token list starting at *start*.
331
331
 
332
- Returns the position after the closing brace, or -1 on error.
332
+ tokens[start] must be a '{' punct token.
333
+ Returns the index after the closing '}', or -1 on error.
333
334
  """
334
- if start >= len(text) or text[start] != "{":
335
+ if start >= len(tokens) or tokens[start].value != "{":
335
336
  return -1
336
337
  depth = 1
337
338
  i = start + 1
338
- while i < len(text) and depth > 0:
339
- if text[i] == "{":
339
+ while i < len(tokens) and depth > 0:
340
+ v = tokens[i].value
341
+ if v == "{":
340
342
  depth += 1
341
- elif text[i] == "}":
343
+ elif v == "}":
342
344
  depth -= 1
343
345
  i += 1
344
346
  return i if depth == 0 else -1
@@ -350,64 +352,62 @@ def _collect_graphql_op_types(query: str) -> list[str]:
350
352
  Returns a list of operation types found (e.g. ['query', 'mutation']).
351
353
  Handles line comments, fragment definitions, and anonymous queries.
352
354
  Multiple operations in a single document are all reported.
355
+
356
+ Uses the string-aware tokenizer from graphql_validator so that braces
357
+ inside string literals are not miscounted.
353
358
  """
354
- # Strip line comments
355
- lines = []
356
- for line in query.split("\n"):
357
- idx = line.find("#")
358
- if idx >= 0:
359
- line = line[:idx]
360
- lines.append(line)
361
- cleaned = " ".join(lines).strip()
362
-
363
- if not cleaned:
359
+ from .graphql_validator import _tokenize
360
+
361
+ tokens = _tokenize(query)
362
+ if not tokens:
364
363
  return []
365
364
 
366
- ops = []
367
- pos = 0
365
+ ops: list[str] = []
366
+ i = 0
368
367
 
369
- while pos < len(cleaned):
370
- # Skip whitespace
371
- while pos < len(cleaned) and cleaned[pos] in " \t\r\n":
372
- pos += 1
373
- if pos >= len(cleaned):
374
- break
368
+ while i < len(tokens):
369
+ tok = tokens[i]
375
370
 
376
371
  # Anonymous query starts with {
377
- if cleaned[pos] == "{":
372
+ if tok.kind == "punct" and tok.value == "{":
378
373
  ops.append("query")
379
- end = _skip_braced_block(cleaned, pos)
374
+ end = _skip_braced_tokens(tokens, i)
380
375
  if end == -1:
381
376
  break
382
- pos = end
377
+ i = end
383
378
  continue
384
379
 
385
- # Skip fragment definitions: fragment Name on Type { ... }
386
- if cleaned[pos:].startswith("fragment"):
387
- brace_start = cleaned.find("{", pos)
388
- if brace_start == -1:
389
- break
390
- end = _skip_braced_block(cleaned, brace_start)
391
- if end == -1:
392
- break
393
- pos = end
394
- continue
380
+ if tok.kind == "ident":
381
+ # Skip fragment definitions: fragment Name on Type { ... }
382
+ if tok.value == "fragment":
383
+ # Scan forward to the opening brace
384
+ j = i + 1
385
+ while j < len(tokens) and tokens[j].value != "{":
386
+ j += 1
387
+ if j >= len(tokens):
388
+ break
389
+ end = _skip_braced_tokens(tokens, j)
390
+ if end == -1:
391
+ break
392
+ i = end
393
+ continue
395
394
 
396
- # Check for operation keyword
397
- match = re.match(r"(query|mutation|subscription)\b", cleaned[pos:], re.IGNORECASE)
398
- if match:
399
- ops.append(match.group(1).lower())
400
- # Skip past the operation body
401
- brace_start = cleaned.find("{", pos)
402
- if brace_start == -1:
403
- break
404
- end = _skip_braced_block(cleaned, brace_start)
405
- if end == -1:
406
- break
407
- pos = end
408
- continue
395
+ # Check for operation keyword
396
+ if tok.value.lower() in ("query", "mutation", "subscription"):
397
+ ops.append(tok.value.lower())
398
+ # Scan forward to the opening brace
399
+ j = i + 1
400
+ while j < len(tokens) and tokens[j].value != "{":
401
+ j += 1
402
+ if j >= len(tokens):
403
+ break
404
+ end = _skip_braced_tokens(tokens, j)
405
+ if end == -1:
406
+ break
407
+ i = end
408
+ continue
409
409
 
410
- # Unrecognized content — stop parsing
410
+ # Unrecognized token — stop parsing
411
411
  break
412
412
 
413
413
  return ops