dev-bubble 0.7.6__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 (133) hide show
  1. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/.claude/CLAUDE.md +13 -8
  2. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/CHANGELOG.md +1 -1
  3. {dev_bubble-0.7.6/dev_bubble.egg-info → dev_bubble-0.7.9}/PKG-INFO +7 -9
  4. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/README.md +6 -8
  5. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/SPEC.md +4 -5
  6. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/bubble/__init__.py +1 -1
  7. dev_bubble-0.7.9/bubble/ai.py +455 -0
  8. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/bubble/auth_proxy.py +52 -52
  9. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/bubble/clean.py +3 -1
  10. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/bubble/cli.py +129 -66
  11. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/bubble/commands/security_cmd.py +6 -4
  12. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/bubble/commands/settings.py +117 -49
  13. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/bubble/config.py +24 -17
  14. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/bubble/container_helpers.py +8 -6
  15. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/bubble/data/skill.md +11 -1
  16. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/bubble/finalization.py +10 -5
  17. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/bubble/github_token.py +41 -23
  18. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/bubble/images/builder.py +22 -14
  19. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/bubble/images/scripts/base.sh +5 -0
  20. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/bubble/images/scripts/lean-toolchain.sh +1 -1
  21. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/bubble/images/scripts/lean.sh +3 -1
  22. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/bubble/images/scripts/python.sh +2 -2
  23. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/bubble/images/scripts/tools/vscode.sh +65 -135
  24. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/bubble/provisioning.py +2 -2
  25. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/bubble/remote.py +9 -9
  26. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/bubble/runtime/base.py +22 -0
  27. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/bubble/runtime/incus.py +47 -0
  28. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/bubble/security.py +187 -34
  29. {dev_bubble-0.7.6 → dev_bubble-0.7.9/dev_bubble.egg-info}/PKG-INFO +7 -9
  30. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/dev_bubble.egg-info/SOURCES.txt +2 -2
  31. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/tests/conftest.py +2 -2
  32. dev_bubble-0.7.9/tests/test_ai.py +727 -0
  33. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/tests/test_auth_proxy.py +17 -0
  34. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/tests/test_branch_no_target.py +1 -1
  35. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/tests/test_claude_projects_symlink.py +28 -28
  36. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/tests/test_config.py +92 -106
  37. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/tests/test_github_token.py +163 -24
  38. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/tests/test_mounts.py +8 -8
  39. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/tests/test_remote.py +6 -6
  40. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/tests/test_security.py +260 -151
  41. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/tests/test_tools.py +54 -18
  42. dev_bubble-0.7.6/bubble/claude.py +0 -271
  43. dev_bubble-0.7.6/tests/test_claude.py +0 -393
  44. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/.github/workflows/ci.yml +0 -0
  45. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/.github/workflows/publish.yml +0 -0
  46. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/.gitignore +0 -0
  47. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/LICENSE +0 -0
  48. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/bubble/__main__.py +0 -0
  49. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/bubble/automation.py +0 -0
  50. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/bubble/clone.py +0 -0
  51. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/bubble/cloud.py +0 -0
  52. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/bubble/cloud_types.py +0 -0
  53. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/bubble/commands/__init__.py +0 -0
  54. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/bubble/commands/cloud_cmd.py +0 -0
  55. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/bubble/commands/completion.py +0 -0
  56. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/bubble/commands/doctor.py +0 -0
  57. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/bubble/commands/images.py +0 -0
  58. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/bubble/commands/infrastructure.py +0 -0
  59. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/bubble/commands/lifecycle.py +0 -0
  60. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/bubble/commands/list_cmd.py +0 -0
  61. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/bubble/commands/relay_cmd.py +0 -0
  62. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/bubble/commands/remote_cmd.py +0 -0
  63. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/bubble/commands/status_cmd.py +0 -0
  64. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/bubble/default_repos.json +0 -0
  65. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/bubble/git_store.py +0 -0
  66. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/bubble/graphql_validator.py +0 -0
  67. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/bubble/hooks/__init__.py +0 -0
  68. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/bubble/hooks/lean.py +0 -0
  69. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/bubble/hooks/python.py +0 -0
  70. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/bubble/image_management.py +0 -0
  71. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/bubble/images/__init__.py +0 -0
  72. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/bubble/images/scripts/cloud-init.sh +0 -0
  73. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/bubble/images/scripts/tools/claude.sh +0 -0
  74. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/bubble/images/scripts/tools/codex.sh +0 -0
  75. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/bubble/images/scripts/tools/elan.sh +0 -0
  76. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/bubble/images/scripts/tools/emacs.sh +0 -0
  77. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/bubble/images/scripts/tools/gh.sh +0 -0
  78. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/bubble/images/scripts/tools/neovim.sh +0 -0
  79. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/bubble/images/scripts/tools/pins.json +0 -0
  80. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/bubble/lean.py +0 -0
  81. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/bubble/lifecycle.py +0 -0
  82. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/bubble/naming.py +0 -0
  83. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/bubble/native.py +0 -0
  84. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/bubble/network.py +0 -0
  85. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/bubble/notices.py +0 -0
  86. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/bubble/output.py +0 -0
  87. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/bubble/relay.py +0 -0
  88. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/bubble/repo_registry.py +0 -0
  89. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/bubble/runtime/__init__.py +0 -0
  90. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/bubble/runtime/colima.py +0 -0
  91. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/bubble/setup.py +0 -0
  92. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/bubble/skill.py +0 -0
  93. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/bubble/spinner.py +0 -0
  94. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/bubble/target.py +0 -0
  95. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/bubble/token_store.py +0 -0
  96. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/bubble/tools.py +0 -0
  97. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/bubble/tunnel.py +0 -0
  98. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/bubble/vscode.py +0 -0
  99. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/config/com.bubble.git-update.plist +0 -0
  100. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/config/com.bubble.image-refresh.plist +0 -0
  101. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/config/com.bubble.relay-daemon.plist +0 -0
  102. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/conftest.py +0 -0
  103. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/dev_bubble.egg-info/dependency_links.txt +0 -0
  104. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/dev_bubble.egg-info/entry_points.txt +0 -0
  105. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/dev_bubble.egg-info/requires.txt +0 -0
  106. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/dev_bubble.egg-info/top_level.txt +0 -0
  107. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/pyproject.toml +0 -0
  108. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/setup.cfg +0 -0
  109. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/tests/test_build_lock.py +0 -0
  110. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/tests/test_cloud.py +0 -0
  111. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/tests/test_completion.py +0 -0
  112. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/tests/test_customize.py +0 -0
  113. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/tests/test_editor.py +0 -0
  114. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/tests/test_git_store.py +0 -0
  115. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/tests/test_graphql_validator.py +0 -0
  116. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/tests/test_hooks.py +0 -0
  117. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/tests/test_integration.py +0 -0
  118. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/tests/test_lifecycle.py +0 -0
  119. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/tests/test_list_columns.py +0 -0
  120. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/tests/test_list_remote.py +0 -0
  121. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/tests/test_multi_target.py +0 -0
  122. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/tests/test_naming.py +0 -0
  123. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/tests/test_native.py +0 -0
  124. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/tests/test_network.py +0 -0
  125. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/tests/test_notices.py +0 -0
  126. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/tests/test_relay.py +0 -0
  127. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/tests/test_repo_registry.py +0 -0
  128. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/tests/test_skill.py +0 -0
  129. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/tests/test_spinner.py +0 -0
  130. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/tests/test_status.py +0 -0
  131. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/tests/test_target.py +0 -0
  132. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/tests/test_tunnel.py +0 -0
  133. {dev_bubble-0.7.6 → dev_bubble-0.7.9}/tests/test_vscode.py +0 -0
@@ -121,14 +121,19 @@ Bubbles can run on a remote machine instead of locally. The `--ssh HOST` flag (o
121
121
  Users can place a `customize.sh` script at `~/.bubble/customize.sh` to run custom setup in all container images. The script runs as root as the final step when building any image (base, lean, lean-v4.X.Y). This lets users add tools, dotfiles, shell config, etc. without forking image scripts. The script's content hash is tracked in `~/.bubble/customize-hash`; on `bubble open`, if the hash differs from the stored value, a background rebuild of the base image is triggered (same pattern as VS Code commit hash drift). Code is in `builder.py` (`customize_hash()`, `_run_customize_script()`).
122
122
 
123
123
  ### GitHub Auth Proxy
124
- The auth proxy (`auth_proxy.py`) provides repo-scoped GitHub authentication without injecting the host's token into containers. It's an HTTP reverse proxy that runs on the host with graduated access levels:
125
-
126
- | Level | Description | Routes |
127
- |-------|-------------|--------|
128
- | 1 | Git only | `/git/{owner}/{repo}/...` (smart HTTP) |
129
- | 2 | Git + REST read | + `GET /repos/{owner}/{repo}/...` |
130
- | 3 | Git + gh read-only (default) | + `POST /graphql` (queries only, mutations blocked) |
131
- | 4 | Git + gh read-write | + mutations + REST POST/PATCH/DELETE |
124
+ The auth proxy (`auth_proxy.py`) provides repo-scoped GitHub authentication without injecting the host's token into containers. It's an HTTP reverse proxy that runs on the host. The access level is controlled by the unified `github` security setting (`security.py`), which picks one level from a graduated escalation ladder:
125
+
126
+ | `github` level | Behavior |
127
+ |----------------|----------|
128
+ | `off` | no GitHub access at all |
129
+ | `basic` | git push/pull only (proxy rewrites, repo-scoped) |
130
+ | `rest` | + repo-scoped REST API |
131
+ | `allowlist-read-graphql` | + allowlisted GraphQL queries |
132
+ | `allowlist-write-graphql` | + allowlisted GraphQL mutations (default) |
133
+ | `write-graphql` | + arbitrary GraphQL, no allowlist filtering |
134
+ | `direct` | inject the raw token, no proxy |
135
+
136
+ `auto` defaults to `allowlist-write-graphql`. The old `github-auth`, `github-api`, and `github-token-inject` settings are deprecated but migrated automatically.
132
137
 
133
138
  **Git flow:** Container git → `url.insteadOf` rewrites to `http://127.0.0.1:7654/git/...` → proxy validates `X-Bubble-Token` header → checks path matches allowed `owner/repo` → adds `Authorization: token <real-token>` → forwards to `https://github.com` → returns response.
134
139
 
@@ -218,7 +218,7 @@
218
218
  - Templated Claude prompts for issues and PRs (#33)
219
219
  - PR bubbles now auto-inject a Claude prompt that checks CI status and summarizes PR comments
220
220
  - User-customizable prompt templates via `~/.bubble/templates/issue.txt` and `~/.bubble/templates/pr.txt`
221
- - Issue template placeholders: `{owner}`, `{repo}`, `{issue_num}`, `{title}`, `{body}`, `{comments}`, `{comments_section}`, `{branch}`
221
+ - Issue template placeholders: `{owner}`, `{repo}`, `{issue_num}`, `{title}`, `{body}`, `{comments}`, `{comments_section}`, `{branch}`, `{instructions}`
222
222
  - PR template placeholders: `{owner}`, `{repo}`, `{pr_num}`, `{title}`, `{body}`, `{branch}`
223
223
  - Falls back to built-in defaults when no custom template exists
224
224
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dev-bubble
3
- Version: 0.7.6
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.6"
3
+ __version__ = "0.7.9"
@@ -0,0 +1,455 @@
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
+ """
8
+
9
+ import json
10
+ import re
11
+ import shlex
12
+ import subprocess
13
+ from pathlib import Path
14
+
15
+ from .config import DATA_DIR
16
+ from .runtime.base import ContainerRuntime
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"]
50
+
51
+ TEMPLATES_DIR = DATA_DIR / "templates"
52
+
53
+ # Ordered autonomy levels. Each level implies all lower levels.
54
+ AUTONOMY_LEVELS = ("read", "plan", "implement", "pr", "merge")
55
+
56
+ # Valid values for the second_opinion setting.
57
+ SECOND_OPINION_VALUES = ("auto", "on", "off")
58
+
59
+ # Issue prompt instructions keyed by autonomy level.
60
+ _ISSUE_INSTRUCTIONS = {
61
+ "read": (
62
+ "Please read and understand the issue. "
63
+ "Summarize the problem and any relevant context, but take no further action."
64
+ ),
65
+ "plan": (
66
+ "Please read and understand the issue, then propose a plan to fix it. "
67
+ "Describe what files need to change and how, but do not implement anything yet."
68
+ ),
69
+ "implement": (
70
+ "Please claim this issue (assign it to yourself if possible), "
71
+ "then implement a fix or feature as described. "
72
+ "Work on a branch named `{branch}`. Do not commit or open a PR."
73
+ ),
74
+ "pr": (
75
+ "Please claim this issue (assign it to yourself if possible), "
76
+ "then implement a fix or feature as described. "
77
+ "Work on a branch named `{branch}`, and open a PR when done."
78
+ ),
79
+ "merge": (
80
+ "Please claim this issue (assign it to yourself if possible), "
81
+ "then implement a fix or feature as described. "
82
+ "Work on a branch named `{branch}`, open a PR, "
83
+ "rebase onto the default branch, watch CI, and merge when it passes."
84
+ ),
85
+ }
86
+
87
+ _SECOND_OPINION_SUFFIX = (
88
+ "\n\nBefore proceeding, get a second opinion from another AI "
89
+ "(e.g. Codex) to review your approach."
90
+ )
91
+
92
+ # Default issue prompt template. Placeholders:
93
+ # {owner}, {repo}, {issue_num}, {title}, {body}, {comments},
94
+ # {comments_section} (pre-formatted, empty when no comments), {branch},
95
+ # {instructions}
96
+ _DEFAULT_ISSUE_TEMPLATE = (
97
+ 'Please read and understand GitHub issue #{issue_num}: "{title}".\n'
98
+ "\n"
99
+ "Issue description:\n"
100
+ "{body}\n"
101
+ "{comments_section}"
102
+ "\n{instructions}"
103
+ )
104
+
105
+ # Default PR prompt template. Placeholders:
106
+ # {owner}, {repo}, {pr_num}, {title}, {body}, {branch}
107
+ _DEFAULT_PR_TEMPLATE = (
108
+ 'You are working on PR #{pr_num}: "{title}" in {owner}/{repo}'
109
+ " on branch `{branch}`.\n"
110
+ "\n"
111
+ "PR description:\n"
112
+ "{body}\n"
113
+ "\n"
114
+ "Please:\n"
115
+ "1. Check the CI status for this PR using the GitHub API.\n"
116
+ "2. Build a numbered table of all PR comments (both review-level and"
117
+ " inline) with columns for: comment number, author, a summary of the"
118
+ " comment, and whether it has a response yet.\n"
119
+ "\n"
120
+ "This gives an overview of where things stand with this PR."
121
+ )
122
+
123
+
124
+ def _load_template(kind: str) -> str | None:
125
+ """Load a custom template from ~/.bubble/templates/<kind>.txt.
126
+
127
+ Returns the template string, or None if no custom template exists.
128
+ Falls back gracefully on permission errors or encoding issues.
129
+ """
130
+ path = TEMPLATES_DIR / f"{kind}.txt"
131
+ if path.is_file():
132
+ try:
133
+ return path.read_text()
134
+ except (OSError, UnicodeError):
135
+ from .output import detail
136
+
137
+ detail(f"Warning: could not read template {path}, using default.", err=True)
138
+ return None
139
+
140
+
141
+ # Matches simple {name} placeholders — no attribute access, indexing, or format specs.
142
+ _PLACEHOLDER_RE = re.compile(r"\{(\w+)\}")
143
+
144
+
145
+ def _render_template(template: str, **kwargs) -> str:
146
+ """Render a template by substituting simple {name} placeholders.
147
+
148
+ Only plain identifiers are replaced (e.g. {title}, {pr_num}).
149
+ Attribute access ({x.y}), indexing ({x[0]}), and format specs ({x:>10})
150
+ are left untouched. Unknown placeholders are also left as-is.
151
+ """
152
+
153
+ def _replace(m: re.Match) -> str:
154
+ key = m.group(1)
155
+ if key in kwargs:
156
+ return str(kwargs[key])
157
+ return m.group(0) # leave unknown placeholders as-is
158
+
159
+ return _PLACEHOLDER_RE.sub(_replace, template)
160
+
161
+
162
+ def _fetch_github_item(owner: str, repo: str, endpoint: str, jq: str) -> str | None:
163
+ """Fetch a GitHub API endpoint via gh CLI, returning stdout or None on failure."""
164
+ try:
165
+ result = subprocess.run(
166
+ ["gh", "api", f"repos/{owner}/{repo}/{endpoint}", "--jq", jq],
167
+ capture_output=True,
168
+ text=True,
169
+ timeout=15,
170
+ )
171
+ if result.returncode != 0:
172
+ return None
173
+ return result.stdout
174
+ except (FileNotFoundError, subprocess.TimeoutExpired):
175
+ return None
176
+
177
+
178
+ def _resolve_second_opinion(second_opinion: str, config: dict | None = None) -> bool:
179
+ """Resolve the second_opinion setting to a boolean.
180
+
181
+ 'on' → True, 'off' → False, 'auto' → True if codex will be
182
+ available in the container (checked via tool resolution, not host PATH).
183
+ """
184
+ if second_opinion == "on":
185
+ return True
186
+ if second_opinion == "off":
187
+ return False
188
+ # auto: check if codex is resolved as an enabled tool
189
+ if config is not None:
190
+ from .tools import resolve_tools
191
+
192
+ return "codex" in resolve_tools(config)
193
+ # Fallback when no config available: check host PATH
194
+ import shutil
195
+
196
+ return shutil.which("codex") is not None
197
+
198
+
199
+ def generate_issue_prompt(
200
+ owner: str,
201
+ repo: str,
202
+ issue_num: str,
203
+ branch: str,
204
+ autonomy: str = "plan",
205
+ second_opinion: str = "auto",
206
+ config: dict | None = None,
207
+ ) -> str | None:
208
+ """Fetch GitHub issue details and generate an AI prompt.
209
+
210
+ Returns the prompt string, or None if the issue can't be fetched.
211
+ Uses a custom template from ~/.bubble/templates/issue.txt if present,
212
+ otherwise falls back to the built-in default.
213
+
214
+ The autonomy level controls what action instructions are included:
215
+ read, plan, implement, pr, merge.
216
+ """
217
+ raw = _fetch_github_item(owner, repo, f"issues/{issue_num}", ".title,.body")
218
+ if raw is None:
219
+ return None
220
+ lines = raw.split("\n", 1)
221
+ title = lines[0] if lines else ""
222
+ body = lines[1].strip() if len(lines) > 1 else ""
223
+
224
+ # Fetch comments (first 4000 chars)
225
+ comments_text = ""
226
+ raw_comments = _fetch_github_item(owner, repo, f"issues/{issue_num}/comments", ".[].body")
227
+ if raw_comments and raw_comments.strip():
228
+ comments_text = raw_comments.strip()[:4000]
229
+
230
+ comments_section = ""
231
+ if comments_text:
232
+ comments_section = f"\nComments:\n{comments_text}\n"
233
+
234
+ # Build instructions from autonomy level
235
+ if autonomy not in AUTONOMY_LEVELS:
236
+ autonomy = "plan"
237
+ instructions = _ISSUE_INSTRUCTIONS[autonomy]
238
+ # The instructions may contain {branch} placeholder
239
+ instructions = instructions.format(branch=branch)
240
+
241
+ # Append second opinion request if enabled
242
+ if _resolve_second_opinion(second_opinion, config=config):
243
+ instructions += _SECOND_OPINION_SUFFIX
244
+
245
+ custom_template = _load_template("issue")
246
+ template = custom_template or _DEFAULT_ISSUE_TEMPLATE
247
+
248
+ prompt = _render_template(
249
+ template,
250
+ owner=owner,
251
+ repo=repo,
252
+ issue_num=issue_num,
253
+ title=title,
254
+ body=body,
255
+ comments=comments_text,
256
+ comments_section=comments_section,
257
+ branch=branch,
258
+ instructions=instructions,
259
+ )
260
+
261
+ # If a custom template didn't use {instructions}, append them so
262
+ # autonomy/second-opinion settings are never silently lost.
263
+ if custom_template and "{instructions}" not in custom_template:
264
+ prompt = prompt.rstrip() + "\n\n" + instructions
265
+
266
+ return prompt
267
+
268
+
269
+ def generate_pr_prompt(owner: str, repo: str, pr_num: str, branch: str) -> str | None:
270
+ """Fetch GitHub PR details and generate an AI prompt.
271
+
272
+ Returns the prompt string, or None if the PR can't be fetched.
273
+ Uses a custom template from ~/.bubble/templates/pr.txt if present,
274
+ otherwise falls back to the built-in default.
275
+ """
276
+ raw = _fetch_github_item(owner, repo, f"pulls/{pr_num}", ".title,.body")
277
+ if raw is None:
278
+ return None
279
+ lines = raw.split("\n", 1)
280
+ title = lines[0] if lines else ""
281
+ body = lines[1].strip() if len(lines) > 1 else ""
282
+
283
+ template = _load_template("pr") or _DEFAULT_PR_TEMPLATE
284
+ return _render_template(
285
+ template,
286
+ owner=owner,
287
+ repo=repo,
288
+ pr_num=pr_num,
289
+ title=title,
290
+ body=body,
291
+ branch=branch,
292
+ )
293
+
294
+
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,
364
+ ):
365
+ """Inject AI auto-start task into a container's VS Code configuration.
366
+
367
+ Dispatches to the configured preferred AI provider (default: Claude).
368
+
369
+ - Writes prompt to .vscode/ai-prompt.txt
370
+ - Creates/updates .vscode/tasks.json with AI task (runOn: folderOpen)
371
+ - Configures .vscode/settings.json for automatic tasks
372
+ - Adds generated files to git exclude
373
+ - Pre-trusts the project directory in the preferred provider's config
374
+ """
375
+ provider = "claude"
376
+ if config:
377
+ provider = config.get("ai", {}).get("preferred", "claude")
378
+
379
+ task_command = _task_command_for(provider)
380
+
381
+ q_dir = shlex.quote(project_dir)
382
+ q_prompt = shlex.quote(prompt)
383
+
384
+ # Create .vscode directory
385
+ runtime.exec(container, ["su", "-", "user", "-c", f"mkdir -p {q_dir}/.vscode"])
386
+
387
+ # Write prompt to file
388
+ runtime.exec(
389
+ container,
390
+ ["su", "-", "user", "-c", f"printf '%s' {q_prompt} > {q_dir}/.vscode/ai-prompt.txt"],
391
+ )
392
+
393
+ # Create or update tasks.json with AI task
394
+ ai_task = {
395
+ "label": "AI",
396
+ "type": "shell",
397
+ "command": task_command,
398
+ "runOptions": {"runOn": "folderOpen"},
399
+ "presentation": {"reveal": "always", "panel": "dedicated"},
400
+ }
401
+ tasks_json_str = shlex.quote(json.dumps(ai_task))
402
+
403
+ # Script: if tasks.json exists, add AI task (removing old Claude and AI labels);
404
+ # otherwise create new file
405
+ script = (
406
+ f"cd {q_dir} && "
407
+ f"if [ -f .vscode/tasks.json ]; then "
408
+ f' python3 -c "'
409
+ f"import json,sys; "
410
+ f"t=json.load(open('.vscode/tasks.json')); "
411
+ f"t['tasks']=[x for x in t.get('tasks',[])"
412
+ f" if x.get('label') not in ('Claude','AI')]+[json.loads(sys.argv[1])]; "
413
+ f"json.dump(t,open('.vscode/tasks.json','w'),indent=2)"
414
+ f'" {tasks_json_str}; '
415
+ f"else "
416
+ f' python3 -c "'
417
+ f"import json,sys; "
418
+ f"json.dump({{'version':'2.0.0','tasks':[json.loads(sys.argv[1])]}},open('.vscode/tasks.json','w'),indent=2)"
419
+ f'" {tasks_json_str}; '
420
+ f"fi"
421
+ )
422
+ runtime.exec(container, ["su", "-", "user", "-c", script])
423
+
424
+ # Configure settings.json for automatic tasks
425
+ settings_script = (
426
+ f"cd {q_dir} && "
427
+ f'python3 -c "'
428
+ f"import json,os; "
429
+ f"p='.vscode/settings.json'; "
430
+ f"s=json.load(open(p)) if os.path.exists(p) else {{}}; "
431
+ f"s['terminal.integrated.defaultLocation']='editor'; "
432
+ f"s['task.allowAutomaticTasks']='on'; "
433
+ f"json.dump(s,open(p,'w'),indent=2)"
434
+ f'"'
435
+ )
436
+ runtime.exec(container, ["su", "-", "user", "-c", settings_script])
437
+
438
+ # Add generated files to git exclude
439
+ exclude_script = (
440
+ f"cd {q_dir} && "
441
+ f"GIT_DIR=$(git rev-parse --git-dir) && "
442
+ f"mkdir -p $GIT_DIR/info && "
443
+ f"for f in .vscode/ai-prompt.txt .vscode/claude-prompt.txt"
444
+ f" .vscode/settings.json .vscode/tasks.json; do "
445
+ f' grep -qxF "$f" $GIT_DIR/info/exclude 2>/dev/null'
446
+ f' || echo "$f" >> $GIT_DIR/info/exclude; '
447
+ f"done"
448
+ )
449
+ runtime.exec(container, ["su", "-", "user", "-c", exclude_script])
450
+
451
+ if not quiet:
452
+ from .output import detail
453
+
454
+ label = provider.capitalize()
455
+ detail(f"{label} task injected (will start on VS Code folder open).")