brindle 0.3.18__tar.gz → 0.3.19__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.
- brindle-0.3.18/README.md → brindle-0.3.19/PKG-INFO +58 -1
- brindle-0.3.18/PKG-INFO → brindle-0.3.19/README.md +27 -32
- {brindle-0.3.18 → brindle-0.3.19}/pyproject.toml +1 -1
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/cli.py +104 -4
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/config.py +13 -0
- brindle-0.3.19/src/brindle/key_check.py +66 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/keys_cmds.py +18 -1
- brindle-0.3.19/src/brindle/local_setup.py +259 -0
- brindle-0.3.19/src/brindle/picker.py +284 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/pro/team_policy.py +86 -0
- brindle-0.3.19/src/brindle/worker_models.py +176 -0
- {brindle-0.3.18 → brindle-0.3.19}/.gitignore +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/LICENSE +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/SCHEDULE-A +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/__init__.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/__main__.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/account.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/agents.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/airgap.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/antigravity.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/autopilot.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/budget.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/builtin_agents/__init__.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/builtin_agents/backend-auditor.md +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/builtin_agents/developer-codex.md +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/builtin_agents/developer-fable.md +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/builtin_agents/developer-gemini.md +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/builtin_agents/developer-heavy.md +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/builtin_agents/developer-light.md +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/builtin_agents/developer-local.md +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/builtin_agents/developer.md +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/builtin_agents/qa.md +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/builtin_agents/reviewer-codex.md +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/builtin_agents/reviewer-gemini.md +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/builtin_agents/reviewer-local.md +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/builtin_agents/reviewer.md +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/builtin_agents/subagent.md +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/builtin_agents/supervisor.md +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/builtin_rules/__init__.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/builtin_rules/security/backend.md +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/builtin_rules/style/minimal-diff.md +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/builtin_rules/tests/only.md +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/ci_adapters.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/ci_client.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/ci_federation.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/ci_hosts.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/cloud_credential.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/cloud_login.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/codemap.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/codemap_docs.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/codex_hook.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/company_identity.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/company_login.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/conflicts.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/cost.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/cost_estimate.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/cull.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/db.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/demo.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/detect.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/doctor.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/escapes.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/events.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/gates.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/git.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/guardrails.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/history.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/identity_lock.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/inbox.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/keystore.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/launch_health.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/learned_rules.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/learning.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/legacy.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/login_pause.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/mcp_server.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/model_access.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/model_availability.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/native/__init__.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/native/client.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/native/loop.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/native/permissions.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/native/runner.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/native/serve.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/native/tools.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/pane_auth.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/permissions.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/pipeline.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/plugins.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/policy.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/pool.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/pricing.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/pro/__init__.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/pro/_files.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/pro/account.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/pro/audit_chain.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/pro/audit_export.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/pro/auth.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/pro/brief_learning.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/pro/cost_centers.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/pro/credentials.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/pro/features.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/pro/keys.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/pro/learning.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/pro/license.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/pro/loopback.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/pro/managed_models.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/pro/org_profiles.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/pro/orgkey.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/pro/rollout.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/pro/route_health.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/pro/settings_sync.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/pro/status.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/pro/team_events.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/procs.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/profiles.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/prompt_answer.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/providers.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/quota.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/repo_cmds.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/repos.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/rewind.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/rule_checks.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/savings.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/scratch.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/secrets.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/self_serve.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/services.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/sessions.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/sidebar_follow.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/signin_pause.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/status_cache.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/tasks.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/tmux.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/updates.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/upgrade.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/usage.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/view.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/watch.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/watch_costs.py +0 -0
- {brindle-0.3.18 → brindle-0.3.19}/src/brindle/workspaces.py +0 -0
|
@@ -1,3 +1,34 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: brindle
|
|
3
|
+
Version: 0.3.19
|
|
4
|
+
Summary: Run coding agents in parallel, each on its own git branch, with a supervisor that delegates, reviews, and merges
|
|
5
|
+
Project-URL: Homepage, https://pawdelta.com/brindle/
|
|
6
|
+
Project-URL: Documentation, https://pawdelta.com/brindle/docs/recommended-use
|
|
7
|
+
Project-URL: Changelog, https://pawdelta.com/brindle/changelog
|
|
8
|
+
Project-URL: Support, https://pawdelta.com/brindle/docs/faq
|
|
9
|
+
Author: Hannah Lerner
|
|
10
|
+
License-Expression: LicenseRef-brindle-1.0
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
License-File: SCHEDULE-A
|
|
13
|
+
Keywords: agents,ai,claude-code,codex,git,mcp,orchestration,tmux,worktree
|
|
14
|
+
Classifier: Development Status :: 3 - Alpha
|
|
15
|
+
Classifier: Environment :: Console
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: Operating System :: MacOS
|
|
18
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
19
|
+
Classifier: Programming Language :: Python :: 3
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Topic :: Software Development
|
|
24
|
+
Classifier: Topic :: Software Development :: Version Control :: Git
|
|
25
|
+
Requires-Python: >=3.11
|
|
26
|
+
Requires-Dist: cryptography>=42
|
|
27
|
+
Requires-Dist: mcp<3,>=2.2
|
|
28
|
+
Requires-Dist: packaging>=24
|
|
29
|
+
Requires-Dist: typer>=0.12
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
1
32
|
# brindle
|
|
2
33
|
|
|
3
34
|
*[pawdelta.com/brindle](https://pawdelta.com/brindle/) · Published on PyPI as `brindle`; the command is `brindle`. This project is
|
|
@@ -63,7 +94,7 @@ uv tool install --editable ~/Projects/brindle # or from a local checkout
|
|
|
63
94
|
brindle drives Claude Code, so you need that too (`npm install -g @anthropic-ai/claude-code`).
|
|
64
95
|
|
|
65
96
|
`brindle --version` prints the installed version; the tmux status bar of every brindle
|
|
66
|
-
session shows it too (`brindle 0.3.
|
|
97
|
+
session shows it too (`brindle 0.3.19`). A session started before an upgrade keeps
|
|
67
98
|
running the old code, and shows the old number, until you restart it.
|
|
68
99
|
|
|
69
100
|
## Quick start
|
|
@@ -184,6 +215,31 @@ brindle pr fix-login # push + gh pr create
|
|
|
184
215
|
brindle rm fix-login # deletes the branch only if it's merged (-K keeps it)
|
|
185
216
|
```
|
|
186
217
|
|
|
218
|
+
### Choosing the supervisor and workers
|
|
219
|
+
|
|
220
|
+
The first time you run `brindle` it asks which CLI runs the supervisor (the
|
|
221
|
+
agent that plans the work and hands it out), and remembers your pick. After
|
|
222
|
+
that it starts straight away and prints one line naming the CLI. It asks again
|
|
223
|
+
only when your pick is no longer signed in or no longer allowed.
|
|
224
|
+
|
|
225
|
+
- `brindle -s` opens the picker; `brindle -s codex` switches directly. Either
|
|
226
|
+
is remembered.
|
|
227
|
+
- In the picker, `w` opens the workers screen: a model for each task size
|
|
228
|
+
(light, medium, heavy). Models are grouped by how they run: through a CLI
|
|
229
|
+
you sign in to, locally with Ollama, or hosted with an API key. Picking one
|
|
230
|
+
that isn't ready sets it up right there: signing in, installing Ollama and
|
|
231
|
+
downloading the model after a memory check, or asking for the API key.
|
|
232
|
+
- API keys are checked with the provider before they're stored, and stay in
|
|
233
|
+
this machine's keychain.
|
|
234
|
+
- The picker and the workers screen list only the CLIs and models you're
|
|
235
|
+
allowed to use. In an org that means its allowed providers and models for
|
|
236
|
+
your role, its managed routes, and whether personal API keys are allowed.
|
|
237
|
+
`-s` with anything else is refused, and saved picks are re-checked at every
|
|
238
|
+
launch.
|
|
239
|
+
- Without a terminal (scripts, CI), with `--yes`, or in `brindle demo`, nothing
|
|
240
|
+
asks: brindle uses your last pick, else the supervisor profile's CLI (Claude
|
|
241
|
+
Code unless you changed it).
|
|
242
|
+
|
|
187
243
|
## Recommended use
|
|
188
244
|
|
|
189
245
|
brindle pays off when work splits into pieces that can proceed in parallel, or
|
|
@@ -312,6 +368,7 @@ your own status line prints, so what you see doesn't change.
|
|
|
312
368
|
| | |
|
|
313
369
|
|---|---|
|
|
314
370
|
| `brindle` | a fresh supervisor chat here, dashboard alongside |
|
|
371
|
+
| `brindle -s [CLI]` | pick the supervisor's CLI (no value: open the picker; `w` there picks worker models); remembered for next time |
|
|
315
372
|
| `brindle supervisor` | the same, with `--branch`/`--worktree` to run it in another worktree |
|
|
316
373
|
| `brindle init` | detect setup and test commands, write `.brindle/config.json`, check tools |
|
|
317
374
|
| `brindle demo [--local]` | watch brindle finish a tiny practice repo: parallel workers, reviews, gated merges |
|
|
@@ -1,34 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: brindle
|
|
3
|
-
Version: 0.3.18
|
|
4
|
-
Summary: Run coding agents in parallel, each on its own git branch, with a supervisor that delegates, reviews, and merges
|
|
5
|
-
Project-URL: Homepage, https://pawdelta.com/brindle/
|
|
6
|
-
Project-URL: Documentation, https://pawdelta.com/brindle/docs/recommended-use
|
|
7
|
-
Project-URL: Changelog, https://pawdelta.com/brindle/changelog
|
|
8
|
-
Project-URL: Support, https://pawdelta.com/brindle/docs/faq
|
|
9
|
-
Author: Hannah Lerner
|
|
10
|
-
License-Expression: LicenseRef-brindle-1.0
|
|
11
|
-
License-File: LICENSE
|
|
12
|
-
License-File: SCHEDULE-A
|
|
13
|
-
Keywords: agents,ai,claude-code,codex,git,mcp,orchestration,tmux,worktree
|
|
14
|
-
Classifier: Development Status :: 3 - Alpha
|
|
15
|
-
Classifier: Environment :: Console
|
|
16
|
-
Classifier: Intended Audience :: Developers
|
|
17
|
-
Classifier: Operating System :: MacOS
|
|
18
|
-
Classifier: Operating System :: POSIX :: Linux
|
|
19
|
-
Classifier: Programming Language :: Python :: 3
|
|
20
|
-
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
-
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
-
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
-
Classifier: Topic :: Software Development
|
|
24
|
-
Classifier: Topic :: Software Development :: Version Control :: Git
|
|
25
|
-
Requires-Python: >=3.11
|
|
26
|
-
Requires-Dist: cryptography>=42
|
|
27
|
-
Requires-Dist: mcp<3,>=2.2
|
|
28
|
-
Requires-Dist: packaging>=24
|
|
29
|
-
Requires-Dist: typer>=0.12
|
|
30
|
-
Description-Content-Type: text/markdown
|
|
31
|
-
|
|
32
1
|
# brindle
|
|
33
2
|
|
|
34
3
|
*[pawdelta.com/brindle](https://pawdelta.com/brindle/) · Published on PyPI as `brindle`; the command is `brindle`. This project is
|
|
@@ -94,7 +63,7 @@ uv tool install --editable ~/Projects/brindle # or from a local checkout
|
|
|
94
63
|
brindle drives Claude Code, so you need that too (`npm install -g @anthropic-ai/claude-code`).
|
|
95
64
|
|
|
96
65
|
`brindle --version` prints the installed version; the tmux status bar of every brindle
|
|
97
|
-
session shows it too (`brindle 0.3.
|
|
66
|
+
session shows it too (`brindle 0.3.19`). A session started before an upgrade keeps
|
|
98
67
|
running the old code, and shows the old number, until you restart it.
|
|
99
68
|
|
|
100
69
|
## Quick start
|
|
@@ -215,6 +184,31 @@ brindle pr fix-login # push + gh pr create
|
|
|
215
184
|
brindle rm fix-login # deletes the branch only if it's merged (-K keeps it)
|
|
216
185
|
```
|
|
217
186
|
|
|
187
|
+
### Choosing the supervisor and workers
|
|
188
|
+
|
|
189
|
+
The first time you run `brindle` it asks which CLI runs the supervisor (the
|
|
190
|
+
agent that plans the work and hands it out), and remembers your pick. After
|
|
191
|
+
that it starts straight away and prints one line naming the CLI. It asks again
|
|
192
|
+
only when your pick is no longer signed in or no longer allowed.
|
|
193
|
+
|
|
194
|
+
- `brindle -s` opens the picker; `brindle -s codex` switches directly. Either
|
|
195
|
+
is remembered.
|
|
196
|
+
- In the picker, `w` opens the workers screen: a model for each task size
|
|
197
|
+
(light, medium, heavy). Models are grouped by how they run: through a CLI
|
|
198
|
+
you sign in to, locally with Ollama, or hosted with an API key. Picking one
|
|
199
|
+
that isn't ready sets it up right there: signing in, installing Ollama and
|
|
200
|
+
downloading the model after a memory check, or asking for the API key.
|
|
201
|
+
- API keys are checked with the provider before they're stored, and stay in
|
|
202
|
+
this machine's keychain.
|
|
203
|
+
- The picker and the workers screen list only the CLIs and models you're
|
|
204
|
+
allowed to use. In an org that means its allowed providers and models for
|
|
205
|
+
your role, its managed routes, and whether personal API keys are allowed.
|
|
206
|
+
`-s` with anything else is refused, and saved picks are re-checked at every
|
|
207
|
+
launch.
|
|
208
|
+
- Without a terminal (scripts, CI), with `--yes`, or in `brindle demo`, nothing
|
|
209
|
+
asks: brindle uses your last pick, else the supervisor profile's CLI (Claude
|
|
210
|
+
Code unless you changed it).
|
|
211
|
+
|
|
218
212
|
## Recommended use
|
|
219
213
|
|
|
220
214
|
brindle pays off when work splits into pieces that can proceed in parallel, or
|
|
@@ -343,6 +337,7 @@ your own status line prints, so what you see doesn't change.
|
|
|
343
337
|
| | |
|
|
344
338
|
|---|---|
|
|
345
339
|
| `brindle` | a fresh supervisor chat here, dashboard alongside |
|
|
340
|
+
| `brindle -s [CLI]` | pick the supervisor's CLI (no value: open the picker; `w` there picks worker models); remembered for next time |
|
|
346
341
|
| `brindle supervisor` | the same, with `--branch`/`--worktree` to run it in another worktree |
|
|
347
342
|
| `brindle init` | detect setup and test commands, write `.brindle/config.json`, check tools |
|
|
348
343
|
| `brindle demo [--local]` | watch brindle finish a tiny practice repo: parallel workers, reviews, gated merges |
|
|
@@ -34,7 +34,34 @@ class _App(typer.Typer):
|
|
|
34
34
|
raise SystemExit(1)
|
|
35
35
|
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
SUPERVISOR_FLAGS = ("-s", "--supervisor")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _bare_supervisor_flag(args: list[str], commands) -> list[str]:
|
|
41
|
+
"""``args`` with picker.PICK after each ``-s``/``--supervisor`` that has
|
|
42
|
+
no CLI after it (it's last, or an option or a command follows), so a bare
|
|
43
|
+
``brindle -s`` opens the picker: a Typer option can't take an optional
|
|
44
|
+
value."""
|
|
45
|
+
from brindle.picker import PICK
|
|
46
|
+
|
|
47
|
+
out = []
|
|
48
|
+
for i, a in enumerate(args):
|
|
49
|
+
out.append(a)
|
|
50
|
+
if a == "--":
|
|
51
|
+
return out + list(args[i + 1:])
|
|
52
|
+
if a in SUPERVISOR_FLAGS:
|
|
53
|
+
nxt = args[i + 1] if i + 1 < len(args) else None
|
|
54
|
+
if nxt is None or nxt.startswith("-") or nxt in commands:
|
|
55
|
+
out.append(PICK)
|
|
56
|
+
return out
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class _PickerGroup(typer.core.TyperGroup):
|
|
60
|
+
def parse_args(self, ctx, args):
|
|
61
|
+
return super().parse_args(ctx, _bare_supervisor_flag(args, self.commands))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
app = _App(cls=_PickerGroup, add_completion=False, help="""brindle: run coding agents in parallel, each on its own git branch.
|
|
38
65
|
|
|
39
66
|
Run `brindle` with no arguments to open (or reopen) a supervisor chat here, with
|
|
40
67
|
the live dashboard underneath. Outside a git repo it starts a scratch session;
|
|
@@ -177,7 +204,7 @@ def demo(
|
|
|
177
204
|
typer.echo(f"demo repo: {root}")
|
|
178
205
|
os.chdir(root)
|
|
179
206
|
start(prompt=None, provider=None, attach=attach, watch=True,
|
|
180
|
-
autopilot=True, branch=None, worktree=None)
|
|
207
|
+
autopilot=True, branch=None, worktree=None, demo=True)
|
|
181
208
|
|
|
182
209
|
|
|
183
210
|
@app.command()
|
|
@@ -193,6 +220,7 @@ def new(
|
|
|
193
220
|
no_fetch: bool = typer.Option(False, "--no-fetch", help="Don't fetch the base branch first."),
|
|
194
221
|
no_setup: bool = typer.Option(False, "--no-setup", help="Skip setup commands."),
|
|
195
222
|
attach: bool = typer.Option(False, "--attach", help="Attach to the tmux session afterward."),
|
|
223
|
+
yes: Annotated[bool, typer.Option("--yes", "-y", help="Don't open the supervisor picker: use your last pick, else the default.")] = False,
|
|
196
224
|
provider_alias: Annotated[Optional[str], typer.Option("--provider", hidden=True)] = None,
|
|
197
225
|
) -> None:
|
|
198
226
|
"""Create a worktree on a new branch and start a supervisor in it."""
|
|
@@ -209,6 +237,12 @@ def new(
|
|
|
209
237
|
_fail("--base can't be combined with --pr (the PR's base branch is used)")
|
|
210
238
|
elif not branch:
|
|
211
239
|
_fail("missing BRANCH (or pass --pr <number>)")
|
|
240
|
+
if not no_agent:
|
|
241
|
+
try:
|
|
242
|
+
root = git.main_repo_root(os.getcwd())
|
|
243
|
+
except git.GitError:
|
|
244
|
+
root = None
|
|
245
|
+
provider = _resolve_supervisor(root, provider, yes=yes)
|
|
212
246
|
db = DB()
|
|
213
247
|
if pr is not None:
|
|
214
248
|
created = _run(workspaces.create_from_pr, db, os.getcwd(), pr, run_setup=not no_setup)
|
|
@@ -258,7 +292,9 @@ def start(
|
|
|
258
292
|
autopilot: Optional[bool] = typer.Option(None, "--autopilot/--no-autopilot", help="The supervisor drives toward a goal until it's verified (default: on, or `autopilot` in .brindle/config.json)."),
|
|
259
293
|
branch: Optional[str] = typer.Option(None, "--branch", "-b", help="Run in the worktree for this branch, creating the branch and worktree if needed."),
|
|
260
294
|
worktree: Optional[str] = typer.Option(None, "--worktree", "-w", help="Run in the worktree at this path (created with --branch if it doesn't exist)."),
|
|
295
|
+
yes: Annotated[bool, typer.Option("--yes", "-y", help="Don't open the supervisor picker: use your last pick, else the default.")] = False,
|
|
261
296
|
provider_alias: Annotated[Optional[str], typer.Option("--provider", hidden=True)] = None,
|
|
297
|
+
demo: Annotated[bool, typer.Option("--demo-run", hidden=True)] = False,
|
|
262
298
|
) -> None:
|
|
263
299
|
"""Start a fresh supervisor chat here, with the dashboard alongside. A launch only ever starts a supervisor; it starts the other agents itself.
|
|
264
300
|
|
|
@@ -312,6 +348,7 @@ def start(
|
|
|
312
348
|
if provider and provider in NOT_SUPERVISOR:
|
|
313
349
|
_fail(f"the {provider} provider can't run the supervisor; use one of: "
|
|
314
350
|
f"{', '.join(SUPERVISOR_PROVIDERS)}")
|
|
351
|
+
provider = _resolve_supervisor(ws.repo_root, provider, yes=yes, demo=demo)
|
|
315
352
|
_preflight(agents.SUPERVISOR, provider, ws.repo_root)
|
|
316
353
|
_trust_codex_hook(ws.repo_root)
|
|
317
354
|
# Nothing slow before the chat starts: the paused session's leftover
|
|
@@ -366,6 +403,67 @@ def _preflight(agent: str, provider: Optional[str], repo_root: str) -> None:
|
|
|
366
403
|
_fail("brindle can't start yet. `brindle doctor` checks everything else.")
|
|
367
404
|
|
|
368
405
|
|
|
406
|
+
def _is_ci_env() -> bool:
|
|
407
|
+
for key in ("CI", "GITHUB_ACTIONS", "GITLAB_CI", "BUILDKITE", "CIRCLECI", "TF_BUILD", "JENKINS_URL", "BRINDLE_CI"):
|
|
408
|
+
val = (os.environ.get(key) or "").strip().lower()
|
|
409
|
+
if val and val not in ("0", "false", "no", "off"):
|
|
410
|
+
return True
|
|
411
|
+
return False
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _can_prompt(yes: bool = False, demo: bool = False) -> bool:
|
|
415
|
+
return bool(sys.stdin.isatty() and yes is not True and demo is not True and not _is_ci_env())
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _resolve_supervisor(repo_root: str | None, provider: Optional[str], *,
|
|
419
|
+
yes: bool = False, demo: bool = False) -> Optional[str]:
|
|
420
|
+
"""The CLI the supervisor runs on (None: its profile's). ``-s CLI`` picks
|
|
421
|
+
it and is remembered; a bare ``-s`` opens the picker. Otherwise the
|
|
422
|
+
person's last pick, asked again in the picker only on the first launch or
|
|
423
|
+
when that pick isn't signed in or allowed, and only where brindle may ask.
|
|
424
|
+
A CLI the org doesn't allow this person is refused, whichever way it came."""
|
|
425
|
+
from brindle import doctor as doctor_mod, picker
|
|
426
|
+
from brindle.config import SUPERVISOR_KEY, set_user, supervisor_pick
|
|
427
|
+
from brindle.pro import team_policy
|
|
428
|
+
|
|
429
|
+
acc = team_policy.access(repo_root)
|
|
430
|
+
pick = supervisor_pick()
|
|
431
|
+
|
|
432
|
+
def ask() -> str:
|
|
433
|
+
chosen = picker.pick_supervisor(acc, pick)
|
|
434
|
+
if chosen is None:
|
|
435
|
+
typer.echo("No supervisor picked; nothing was started.")
|
|
436
|
+
raise typer.Exit(1)
|
|
437
|
+
return chosen
|
|
438
|
+
|
|
439
|
+
if provider == picker.PICK:
|
|
440
|
+
if not _can_prompt(yes, demo):
|
|
441
|
+
_fail("`brindle -s` on its own opens the supervisor picker, which needs a terminal; "
|
|
442
|
+
"name the CLI instead, e.g. `brindle -s codex`")
|
|
443
|
+
provider = ask()
|
|
444
|
+
elif provider:
|
|
445
|
+
why = acc.refusal(provider)
|
|
446
|
+
if why:
|
|
447
|
+
_fail(f"can't run the supervisor on {provider}: {why}")
|
|
448
|
+
if _can_prompt(yes, demo) and provider not in doctor_mod.signed_in_providers():
|
|
449
|
+
if not picker.sign_in(provider):
|
|
450
|
+
raise typer.Exit(1)
|
|
451
|
+
elif _can_prompt(yes, demo) and not (pick and acc.allows(pick)
|
|
452
|
+
and pick in doctor_mod.signed_in_providers()):
|
|
453
|
+
provider = ask()
|
|
454
|
+
elif pick and acc.allows(pick):
|
|
455
|
+
provider = pick
|
|
456
|
+
final = provider or _run(load_profile, agents.SUPERVISOR, repo_root).provider
|
|
457
|
+
why = acc.refusal(final)
|
|
458
|
+
if why:
|
|
459
|
+
_fail(f"can't run the supervisor on {final}: {why}. Pick another with `brindle -s`.")
|
|
460
|
+
if provider:
|
|
461
|
+
if provider != pick:
|
|
462
|
+
set_user(SUPERVISOR_KEY, provider)
|
|
463
|
+
typer.echo(f"Supervisor: {provider} (your pick) · brindle -s to change")
|
|
464
|
+
return provider
|
|
465
|
+
|
|
466
|
+
|
|
369
467
|
def _trust_codex_hook(repo_root: str) -> None:
|
|
370
468
|
"""With the permission policy on and Codex installed, trust brindle's Codex
|
|
371
469
|
hook if it isn't yet (or brindle moved), and say so in one line, so Codex
|
|
@@ -761,6 +859,7 @@ def default(
|
|
|
761
859
|
None, "--supervisor", "-s", help="Run the supervisor on this CLI instead of Claude Code (codex, antigravity). Workers keep their own profiles' providers.",
|
|
762
860
|
),
|
|
763
861
|
show_version: bool = typer.Option(False, "--version", help="Print brindle's version and exit."),
|
|
862
|
+
yes: Annotated[bool, typer.Option("--yes", "-y", help="Don't open the supervisor picker: use your last pick, else the default.")] = False,
|
|
764
863
|
provider_alias: Annotated[Optional[str], typer.Option("--provider", hidden=True)] = None,
|
|
765
864
|
) -> None:
|
|
766
865
|
"""Bare `brindle`: a fresh supervisor chat here (a scratch session outside git)."""
|
|
@@ -782,7 +881,7 @@ def default(
|
|
|
782
881
|
continue_cmd(session_id=None, attach=True)
|
|
783
882
|
else:
|
|
784
883
|
start(prompt=None, provider=provider, attach=True, watch=True,
|
|
785
|
-
autopilot=autopilot, branch=None, worktree=None)
|
|
884
|
+
autopilot=autopilot, branch=None, worktree=None, yes=yes)
|
|
786
885
|
|
|
787
886
|
|
|
788
887
|
@app.command()
|
|
@@ -796,12 +895,13 @@ def supervisor(
|
|
|
796
895
|
autopilot: Optional[bool] = typer.Option(None, "--autopilot/--no-autopilot", help="The supervisor drives toward a goal until it's verified (default: on, or `autopilot` in .brindle/config.json)."),
|
|
797
896
|
branch: Optional[str] = typer.Option(None, "--branch", "-b", help="Run in the worktree for this branch, creating the branch and worktree if needed."),
|
|
798
897
|
worktree: Optional[str] = typer.Option(None, "--worktree", "-w", help="Run in the worktree at this path (created with --branch if it doesn't exist)."),
|
|
898
|
+
yes: Annotated[bool, typer.Option("--yes", "-y", help="Don't open the supervisor picker: use your last pick, else the default.")] = False,
|
|
799
899
|
provider_alias: Annotated[Optional[str], typer.Option("--provider", hidden=True)] = None,
|
|
800
900
|
) -> None:
|
|
801
901
|
"""Start an explicit supervisor session to coordinate coding agents."""
|
|
802
902
|
provider = provider or provider_alias
|
|
803
903
|
start(prompt=prompt, provider=provider, attach=attach, watch=watch,
|
|
804
|
-
autopilot=autopilot, branch=branch, worktree=worktree)
|
|
904
|
+
autopilot=autopilot, branch=branch, worktree=worktree, yes=yes)
|
|
805
905
|
|
|
806
906
|
|
|
807
907
|
def _session_root(db: DB) -> str:
|
|
@@ -267,6 +267,9 @@ def load_repo_config(repo_root: str | Path) -> RepoConfig:
|
|
|
267
267
|
if norm_tier in WEIGHTS and isinstance(names, list):
|
|
268
268
|
cfg.routing[norm_tier] = [n for n in names if isinstance(n, str)]
|
|
269
269
|
cfg.routing_sources[norm_tier] = str(path)
|
|
270
|
+
from brindle import worker_models
|
|
271
|
+
|
|
272
|
+
worker_models.apply(cfg, user, str(repo_root)) # the models picked on the picker's workers screen
|
|
270
273
|
for source in (shared, local): # per weight, so a repo can override one and keep the rest
|
|
271
274
|
second = source.get("second_review")
|
|
272
275
|
if isinstance(second, dict):
|
|
@@ -311,6 +314,16 @@ def user_settings() -> dict:
|
|
|
311
314
|
return _read_json(user_config_path())
|
|
312
315
|
|
|
313
316
|
|
|
317
|
+
SUPERVISOR_KEY = "supervisor" # ~/.brindle/config.json: the CLI the person last picked for the supervisor
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def supervisor_pick() -> str | None:
|
|
321
|
+
"""The supervisor CLI this person last picked (in the picker or with
|
|
322
|
+
``-s``), or None before the first pick."""
|
|
323
|
+
pick = user_settings().get(SUPERVISOR_KEY)
|
|
324
|
+
return pick if isinstance(pick, str) and pick else None
|
|
325
|
+
|
|
326
|
+
|
|
314
327
|
def set_user(key: str, value: object) -> Path:
|
|
315
328
|
"""Set one key in ``~/.brindle/config.json`` (other keys are kept)."""
|
|
316
329
|
path = user_config_path()
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Check a pasted API key with its provider before `brindle keys set` stores it.
|
|
2
|
+
Every check is a read-only GET that generates nothing and costs nothing."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import urllib.error
|
|
8
|
+
import urllib.request
|
|
9
|
+
|
|
10
|
+
# name -> (where to create a key, how the provider is called)
|
|
11
|
+
KEY_PAGES = {
|
|
12
|
+
"ANTHROPIC_API_KEY": "https://console.anthropic.com/settings/keys",
|
|
13
|
+
"OPENAI_API_KEY": "https://platform.openai.com/api-keys",
|
|
14
|
+
"OPENROUTER_API_KEY": "https://openrouter.ai/keys",
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def key_page(name: str) -> str | None:
|
|
19
|
+
return KEY_PAGES.get(name)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def personal_keys_denied(repo_root: str | None) -> bool:
|
|
23
|
+
"""True when the org's policy sets deny_personal_keys (read as agents.py does)."""
|
|
24
|
+
from brindle import agents
|
|
25
|
+
|
|
26
|
+
m = agents.managed(repo_root or ".")
|
|
27
|
+
return bool(m is not None and m.deny_personal_keys)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _get(url: str, headers: dict[str, str]) -> tuple[int, dict]:
|
|
31
|
+
req = urllib.request.Request(url, headers=headers, method="GET")
|
|
32
|
+
try:
|
|
33
|
+
with urllib.request.urlopen(req, timeout=15) as resp: # noqa: S310 - fixed https URLs
|
|
34
|
+
return resp.status, json.loads(resp.read() or b"{}")
|
|
35
|
+
except urllib.error.HTTPError as e:
|
|
36
|
+
return e.code, {}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def verify(name: str, value: str) -> str | None:
|
|
40
|
+
"""None when the provider accepts the key, else a message saying why not.
|
|
41
|
+
Names without a check are accepted unchecked."""
|
|
42
|
+
if name == "OPENROUTER_API_KEY":
|
|
43
|
+
url, headers = "https://openrouter.ai/api/v1/key", {"Authorization": f"Bearer {value}"}
|
|
44
|
+
elif name == "ANTHROPIC_API_KEY":
|
|
45
|
+
url = "https://api.anthropic.com/v1/models"
|
|
46
|
+
headers = {"x-api-key": value, "anthropic-version": "2023-06-01"}
|
|
47
|
+
elif name == "OPENAI_API_KEY":
|
|
48
|
+
url, headers = "https://api.openai.com/v1/models", {"Authorization": f"Bearer {value}"}
|
|
49
|
+
else:
|
|
50
|
+
return None
|
|
51
|
+
try:
|
|
52
|
+
status, body = _get(url, headers)
|
|
53
|
+
except (OSError, ValueError) as e:
|
|
54
|
+
return f"couldn't reach the provider to check the key: {e}"
|
|
55
|
+
if status == 401:
|
|
56
|
+
return "the provider rejected this key (401 unauthorized); check that you copied all of it"
|
|
57
|
+
if status != 200:
|
|
58
|
+
return f"the provider answered {status} when checking the key"
|
|
59
|
+
if name == "OPENROUTER_API_KEY":
|
|
60
|
+
data = (body.get("data") if isinstance(body, dict) else None) or {}
|
|
61
|
+
if not isinstance(data, dict):
|
|
62
|
+
data = {}
|
|
63
|
+
left = data.get("limit_remaining")
|
|
64
|
+
if left is not None and left <= 0:
|
|
65
|
+
return "this OpenRouter key has no credit left"
|
|
66
|
+
return None
|
|
@@ -7,10 +7,11 @@ from __future__ import annotations
|
|
|
7
7
|
import getpass
|
|
8
8
|
import os
|
|
9
9
|
import sys
|
|
10
|
+
import webbrowser
|
|
10
11
|
|
|
11
12
|
import typer
|
|
12
13
|
|
|
13
|
-
from brindle import keystore
|
|
14
|
+
from brindle import key_check, keystore
|
|
14
15
|
|
|
15
16
|
app = typer.Typer(no_args_is_help=True,
|
|
16
17
|
help="Store model API keys once; every agent pane gets the ones its provider reads.")
|
|
@@ -37,10 +38,26 @@ def keys_set(name: str = typer.Argument(..., help="The variable, e.g. ANTHROPIC_
|
|
|
37
38
|
keystore.check_name(name, _root())
|
|
38
39
|
except keystore.CredentialError as e:
|
|
39
40
|
_fail(str(e))
|
|
41
|
+
try:
|
|
42
|
+
denied = key_check.personal_keys_denied(_root())
|
|
43
|
+
except Exception as e: # noqa: BLE001 - policy unreadable: fail closed
|
|
44
|
+
_fail(f"couldn't read your org's key policy: {e}")
|
|
45
|
+
if denied:
|
|
46
|
+
_fail("your org's policy denies personal API keys; brindle won't store one")
|
|
47
|
+
page = key_check.key_page(name)
|
|
40
48
|
if sys.stdin.isatty():
|
|
49
|
+
if page:
|
|
50
|
+
typer.echo(f"Get a key at {page}")
|
|
51
|
+
if input("Press o to open it in your browser, or Enter to paste a key: ").strip().lower() == "o":
|
|
52
|
+
webbrowser.open(page)
|
|
41
53
|
value = getpass.getpass(f"{name}: ")
|
|
42
54
|
else:
|
|
43
55
|
value = sys.stdin.readline()
|
|
56
|
+
value = value.strip()
|
|
57
|
+
if value:
|
|
58
|
+
problem = key_check.verify(name, value)
|
|
59
|
+
if problem:
|
|
60
|
+
_fail(f"{name} not stored: {problem}")
|
|
44
61
|
try:
|
|
45
62
|
keystore.set_key(name, value, _root())
|
|
46
63
|
except keystore.CredentialError as e:
|