grip-hook 0.2.1__tar.gz → 0.2.2__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.
- {grip_hook-0.2.1 → grip_hook-0.2.2}/CHANGELOG.md +41 -1
- grip_hook-0.2.2/PKG-INFO +149 -0
- grip_hook-0.2.2/README.md +100 -0
- {grip_hook-0.2.1 → grip_hook-0.2.2}/src/grip_hook/__init__.py +1 -1
- {grip_hook-0.2.1 → grip_hook-0.2.2}/src/grip_hook/cli.py +132 -14
- {grip_hook-0.2.1 → grip_hook-0.2.2}/src/grip_hook/config.py +39 -4
- {grip_hook-0.2.1 → grip_hook-0.2.2}/src/grip_hook/git.py +65 -2
- {grip_hook-0.2.1 → grip_hook-0.2.2}/src/grip_hook/providers/__init__.py +2 -2
- {grip_hook-0.2.1 → grip_hook-0.2.2}/src/grip_hook/providers/anthropic.py +12 -0
- {grip_hook-0.2.1 → grip_hook-0.2.2}/src/grip_hook/providers/openai_compat.py +1 -1
- {grip_hook-0.2.1 → grip_hook-0.2.2}/tests/test_config.py +16 -1
- {grip_hook-0.2.1 → grip_hook-0.2.2}/tests/test_git.py +37 -0
- {grip_hook-0.2.1 → grip_hook-0.2.2}/tests/test_init.py +38 -0
- {grip_hook-0.2.1 → grip_hook-0.2.2}/tests/test_providers.py +10 -0
- grip_hook-0.2.2/tests/test_release.py +88 -0
- grip_hook-0.2.1/PKG-INFO +0 -381
- grip_hook-0.2.1/README.md +0 -332
- {grip_hook-0.2.1 → grip_hook-0.2.2}/.gitignore +0 -0
- {grip_hook-0.2.1 → grip_hook-0.2.2}/LICENSE +0 -0
- {grip_hook-0.2.1 → grip_hook-0.2.2}/branding/README.md +0 -0
- {grip_hook-0.2.1 → grip_hook-0.2.2}/evals/README.md +0 -0
- {grip_hook-0.2.1 → grip_hook-0.2.2}/evals/cases/clamp-add/after/tests/test_ops.py +0 -0
- {grip_hook-0.2.1 → grip_hook-0.2.2}/pyproject.toml +0 -0
- {grip_hook-0.2.1 → grip_hook-0.2.2}/src/grip_hook/__main__.py +0 -0
- {grip_hook-0.2.1 → grip_hook-0.2.2}/src/grip_hook/agent.py +0 -0
- {grip_hook-0.2.1 → grip_hook-0.2.2}/src/grip_hook/errors.py +0 -0
- {grip_hook-0.2.1 → grip_hook-0.2.2}/src/grip_hook/hooks.py +0 -0
- {grip_hook-0.2.1 → grip_hook-0.2.2}/src/grip_hook/integrations.py +0 -0
- {grip_hook-0.2.1 → grip_hook-0.2.2}/src/grip_hook/memory.py +0 -0
- {grip_hook-0.2.1 → grip_hook-0.2.2}/src/grip_hook/models.py +0 -0
- {grip_hook-0.2.1 → grip_hook-0.2.2}/src/grip_hook/platform.py +0 -0
- {grip_hook-0.2.1 → grip_hook-0.2.2}/src/grip_hook/prompts.py +0 -0
- {grip_hook-0.2.1 → grip_hook-0.2.2}/src/grip_hook/providers/agents.py +0 -0
- {grip_hook-0.2.1 → grip_hook-0.2.2}/src/grip_hook/providers/base.py +0 -0
- {grip_hook-0.2.1 → grip_hook-0.2.2}/src/grip_hook/providers/fake.py +0 -0
- {grip_hook-0.2.1 → grip_hook-0.2.2}/src/grip_hook/py.typed +0 -0
- {grip_hook-0.2.1 → grip_hook-0.2.2}/src/grip_hook/quiz.py +0 -0
- {grip_hook-0.2.1 → grip_hook-0.2.2}/src/grip_hook/study.py +0 -0
- {grip_hook-0.2.1 → grip_hook-0.2.2}/src/grip_hook/terminal.py +0 -0
- {grip_hook-0.2.1 → grip_hook-0.2.2}/src/grip_hook/whiteboard.html +0 -0
- {grip_hook-0.2.1 → grip_hook-0.2.2}/src/grip_hook/whiteboard.py +0 -0
- {grip_hook-0.2.1 → grip_hook-0.2.2}/src/grip_hook/whiteboard_server.py +0 -0
- {grip_hook-0.2.1 → grip_hook-0.2.2}/tests/__init__.py +0 -0
- {grip_hook-0.2.1 → grip_hook-0.2.2}/tests/conftest.py +0 -0
- {grip_hook-0.2.1 → grip_hook-0.2.2}/tests/test_agent.py +0 -0
- {grip_hook-0.2.1 → grip_hook-0.2.2}/tests/test_agents.py +0 -0
- {grip_hook-0.2.1 → grip_hook-0.2.2}/tests/test_branding.py +0 -0
- {grip_hook-0.2.1 → grip_hook-0.2.2}/tests/test_cli.py +0 -0
- {grip_hook-0.2.1 → grip_hook-0.2.2}/tests/test_evals.py +0 -0
- {grip_hook-0.2.1 → grip_hook-0.2.2}/tests/test_hooks.py +0 -0
- {grip_hook-0.2.1 → grip_hook-0.2.2}/tests/test_integrations.py +0 -0
- {grip_hook-0.2.1 → grip_hook-0.2.2}/tests/test_memory.py +0 -0
- {grip_hook-0.2.1 → grip_hook-0.2.2}/tests/test_platform.py +0 -0
- {grip_hook-0.2.1 → grip_hook-0.2.2}/tests/test_quiz.py +0 -0
- {grip_hook-0.2.1 → grip_hook-0.2.2}/tests/test_skip.py +0 -0
- {grip_hook-0.2.1 → grip_hook-0.2.2}/tests/test_study.py +0 -0
- {grip_hook-0.2.1 → grip_hook-0.2.2}/tests/test_terminal.py +0 -0
- {grip_hook-0.2.1 → grip_hook-0.2.2}/tests/test_whiteboard.py +0 -0
|
@@ -6,6 +6,45 @@ All notable changes to this project are documented here. The format follows
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.2.2] - 2026-10-19
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- Releases are automatic. Each pull request carries one label, `release:patch`,
|
|
14
|
+
`release:minor`, `release:major` or `release:none`; merging a labelled one bumps the
|
|
15
|
+
version, dates the changelog, tags `main` and publishes to PyPI and GitHub. A check
|
|
16
|
+
makes sure every pull request has exactly one.
|
|
17
|
+
- `grip init` asks, in a terminal, which model to use, the pass mark and whether to quiz
|
|
18
|
+
before pushes, commits or both, with defaults detected on the machine, then says what
|
|
19
|
+
the chosen provider still needs. `--yes` takes every default; scripts are not asked.
|
|
20
|
+
- `grip install` says which model will write the questions when none is configured.
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- Pushing a new branch said "nothing to quiz" when its commits were also on a branch that
|
|
25
|
+
had been deleted upstream (a merged pull request) but not pruned locally: the stale
|
|
26
|
+
`origin/...` ref made them look pushed. grip now asks the remote with `git ls-remote`
|
|
27
|
+
whenever the local refs say a new branch has nothing to send.
|
|
28
|
+
- With no provider configured, grip used the Anthropic API even when Claude Code, Codex or
|
|
29
|
+
Gemini CLI was installed, and without an API key it crashed with a traceback. The
|
|
30
|
+
default provider is now `auto`: the first installed coding agent, else Anthropic. A
|
|
31
|
+
missing Anthropic key is now a clear error that says what to set.
|
|
32
|
+
- `grip quiz --range main..feat/x` cut branch names to 12 characters in its header
|
|
33
|
+
(`main..feat/kidnap-`); only full hashes are shortened now.
|
|
34
|
+
|
|
35
|
+
### Documentation
|
|
36
|
+
|
|
37
|
+
- A shorter README in the usual open-source shape: what grip is, install, quick start,
|
|
38
|
+
configuration, links. The long-form material moved to the docs site.
|
|
39
|
+
- New [walkthrough](https://guilyx.github.io/grip/walkthrough/): one real change, from
|
|
40
|
+
`git commit` to an accepted push, with the real questions, grades and screenshots.
|
|
41
|
+
- New [inside grip](https://guilyx.github.io/grip/internals/) deep dive: the checks before
|
|
42
|
+
a quiz, diff collection, prompts, structured output per provider, rubric grading, pass
|
|
43
|
+
memory, the agent flow, the whiteboard's validation, layout and server, and what is
|
|
44
|
+
stored or sent.
|
|
45
|
+
- The docs navigation is regrouped into Get started, Guides, Deep dive, Reference and
|
|
46
|
+
Project.
|
|
47
|
+
|
|
9
48
|
## [0.2.1] - 2026-10-19
|
|
10
49
|
|
|
11
50
|
grip is on PyPI.
|
|
@@ -167,7 +206,8 @@ The first public release.
|
|
|
167
206
|
- The score table uses rounded borders, matching the panels.
|
|
168
207
|
- The "blocked" message prints the report path relative to the working directory.
|
|
169
208
|
|
|
170
|
-
[Unreleased]: https://github.com/guilyx/grip/compare/v0.2.
|
|
209
|
+
[Unreleased]: https://github.com/guilyx/grip/compare/v0.2.2...HEAD
|
|
210
|
+
[0.2.2]: https://github.com/guilyx/grip/compare/v0.2.1...v0.2.2
|
|
171
211
|
[0.2.1]: https://github.com/guilyx/grip/compare/v0.2.0...v0.2.1
|
|
172
212
|
[0.2.0]: https://github.com/guilyx/grip/compare/v0.1.0...v0.2.0
|
|
173
213
|
[0.1.0]: https://github.com/guilyx/grip/releases/tag/v0.1.0
|
grip_hook-0.2.2/PKG-INFO
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: grip-hook
|
|
3
|
+
Version: 0.2.2
|
|
4
|
+
Summary: Pass the whiteboard defense before you push: a git hook that quizzes you on your own diff.
|
|
5
|
+
Project-URL: Homepage, https://github.com/guilyx/grip
|
|
6
|
+
Project-URL: Documentation, https://guilyx.github.io/grip/
|
|
7
|
+
Project-URL: Repository, https://github.com/guilyx/grip
|
|
8
|
+
Project-URL: Issues, https://github.com/guilyx/grip/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/guilyx/grip/blob/main/CHANGELOG.md
|
|
10
|
+
Project-URL: Releases, https://github.com/guilyx/grip/releases
|
|
11
|
+
Author-email: "Erwin L." <erwin.lejeune15@gmail.com>
|
|
12
|
+
License-Expression: BSD-3-Clause
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Keywords: code-review,git,hook,llm,pre-commit,pre-push,quiz,understanding
|
|
15
|
+
Classifier: Development Status :: 4 - Beta
|
|
16
|
+
Classifier: Environment :: Console
|
|
17
|
+
Classifier: Intended Audience :: Developers
|
|
18
|
+
Classifier: License :: OSI Approved :: BSD License
|
|
19
|
+
Classifier: Operating System :: OS Independent
|
|
20
|
+
Classifier: Programming Language :: Python :: 3
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
25
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
26
|
+
Classifier: Topic :: Software Development :: Version Control :: Git
|
|
27
|
+
Classifier: Typing :: Typed
|
|
28
|
+
Requires-Python: >=3.11
|
|
29
|
+
Requires-Dist: anthropic<2,>=1.0
|
|
30
|
+
Requires-Dist: click>=8.1
|
|
31
|
+
Requires-Dist: pydantic>=2.5
|
|
32
|
+
Requires-Dist: rich>=13.7
|
|
33
|
+
Provides-Extra: demo
|
|
34
|
+
Requires-Dist: fonttools>=4.40; extra == 'demo'
|
|
35
|
+
Requires-Dist: imageio-ffmpeg>=0.5; extra == 'demo'
|
|
36
|
+
Requires-Dist: pillow>=10.0; extra == 'demo'
|
|
37
|
+
Requires-Dist: pyte>=0.8; extra == 'demo'
|
|
38
|
+
Provides-Extra: dev
|
|
39
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
40
|
+
Requires-Dist: pre-commit>=3.7; extra == 'dev'
|
|
41
|
+
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
|
|
42
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
43
|
+
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
44
|
+
Provides-Extra: docs
|
|
45
|
+
Requires-Dist: mkdocs-material>=9.5; extra == 'docs'
|
|
46
|
+
Requires-Dist: mkdocs<2,>=1.6; extra == 'docs'
|
|
47
|
+
Requires-Dist: mkdocstrings[python]>=0.25; extra == 'docs'
|
|
48
|
+
Description-Content-Type: text/markdown
|
|
49
|
+
|
|
50
|
+
# grip
|
|
51
|
+
|
|
52
|
+
**Pass the whiteboard defense before you push.**
|
|
53
|
+
|
|
54
|
+
[](https://github.com/guilyx/grip/actions/workflows/ci.yml)
|
|
55
|
+
[](https://pypi.org/project/grip-hook/)
|
|
56
|
+
[](https://pypi.org/project/grip-hook/)
|
|
57
|
+
[](https://guilyx.github.io/grip/)
|
|
58
|
+
[](https://github.com/guilyx/grip/blob/main/LICENSE)
|
|
59
|
+
|
|
60
|
+
grip is a git hook that asks you five questions about your own diff before it leaves your
|
|
61
|
+
machine: what it does, why it is built this way, where it fails, what could go wrong, how
|
|
62
|
+
you know it works. A model writes the questions and a hidden rubric from the diff, then
|
|
63
|
+
grades your answers. Score below your team's pass mark and the push is blocked.
|
|
64
|
+
|
|
65
|
+
It checks the developer, not the code: you can ship code a model wrote, as long as you can
|
|
66
|
+
explain and defend it.
|
|
67
|
+
|
|
68
|
+

|
|
69
|
+
|
|
70
|
+
## Features
|
|
71
|
+
|
|
72
|
+
- **Five questions from your diff**, graded against a hidden rubric into a Grip Score out
|
|
73
|
+
of 100. Never line numbers or trivia.
|
|
74
|
+
- **`grip whiteboard`**: your change drawn as parts and arrows in the browser, with the
|
|
75
|
+
questions pinned to it, including what a malicious actor could do.
|
|
76
|
+
- **Works with what you have**: reuse Claude Code, Codex or Gemini CLI as the model (no
|
|
77
|
+
API key), or use Anthropic, any OpenAI-compatible API, or Ollama locally.
|
|
78
|
+
- **Inside coding agents**: `/grip` in Claude Code, Codex, Gemini CLI, Cursor and others,
|
|
79
|
+
and a gate that keeps the agent from pushing until you pass.
|
|
80
|
+
- **Out of the way when it should be**: a passed diff is remembered, `grip skip` lets one
|
|
81
|
+
push through, CI and non-interactive runs are skipped.
|
|
82
|
+
|
|
83
|
+
## Installation
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
pipx install grip-hook # or: uv tool install grip-hook
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Python 3.11 or newer, on Linux, macOS or Windows. This installs the `grip` command and a
|
|
90
|
+
`git grip` alias.
|
|
91
|
+
|
|
92
|
+
## Quick start
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
cd your-repo
|
|
96
|
+
grip init # writes .grip.toml, installs the pre-push hook
|
|
97
|
+
git push # grip asks five questions first
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
`grip init` picks Claude Code, Codex or Gemini CLI as the model when one is installed.
|
|
101
|
+
Otherwise set `ANTHROPIC_API_KEY`, or see [Providers](https://guilyx.github.io/grip/providers/).
|
|
102
|
+
|
|
103
|
+
Other commands you will use:
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
grip quiz # quiz your staged changes now, no hook needed
|
|
107
|
+
grip whiteboard # defend the change in the browser
|
|
108
|
+
grip quiz --provider fake # try it offline with a scripted grader
|
|
109
|
+
grip skip # let the next push through
|
|
110
|
+
grip status # hooks, last score, configuration
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
The [walkthrough](https://guilyx.github.io/grip/walkthrough/) follows one real change from
|
|
114
|
+
`git commit` to an accepted push.
|
|
115
|
+
|
|
116
|
+
## Configuration
|
|
117
|
+
|
|
118
|
+
`.grip.toml` at the root of the repository, committed so the team shares one bar:
|
|
119
|
+
|
|
120
|
+
```toml
|
|
121
|
+
passing_score = 70 # 0-100
|
|
122
|
+
difficulty = "normal" # easy | normal | hard
|
|
123
|
+
provider = "claude-code" # auto | anthropic | claude-code | codex | gemini | openai | ollama
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Every option, environment variables and the pre-commit framework hook are in the
|
|
127
|
+
[configuration reference](https://guilyx.github.io/grip/configuration/).
|
|
128
|
+
|
|
129
|
+
## Documentation
|
|
130
|
+
|
|
131
|
+
**[guilyx.github.io/grip](https://guilyx.github.io/grip/)**: getting started, a
|
|
132
|
+
walkthrough, coding agents, the whiteboard, how grip works inside, and the honest numbers
|
|
133
|
+
on what the score does and does not measure.
|
|
134
|
+
|
|
135
|
+
## Contributing
|
|
136
|
+
|
|
137
|
+
Issues and pull requests are welcome. See [CONTRIBUTING.md](https://github.com/guilyx/grip/blob/main/CONTRIBUTING.md)
|
|
138
|
+
for the development setup and [SECURITY.md](https://github.com/guilyx/grip/blob/main/SECURITY.md)
|
|
139
|
+
for reporting vulnerabilities and what data grip sends. This project follows a
|
|
140
|
+
[code of conduct](https://github.com/guilyx/grip/blob/main/CODE_OF_CONDUCT.md).
|
|
141
|
+
|
|
142
|
+
## Citation
|
|
143
|
+
|
|
144
|
+
If grip is part of a study or a course, please cite it; GitHub's "Cite this repository"
|
|
145
|
+
button reads [CITATION.cff](https://github.com/guilyx/grip/blob/main/CITATION.cff).
|
|
146
|
+
|
|
147
|
+
## License
|
|
148
|
+
|
|
149
|
+
[BSD-3-Clause](https://github.com/guilyx/grip/blob/main/LICENSE). Copyright (c) 2026, Erwin L.
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# grip
|
|
2
|
+
|
|
3
|
+
**Pass the whiteboard defense before you push.**
|
|
4
|
+
|
|
5
|
+
[](https://github.com/guilyx/grip/actions/workflows/ci.yml)
|
|
6
|
+
[](https://pypi.org/project/grip-hook/)
|
|
7
|
+
[](https://pypi.org/project/grip-hook/)
|
|
8
|
+
[](https://guilyx.github.io/grip/)
|
|
9
|
+
[](https://github.com/guilyx/grip/blob/main/LICENSE)
|
|
10
|
+
|
|
11
|
+
grip is a git hook that asks you five questions about your own diff before it leaves your
|
|
12
|
+
machine: what it does, why it is built this way, where it fails, what could go wrong, how
|
|
13
|
+
you know it works. A model writes the questions and a hidden rubric from the diff, then
|
|
14
|
+
grades your answers. Score below your team's pass mark and the push is blocked.
|
|
15
|
+
|
|
16
|
+
It checks the developer, not the code: you can ship code a model wrote, as long as you can
|
|
17
|
+
explain and defend it.
|
|
18
|
+
|
|
19
|
+

|
|
20
|
+
|
|
21
|
+
## Features
|
|
22
|
+
|
|
23
|
+
- **Five questions from your diff**, graded against a hidden rubric into a Grip Score out
|
|
24
|
+
of 100. Never line numbers or trivia.
|
|
25
|
+
- **`grip whiteboard`**: your change drawn as parts and arrows in the browser, with the
|
|
26
|
+
questions pinned to it, including what a malicious actor could do.
|
|
27
|
+
- **Works with what you have**: reuse Claude Code, Codex or Gemini CLI as the model (no
|
|
28
|
+
API key), or use Anthropic, any OpenAI-compatible API, or Ollama locally.
|
|
29
|
+
- **Inside coding agents**: `/grip` in Claude Code, Codex, Gemini CLI, Cursor and others,
|
|
30
|
+
and a gate that keeps the agent from pushing until you pass.
|
|
31
|
+
- **Out of the way when it should be**: a passed diff is remembered, `grip skip` lets one
|
|
32
|
+
push through, CI and non-interactive runs are skipped.
|
|
33
|
+
|
|
34
|
+
## Installation
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pipx install grip-hook # or: uv tool install grip-hook
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Python 3.11 or newer, on Linux, macOS or Windows. This installs the `grip` command and a
|
|
41
|
+
`git grip` alias.
|
|
42
|
+
|
|
43
|
+
## Quick start
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
cd your-repo
|
|
47
|
+
grip init # writes .grip.toml, installs the pre-push hook
|
|
48
|
+
git push # grip asks five questions first
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`grip init` picks Claude Code, Codex or Gemini CLI as the model when one is installed.
|
|
52
|
+
Otherwise set `ANTHROPIC_API_KEY`, or see [Providers](https://guilyx.github.io/grip/providers/).
|
|
53
|
+
|
|
54
|
+
Other commands you will use:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
grip quiz # quiz your staged changes now, no hook needed
|
|
58
|
+
grip whiteboard # defend the change in the browser
|
|
59
|
+
grip quiz --provider fake # try it offline with a scripted grader
|
|
60
|
+
grip skip # let the next push through
|
|
61
|
+
grip status # hooks, last score, configuration
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The [walkthrough](https://guilyx.github.io/grip/walkthrough/) follows one real change from
|
|
65
|
+
`git commit` to an accepted push.
|
|
66
|
+
|
|
67
|
+
## Configuration
|
|
68
|
+
|
|
69
|
+
`.grip.toml` at the root of the repository, committed so the team shares one bar:
|
|
70
|
+
|
|
71
|
+
```toml
|
|
72
|
+
passing_score = 70 # 0-100
|
|
73
|
+
difficulty = "normal" # easy | normal | hard
|
|
74
|
+
provider = "claude-code" # auto | anthropic | claude-code | codex | gemini | openai | ollama
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Every option, environment variables and the pre-commit framework hook are in the
|
|
78
|
+
[configuration reference](https://guilyx.github.io/grip/configuration/).
|
|
79
|
+
|
|
80
|
+
## Documentation
|
|
81
|
+
|
|
82
|
+
**[guilyx.github.io/grip](https://guilyx.github.io/grip/)**: getting started, a
|
|
83
|
+
walkthrough, coding agents, the whiteboard, how grip works inside, and the honest numbers
|
|
84
|
+
on what the score does and does not measure.
|
|
85
|
+
|
|
86
|
+
## Contributing
|
|
87
|
+
|
|
88
|
+
Issues and pull requests are welcome. See [CONTRIBUTING.md](https://github.com/guilyx/grip/blob/main/CONTRIBUTING.md)
|
|
89
|
+
for the development setup and [SECURITY.md](https://github.com/guilyx/grip/blob/main/SECURITY.md)
|
|
90
|
+
for reporting vulnerabilities and what data grip sends. This project follows a
|
|
91
|
+
[code of conduct](https://github.com/guilyx/grip/blob/main/CODE_OF_CONDUCT.md).
|
|
92
|
+
|
|
93
|
+
## Citation
|
|
94
|
+
|
|
95
|
+
If grip is part of a study or a course, please cite it; GitHub's "Cite this repository"
|
|
96
|
+
button reads [CITATION.cff](https://github.com/guilyx/grip/blob/main/CITATION.cff).
|
|
97
|
+
|
|
98
|
+
## License
|
|
99
|
+
|
|
100
|
+
[BSD-3-Clause](https://github.com/guilyx/grip/blob/main/LICENSE). Copyright (c) 2026, Erwin L.
|
|
@@ -6,6 +6,7 @@ import contextlib
|
|
|
6
6
|
import dataclasses
|
|
7
7
|
import json
|
|
8
8
|
import os
|
|
9
|
+
import shutil
|
|
9
10
|
import subprocess
|
|
10
11
|
import sys
|
|
11
12
|
import webbrowser
|
|
@@ -32,8 +33,11 @@ from grip_hook.agent import (
|
|
|
32
33
|
report_payload,
|
|
33
34
|
)
|
|
34
35
|
from grip_hook.config import (
|
|
36
|
+
AGENT_PROVIDERS,
|
|
37
|
+
AUTO_PROVIDER,
|
|
35
38
|
DEFAULT_PASSING_SCORE,
|
|
36
39
|
Config,
|
|
40
|
+
auto_provider,
|
|
37
41
|
describe,
|
|
38
42
|
load_config,
|
|
39
43
|
render_template,
|
|
@@ -41,7 +45,7 @@ from grip_hook.config import (
|
|
|
41
45
|
from grip_hook.errors import GripError, NoTerminalError, ProviderError, QuizFailed
|
|
42
46
|
from grip_hook.git import Diff, Git, PushedRef
|
|
43
47
|
from grip_hook.hooks import INSTALLABLE_STAGES, install, status, uninstall
|
|
44
|
-
from grip_hook.integrations import AGENTS, Action, agent, apply, detect
|
|
48
|
+
from grip_hook.integrations import AGENTS, Action, agent, apply, detect
|
|
45
49
|
from grip_hook.memory import PassMemory, SkipState
|
|
46
50
|
from grip_hook.models import Difficulty, QuestionSet, Report, Stage
|
|
47
51
|
from grip_hook.platform import (
|
|
@@ -634,10 +638,92 @@ def _push_diff(git: Git, cfg: Config, hook_args: tuple[str, ...]) -> Diff:
|
|
|
634
638
|
|
|
635
639
|
def _default_provider() -> str:
|
|
636
640
|
"""The provider ``grip init`` picks: a signed-in coding agent when there is one."""
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
+
return auto_provider()
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
_PROVIDER_HINTS = {
|
|
645
|
+
"claude-code": "Claude Code, already signed in: no key, no extra bill",
|
|
646
|
+
"codex": "Codex CLI, already signed in: no key, no extra bill",
|
|
647
|
+
"gemini": "Gemini CLI, already signed in: no key, no extra bill",
|
|
648
|
+
"auto": "each teammate's installed coding agent, else anthropic",
|
|
649
|
+
"anthropic": "Anthropic API: needs ANTHROPIC_API_KEY or `ant auth login`",
|
|
650
|
+
"ollama": "a local model through Ollama: free, set `model` in .grip.toml",
|
|
651
|
+
"openai": "any OpenAI-compatible API: set `base_url` and OPENAI_API_KEY",
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
@dataclasses.dataclass(frozen=True, slots=True)
|
|
656
|
+
class _InitChoices:
|
|
657
|
+
provider: str
|
|
658
|
+
passing_score: int
|
|
659
|
+
stages: tuple[str, ...]
|
|
660
|
+
agents: bool
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def _init_wizard(
|
|
664
|
+
defaults: _InitChoices,
|
|
665
|
+
*,
|
|
666
|
+
ask_provider: bool,
|
|
667
|
+
ask_score: bool,
|
|
668
|
+
ask_stages: bool,
|
|
669
|
+
ask_agents: bool,
|
|
670
|
+
) -> _InitChoices:
|
|
671
|
+
"""Ask, in the terminal, for what the flags did not say. Enter keeps each default."""
|
|
672
|
+
_out.print("[bold]grip init[/bold] [dim]· Enter keeps the default in brackets[/dim]\n")
|
|
673
|
+
provider = defaults.provider
|
|
674
|
+
if ask_provider:
|
|
675
|
+
installed = [name for name, command in AGENT_PROVIDERS if shutil.which(command)]
|
|
676
|
+
options = [*installed, "auto", "anthropic", "ollama", "openai"]
|
|
677
|
+
_out.print("[bold]Which model asks and grades the questions?[/bold]")
|
|
678
|
+
for name in options:
|
|
679
|
+
_out.print(f" [cyan]{name:<12}[/cyan] {_PROVIDER_HINTS[name]}")
|
|
680
|
+
provider = click.prompt(
|
|
681
|
+
"Provider", type=click.Choice(options), default=defaults.provider, show_choices=False
|
|
682
|
+
)
|
|
683
|
+
score = defaults.passing_score
|
|
684
|
+
if ask_score:
|
|
685
|
+
score = click.prompt(
|
|
686
|
+
f"\nPass mark, out of {MAX_SCORE}", type=click.IntRange(0, MAX_SCORE), default=score
|
|
687
|
+
)
|
|
688
|
+
stages = defaults.stages
|
|
689
|
+
if ask_stages:
|
|
690
|
+
when = click.prompt(
|
|
691
|
+
"\nQuiz before every push, every commit, or both?",
|
|
692
|
+
type=click.Choice(["push", "commit", "both"]),
|
|
693
|
+
default="push",
|
|
694
|
+
)
|
|
695
|
+
stages = {"push": ("pre-push",), "commit": ("pre-commit",)}.get(
|
|
696
|
+
when, ("pre-commit", "pre-push")
|
|
697
|
+
)
|
|
698
|
+
agents = defaults.agents
|
|
699
|
+
if ask_agents and (found := detect()):
|
|
700
|
+
names = ", ".join(a.name for a in found)
|
|
701
|
+
agents = click.confirm(
|
|
702
|
+
f"\nTell the coding agents here ({names}) to run the quiz before they push?",
|
|
703
|
+
default=True,
|
|
704
|
+
)
|
|
705
|
+
_out.print()
|
|
706
|
+
return _InitChoices(provider, score, stages, agents)
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def _interactive() -> bool:
|
|
710
|
+
"""Whether someone is at the keyboard to answer ``grip init``'s questions."""
|
|
711
|
+
return sys.stdin.isatty() and sys.stdout.isatty()
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def _provider_tip(provider: str) -> str | None:
|
|
715
|
+
"""What still has to happen for ``provider`` to work, if anything."""
|
|
716
|
+
resolved = auto_provider() if provider == "auto" else provider
|
|
717
|
+
command = dict(AGENT_PROVIDERS).get(resolved)
|
|
718
|
+
if command and not shutil.which(command):
|
|
719
|
+
return f"`{command}` is not on PATH yet; install it and sign in before the next push."
|
|
720
|
+
if resolved == "anthropic" and not os.environ.get("ANTHROPIC_API_KEY"):
|
|
721
|
+
return "set ANTHROPIC_API_KEY (or run `ant auth login`) before the next push."
|
|
722
|
+
if resolved == "ollama":
|
|
723
|
+
return "set `model` in .grip.toml to a model you have pulled, e.g. qwen3:14b."
|
|
724
|
+
if resolved == "openai":
|
|
725
|
+
return "set `base_url` in .grip.toml and export OPENAI_API_KEY."
|
|
726
|
+
return None
|
|
641
727
|
|
|
642
728
|
|
|
643
729
|
@cli.command(name="init")
|
|
@@ -669,6 +755,7 @@ def _default_provider() -> str:
|
|
|
669
755
|
@click.option("--no-hook", is_flag=True, help="Do not install a git hook.")
|
|
670
756
|
@click.option("--no-agents", is_flag=True, help="Do not write coding-agent rule files.")
|
|
671
757
|
@click.option("--force", is_flag=True, help="Overwrite an existing .grip.toml.")
|
|
758
|
+
@click.option("--yes", "-y", is_flag=True, help="Take every default instead of asking.")
|
|
672
759
|
def init_cmd(
|
|
673
760
|
*,
|
|
674
761
|
provider: str | None,
|
|
@@ -678,34 +765,56 @@ def init_cmd(
|
|
|
678
765
|
no_hook: bool,
|
|
679
766
|
no_agents: bool,
|
|
680
767
|
force: bool,
|
|
768
|
+
yes: bool,
|
|
681
769
|
) -> None:
|
|
682
770
|
"""Set a repository up in one go: .grip.toml, the git hook and the agent rule files.
|
|
683
771
|
|
|
684
|
-
|
|
685
|
-
|
|
772
|
+
In a terminal it asks which model to use, the pass mark and when to quiz, with
|
|
773
|
+
defaults picked for this machine; flags answer a question up front and --yes takes
|
|
774
|
+
every default. It picks a provider you will not need a key for when Claude Code, Codex
|
|
775
|
+
or Gemini CLI is installed. Everything it writes is meant to be committed so the whole
|
|
776
|
+
team shares it.
|
|
686
777
|
"""
|
|
687
778
|
git = Git()
|
|
688
779
|
root = git.root()
|
|
689
780
|
toml_path = root / ".grip.toml"
|
|
690
|
-
|
|
781
|
+
writing = force or not toml_path.exists()
|
|
782
|
+
choices = _InitChoices(
|
|
783
|
+
provider=(provider or _default_provider()).lower(),
|
|
784
|
+
passing_score=DEFAULT_PASSING_SCORE if passing_score is None else passing_score,
|
|
785
|
+
stages=stages or (Stage.PRE_PUSH.value,),
|
|
786
|
+
agents=not no_agents,
|
|
787
|
+
)
|
|
788
|
+
if not yes and _interactive():
|
|
789
|
+
choices = _init_wizard(
|
|
790
|
+
choices,
|
|
791
|
+
ask_provider=writing and provider is None,
|
|
792
|
+
ask_score=writing and passing_score is None,
|
|
793
|
+
ask_stages=not no_hook and not stages,
|
|
794
|
+
ask_agents=not no_agents,
|
|
795
|
+
)
|
|
796
|
+
if not writing:
|
|
691
797
|
_out.print(
|
|
692
798
|
f"[dim]grip: {_pretty_path(toml_path)} exists, keeping it (--force overwrites)[/dim]"
|
|
693
799
|
)
|
|
694
800
|
else:
|
|
695
|
-
chosen = (provider or _default_provider()).lower()
|
|
696
801
|
text = render_template(
|
|
697
|
-
provider=
|
|
698
|
-
passing_score=
|
|
802
|
+
provider=choices.provider,
|
|
803
|
+
passing_score=choices.passing_score,
|
|
699
804
|
difficulty=difficulty or Difficulty.NORMAL.value,
|
|
700
805
|
)
|
|
701
806
|
toml_path.write_text(text, "utf-8")
|
|
702
|
-
_out.print(
|
|
807
|
+
_out.print(
|
|
808
|
+
f"[green]grip:[/green] wrote {_pretty_path(toml_path)} (provider {choices.provider})"
|
|
809
|
+
)
|
|
810
|
+
if tip := _provider_tip(choices.provider):
|
|
811
|
+
_out.print(f"[yellow]grip:[/yellow] {tip}")
|
|
703
812
|
if not no_hook:
|
|
704
|
-
for stage in stages
|
|
813
|
+
for stage in choices.stages:
|
|
705
814
|
result = install(git, Stage(stage), append=True)
|
|
706
815
|
how = "appended to" if result.appended else "installed at"
|
|
707
816
|
_out.print(f"[green]grip:[/green] {stage} hook {how} {_pretty_path(result.path)}")
|
|
708
|
-
if
|
|
817
|
+
if choices.agents:
|
|
709
818
|
changes = apply(root, detect())
|
|
710
819
|
past = {Action.WRITTEN: "wrote", Action.UPDATED: "updated"}
|
|
711
820
|
for change in changes:
|
|
@@ -808,6 +917,15 @@ def install_cmd(stages: tuple[str, ...], force: bool, append: bool) -> None:
|
|
|
808
917
|
result = install(git, Stage(stage), force=force, append=append)
|
|
809
918
|
how = "appended to" if result.appended else "installed at"
|
|
810
919
|
_out.print(f"[green]grip:[/green] {stage} hook {how} {result.path}")
|
|
920
|
+
cfg = _config(git)
|
|
921
|
+
if cfg.provider == AUTO_PROVIDER:
|
|
922
|
+
_out.print(
|
|
923
|
+
f"[dim]grip: no provider configured, so the questions come from "
|
|
924
|
+
f"{cfg.provider_name} (provider auto). `grip init` sets the model, the pass mark "
|
|
925
|
+
"and the team's defaults.[/dim]"
|
|
926
|
+
)
|
|
927
|
+
if tip := _provider_tip(cfg.provider):
|
|
928
|
+
_out.print(f"[yellow]grip:[/yellow] {tip}")
|
|
811
929
|
_out.print("[dim]Run `grip status` to check, `grip uninstall` to remove.[/dim]")
|
|
812
930
|
|
|
813
931
|
|
|
@@ -12,6 +12,7 @@ Precedence, highest first:
|
|
|
12
12
|
from __future__ import annotations
|
|
13
13
|
|
|
14
14
|
import os
|
|
15
|
+
import shutil
|
|
15
16
|
import tomllib
|
|
16
17
|
from dataclasses import dataclass, fields, replace
|
|
17
18
|
from pathlib import Path
|
|
@@ -47,6 +48,27 @@ DEFAULT_EXCLUDES: tuple[str, ...] = (
|
|
|
47
48
|
|
|
48
49
|
ENV_PREFIX = "GRIP_"
|
|
49
50
|
|
|
51
|
+
AUTO_PROVIDER = "auto"
|
|
52
|
+
AGENT_PROVIDERS: tuple[tuple[str, str], ...] = (
|
|
53
|
+
("claude-code", "claude"),
|
|
54
|
+
("codex", "codex"),
|
|
55
|
+
("gemini", "gemini"),
|
|
56
|
+
)
|
|
57
|
+
"""Coding-agent providers ``auto`` tries, in order, with the command each one needs."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def auto_provider() -> str:
|
|
61
|
+
"""What ``provider = "auto"`` means on this machine.
|
|
62
|
+
|
|
63
|
+
A coding agent that is installed (and so, usually, signed in) needs no API key and adds
|
|
64
|
+
no bill, so the first of Claude Code, Codex and Gemini CLI on ``PATH`` wins; without
|
|
65
|
+
one, the Anthropic API.
|
|
66
|
+
"""
|
|
67
|
+
for name, command in AGENT_PROVIDERS:
|
|
68
|
+
if shutil.which(command):
|
|
69
|
+
return name
|
|
70
|
+
return "anthropic"
|
|
71
|
+
|
|
50
72
|
|
|
51
73
|
@dataclass(frozen=True, slots=True)
|
|
52
74
|
class Config:
|
|
@@ -55,9 +77,10 @@ class Config:
|
|
|
55
77
|
passing_score: int = DEFAULT_PASSING_SCORE
|
|
56
78
|
"""Minimum Grip Score (0-100) required to let the commit or push through."""
|
|
57
79
|
|
|
58
|
-
provider: str =
|
|
59
|
-
"""Provider name: ``anthropic``, ``openai``, ``ollama``, ``claude-code``,
|
|
60
|
-
``gemini`` or ``fake``.
|
|
80
|
+
provider: str = AUTO_PROVIDER
|
|
81
|
+
"""Provider name: ``auto``, ``anthropic``, ``openai``, ``ollama``, ``claude-code``,
|
|
82
|
+
``codex``, ``gemini`` or ``fake``. ``auto`` picks an installed coding agent, else
|
|
83
|
+
``anthropic``; see :func:`auto_provider`."""
|
|
61
84
|
|
|
62
85
|
model: str = ""
|
|
63
86
|
"""Model identifier passed to the provider. Empty means the provider's default."""
|
|
@@ -106,6 +129,12 @@ class Config:
|
|
|
106
129
|
raise ConfigError("provider must not be empty")
|
|
107
130
|
return self
|
|
108
131
|
|
|
132
|
+
@property
|
|
133
|
+
def provider_name(self) -> str:
|
|
134
|
+
"""The provider that will actually run: ``auto`` resolved on this machine."""
|
|
135
|
+
name = self.provider.lower()
|
|
136
|
+
return auto_provider() if name == AUTO_PROVIDER else name
|
|
137
|
+
|
|
109
138
|
|
|
110
139
|
_FIELD_NAMES = {f.name for f in fields(Config)}
|
|
111
140
|
|
|
@@ -205,7 +234,8 @@ passing_score = {passing_score}
|
|
|
205
234
|
# easy | normal | hard
|
|
206
235
|
difficulty = "{difficulty}"
|
|
207
236
|
|
|
208
|
-
# anthropic | claude-code | codex | gemini | openai | ollama | fake
|
|
237
|
+
# auto | anthropic | claude-code | codex | gemini | openai | ollama | fake
|
|
238
|
+
# (auto: an installed coding agent, else anthropic)
|
|
209
239
|
provider = "{provider}"
|
|
210
240
|
|
|
211
241
|
# model = "" # empty: the provider's default
|
|
@@ -239,16 +269,21 @@ def describe(cfg: Config) -> list[tuple[str, str]]:
|
|
|
239
269
|
text = value.value
|
|
240
270
|
else:
|
|
241
271
|
text = str(value) if value != "" else "(provider default)"
|
|
272
|
+
if f.name == "provider" and value == AUTO_PROVIDER:
|
|
273
|
+
text = f"{AUTO_PROVIDER} ({cfg.provider_name})"
|
|
242
274
|
rows.append((f.name, text))
|
|
243
275
|
return rows
|
|
244
276
|
|
|
245
277
|
|
|
246
278
|
__all__ = [
|
|
279
|
+
"AGENT_PROVIDERS",
|
|
280
|
+
"AUTO_PROVIDER",
|
|
247
281
|
"DEFAULT_EXCLUDES",
|
|
248
282
|
"DEFAULT_MODEL",
|
|
249
283
|
"DEFAULT_PASSING_SCORE",
|
|
250
284
|
"TEMPLATE",
|
|
251
285
|
"Config",
|
|
286
|
+
"auto_provider",
|
|
252
287
|
"describe",
|
|
253
288
|
"load_config",
|
|
254
289
|
"render_template",
|