spareparts-cli 0.1.0__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 (39) hide show
  1. spareparts_cli-0.1.0/.github/dependabot.yml +31 -0
  2. spareparts_cli-0.1.0/.github/workflows/dependency-scan.yml +46 -0
  3. spareparts_cli-0.1.0/.github/workflows/homebrew.yml +110 -0
  4. spareparts_cli-0.1.0/.github/workflows/publish.yml +82 -0
  5. spareparts_cli-0.1.0/.github/workflows/secret-scan.yml +81 -0
  6. spareparts_cli-0.1.0/.github/workflows/test.yml +92 -0
  7. spareparts_cli-0.1.0/.gitignore +11 -0
  8. spareparts_cli-0.1.0/PKG-INFO +227 -0
  9. spareparts_cli-0.1.0/PROMPTS.md +61 -0
  10. spareparts_cli-0.1.0/README.md +202 -0
  11. spareparts_cli-0.1.0/pyproject.toml +37 -0
  12. spareparts_cli-0.1.0/src/spareparts/__init__.py +3 -0
  13. spareparts_cli-0.1.0/src/spareparts/__main__.py +4 -0
  14. spareparts_cli-0.1.0/src/spareparts/cli.py +94 -0
  15. spareparts_cli-0.1.0/src/spareparts/modules/__init__.py +1 -0
  16. spareparts_cli-0.1.0/src/spareparts/modules/lgtm/__init__.py +218 -0
  17. spareparts_cli-0.1.0/src/spareparts/modules/lgtm/ask.py +209 -0
  18. spareparts_cli-0.1.0/src/spareparts/modules/lgtm/config.py +189 -0
  19. spareparts_cli-0.1.0/src/spareparts/modules/lgtm/diff.py +134 -0
  20. spareparts_cli-0.1.0/src/spareparts/modules/lgtm/generator.py +263 -0
  21. spareparts_cli-0.1.0/src/spareparts/modules/lgtm/git.py +179 -0
  22. spareparts_cli-0.1.0/src/spareparts/modules/lgtm/hook.py +238 -0
  23. spareparts_cli-0.1.0/src/spareparts/modules/lgtm/prompts.py +98 -0
  24. spareparts_cli-0.1.0/src/spareparts/modules/lgtm/screen.py +71 -0
  25. spareparts_cli-0.1.0/src/spareparts/prompts/lgtm-questions.v1.json +103 -0
  26. spareparts_cli-0.1.0/src/spareparts/providers/__init__.py +128 -0
  27. spareparts_cli-0.1.0/src/spareparts/providers/_anthropic.py +69 -0
  28. spareparts_cli-0.1.0/src/spareparts/providers/_gemini.py +86 -0
  29. spareparts_cli-0.1.0/src/spareparts/providers/_openai.py +92 -0
  30. spareparts_cli-0.1.0/tests/test_ask.py +141 -0
  31. spareparts_cli-0.1.0/tests/test_config.py +83 -0
  32. spareparts_cli-0.1.0/tests/test_diff.py +71 -0
  33. spareparts_cli-0.1.0/tests/test_generate_flow.py +185 -0
  34. spareparts_cli-0.1.0/tests/test_generator.py +92 -0
  35. spareparts_cli-0.1.0/tests/test_git.py +49 -0
  36. spareparts_cli-0.1.0/tests/test_hook.py +277 -0
  37. spareparts_cli-0.1.0/tests/test_prompts.py +96 -0
  38. spareparts_cli-0.1.0/tests/test_providers.py +165 -0
  39. spareparts_cli-0.1.0/tests/test_screen.py +39 -0
@@ -0,0 +1,31 @@
1
+ version: 2
2
+
3
+ # The upgrade half of dependency scanning. `dependency-scan.yml` fails a build
4
+ # that is already vulnerable; this opens the pull request that fixes it.
5
+ updates:
6
+ - package-ecosystem: pip
7
+ directory: /
8
+ schedule:
9
+ interval: weekly
10
+ day: monday
11
+ open-pull-requests-limit: 5
12
+ commit-message:
13
+ prefix: chore
14
+ groups:
15
+ # One PR for the whole set. Three vendor SDKs on separate weekly PRs is
16
+ # how a repository trains its owner to stop reading them.
17
+ python:
18
+ patterns: ["*"]
19
+
20
+ # Workflow actions pin to major tags that move, so this mostly catches
21
+ # deprecations (a runner dropping node16 actions, say) before they break CI.
22
+ - package-ecosystem: github-actions
23
+ directory: /
24
+ schedule:
25
+ interval: weekly
26
+ day: monday
27
+ commit-message:
28
+ prefix: chore
29
+ groups:
30
+ actions:
31
+ patterns: ["*"]
@@ -0,0 +1,46 @@
1
+ name: Dependency scan
2
+
3
+ # Known-vulnerable dependencies, checked against the Python Packaging Advisory
4
+ # Database and OSV by pip-audit (https://pypi.org/project/pip-audit/).
5
+ #
6
+ # Self-contained, like the secret scan: no shared workflow, no GitHub Advanced
7
+ # Security. Dependabot (see .github/dependabot.yml) opens the upgrade PRs; this
8
+ # job is what fails a build that ships a vulnerable dependency in the meantime.
9
+ #
10
+ # The schedule matters more than the PR trigger. A dependency does not become
11
+ # vulnerable when someone edits pyproject.toml; it becomes vulnerable when an
12
+ # advisory is published, which can be months after the last commit here.
13
+
14
+ on:
15
+ pull_request:
16
+ push:
17
+ branches: [main]
18
+ schedule:
19
+ - cron: "0 7 * * 1"
20
+ workflow_dispatch:
21
+
22
+ jobs:
23
+ audit:
24
+ name: pip-audit
25
+ runs-on: ubuntu-latest
26
+ permissions:
27
+ contents: read
28
+ steps:
29
+ - uses: actions/checkout@v7
30
+
31
+ - uses: actions/setup-python@v7
32
+ with:
33
+ python-version: "3.12"
34
+
35
+ # `all`, not the bare install: the extras are dependencies this project
36
+ # tells people to install, so an advisory against one of them is ours.
37
+ - name: Install every declared dependency
38
+ run: |
39
+ python -m pip install --upgrade pip
40
+ pip install -e '.[all]' pip-audit
41
+
42
+ - name: Audit
43
+ # --skip-editable skips this project itself, which has no PyPI release
44
+ # to look up. Everything it pulled in is still audited, and the summary
45
+ # pip-audit prints lists exactly what was skipped and why.
46
+ run: pip-audit --skip-editable
@@ -0,0 +1,110 @@
1
+ name: Update Homebrew tap
2
+
3
+ # On every release tag, point the sparepartslabs/homebrew-tap formula at the new
4
+ # PyPI sdist. Runs alongside publish.yml (same v* trigger) and waits for the
5
+ # sdist to appear on PyPI before reading its canonical URL + sha256.
6
+
7
+ on:
8
+ push:
9
+ tags:
10
+ - "v*"
11
+
12
+ jobs:
13
+ bump:
14
+ runs-on: ubuntu-latest
15
+ steps:
16
+ - name: Resolve version
17
+ id: v
18
+ run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
19
+
20
+ - name: Fetch sdist url + sha256 from PyPI (wait for publish)
21
+ id: pypi
22
+ run: |
23
+ VERSION="${{ steps.v.outputs.version }}"
24
+ for i in $(seq 1 30); do
25
+ JSON=$(curl -fsSL "https://pypi.org/pypi/spareparts-cli/${VERSION}/json" || true)
26
+ URL=$(printf '%s' "$JSON" | jq -r '.urls[]? | select(.packagetype=="sdist") | .url')
27
+ SHA=$(printf '%s' "$JSON" | jq -r '.urls[]? | select(.packagetype=="sdist") | .digests.sha256')
28
+ if [ -n "$URL" ] && [ "$URL" != "null" ]; then break; fi
29
+ echo "sdist for ${VERSION} not on PyPI yet, retrying ($i)..."
30
+ sleep 10
31
+ done
32
+ if [ -z "$URL" ] || [ "$URL" = "null" ]; then
33
+ echo "::error::spareparts-cli ${VERSION} sdist not found on PyPI"; exit 1
34
+ fi
35
+ echo "url=$URL" >> "$GITHUB_OUTPUT"
36
+ echo "sha=$SHA" >> "$GITHUB_OUTPUT"
37
+
38
+ - name: Checkout tap
39
+ uses: actions/checkout@v4
40
+ with:
41
+ repository: sparepartslabs/homebrew-tap
42
+ token: ${{ secrets.HOMEBREW_TAP_TOKEN }}
43
+
44
+ - name: Update formula (url, sha256, and vendored resources)
45
+ env:
46
+ VERSION: ${{ steps.v.outputs.version }}
47
+ URL: ${{ steps.pypi.outputs.url }}
48
+ SHA: ${{ steps.pypi.outputs.sha }}
49
+ run: |
50
+ F=Formula/spareparts-cli.rb
51
+ sed -i -E "s#^ url \".*\"# url \"${URL}\"#" "$F"
52
+ sed -i -E "s#^ sha256 \".*\"# sha256 \"${SHA}\"#" "$F"
53
+
54
+ # Refresh the vendored `resource` blocks. `brew update-python-resources`
55
+ # can't be used on release day (it skips PyPI uploads less than a day
56
+ # old), so resolve the deps in a throwaway venv and read each sdist
57
+ # url+sha256 from PyPI.
58
+ #
59
+ # `[all]` on purpose: pip installing this package brings no vendor SDK,
60
+ # because none of the three is the assumed one. A brew install has no
61
+ # extras syntax to offer, so the bottle carries all three and the CLI
62
+ # uses whichever key you have set.
63
+ python3 - "$F" "$VERSION" <<'PY'
64
+ import json, re, subprocess, sys, tempfile, urllib.request, venv
65
+
66
+ formula, version = sys.argv[1], sys.argv[2]
67
+ tmp = tempfile.mkdtemp()
68
+ venv.create(f"{tmp}/v", with_pip=True)
69
+ pip = f"{tmp}/v/bin/pip"
70
+ subprocess.check_call([pip, "install", "-q", f"spareparts-cli[all]=={version}"])
71
+ freeze = subprocess.check_output([pip, "list", "--format=freeze"]).decode()
72
+
73
+ skip = {"spareparts-cli", "pip", "setuptools", "wheel"}
74
+ deps = []
75
+ for line in freeze.splitlines():
76
+ if "==" in line:
77
+ name, ver = line.split("==", 1)
78
+ if name.lower() not in skip:
79
+ deps.append((name, ver))
80
+
81
+ blocks = []
82
+ for name, ver in sorted(deps, key=lambda x: x[0].lower()):
83
+ d = json.load(urllib.request.urlopen(f"https://pypi.org/pypi/{name}/{ver}/json"))
84
+ sd = next((u for u in d["urls"] if u["packagetype"] == "sdist"), None)
85
+ if not sd:
86
+ sys.exit(f"no sdist for {name} {ver}")
87
+ blocks.append(
88
+ f' resource "{d["info"]["name"]}" do\n'
89
+ f' url "{sd["url"]}"\n'
90
+ f' sha256 "{sd["digests"]["sha256"]}"\n'
91
+ f' end'
92
+ )
93
+
94
+ text = open(formula).read()
95
+ # drop existing resource blocks, then insert fresh ones after depends_on
96
+ text = re.sub(r'\n resource "[^"]+" do\n(?:.*\n)*? end\n', '\n', text)
97
+ inject = "\n" + "\n\n".join(blocks) + "\n"
98
+ text = re.sub(r'( depends_on "python@[^"]+"\n)', r'\1' + inject, text, count=1)
99
+ text = re.sub(r'\n{3,}', '\n\n', text) # collapse blank runs (idempotent)
100
+ open(formula, "w").write(text)
101
+ print(f"refreshed {len(blocks)} resources: {', '.join(n for n, _ in deps)}")
102
+ PY
103
+
104
+ git config user.name "github-actions[bot]"
105
+ git config user.email "github-actions[bot]@users.noreply.github.com"
106
+ if git diff --quiet; then
107
+ echo "formula already up to date"; exit 0
108
+ fi
109
+ git commit -am "spareparts-cli ${VERSION}"
110
+ git push
@@ -0,0 +1,82 @@
1
+ name: Publish to PyPI
2
+
3
+ # Releases are tags. `git tag v0.2.0 && git push --tags` publishes 0.2.0 and
4
+ # nothing else does, so what is on PyPI is always something someone chose to
5
+ # put there.
6
+
7
+ on:
8
+ push:
9
+ tags:
10
+ - "v*" # e.g. v0.1.0
11
+
12
+ permissions:
13
+ contents: read
14
+ id-token: write # PyPI Trusted Publishing (OIDC)
15
+
16
+ jobs:
17
+ publish:
18
+ runs-on: ubuntu-latest
19
+ environment: pypi
20
+ steps:
21
+ - uses: actions/checkout@v4
22
+
23
+ - uses: actions/setup-python@v5
24
+ with:
25
+ python-version: "3.12"
26
+
27
+ # A tag that disagrees with pyproject publishes a version nobody asked
28
+ # for, under a name that already means something else. Cheaper to fail
29
+ # here than to yank a release: PyPI will not let the number be reused.
30
+ - name: The tag must match the version in pyproject
31
+ run: |
32
+ python - <<'PY'
33
+ import os, sys, tomllib
34
+ tag = os.environ["GITHUB_REF_NAME"].removeprefix("v")
35
+ with open("pyproject.toml", "rb") as f:
36
+ declared = tomllib.load(f)["project"]["version"]
37
+ if tag != declared:
38
+ sys.exit(f"tag {tag} does not match pyproject version {declared}")
39
+ print(f"releasing {declared}")
40
+ PY
41
+
42
+ - name: Install build tools
43
+ run: pip install build
44
+
45
+ - name: Build sdist and wheel
46
+ run: python -m build
47
+
48
+ # Trusted Publishing, so there is no API token to store or rotate. PyPI
49
+ # is configured to trust this repository, this workflow file, and the
50
+ # `pypi` environment above; changing any of those three breaks the
51
+ # release until PyPI's publisher settings are updated to match.
52
+ - name: Publish to PyPI
53
+ uses: pypa/gh-action-pypi-publish@release/v1
54
+
55
+ # A tag is a git object with no page. A release is where someone deciding
56
+ # whether to install this reads what changed and whether it is still alive,
57
+ # and PyPI shows them the current version and nothing about the last one.
58
+ #
59
+ # Its own job so that `publish` keeps `contents: read`: the job holding the
60
+ # PyPI identity token has no business also being able to write to the repo.
61
+ release:
62
+ needs: publish
63
+ runs-on: ubuntu-latest
64
+ permissions:
65
+ contents: write
66
+ steps:
67
+ - uses: actions/checkout@v4
68
+ with:
69
+ # Notes are generated from what changed since the previous tag, which
70
+ # a shallow clone cannot see.
71
+ fetch-depth: 0
72
+
73
+ # --generate-notes writes them from the pull requests merged since that
74
+ # tag, so there is no changelog to maintain by hand and no second place
75
+ # for the history to be wrong.
76
+ #
77
+ # The sdist and wheel are deliberately not attached: PyPI already serves
78
+ # them, and a second copy is one more thing that can disagree.
79
+ - name: Create the GitHub release
80
+ env:
81
+ GH_TOKEN: ${{ github.token }}
82
+ run: gh release create "$GITHUB_REF_NAME" --generate-notes --verify-tag
@@ -0,0 +1,81 @@
1
+ name: Secret scan
2
+
3
+ # Detection is TruffleHog (https://github.com/trufflesecurity/trufflehog): it
4
+ # verifies found credentials against live services, so a match reported here is
5
+ # far more likely to be a real, active secret than a shape-based false positive.
6
+ #
7
+ # Self-contained by design. This repository depends on nothing private to run
8
+ # its own CI, so the steps are inlined rather than called from a shared workflow.
9
+
10
+ on:
11
+ pull_request:
12
+ push:
13
+ branches: [main]
14
+
15
+ jobs:
16
+ secrets:
17
+ name: trufflehog
18
+ runs-on: ubuntu-latest
19
+ permissions:
20
+ contents: read
21
+ env:
22
+ TRUFFLEHOG_VERSION: "3.95.9"
23
+ steps:
24
+ - name: Check out full history
25
+ uses: actions/checkout@v7
26
+ with:
27
+ fetch-depth: 0
28
+
29
+ - name: Resolve scan base
30
+ id: base
31
+ env:
32
+ EVENT_NAME: ${{ github.event_name }}
33
+ PR_BASE: ${{ github.event.pull_request.base.sha }}
34
+ PUSH_BEFORE: ${{ github.event.before }}
35
+ run: |
36
+ # A pull request scans everything its head adds over its base. A push
37
+ # scans the pushed range; a first push (all-zero "before") has no base,
38
+ # so scan the whole history that arrived with it.
39
+ if [ "$EVENT_NAME" = "pull_request" ]; then
40
+ echo "since=$PR_BASE" >> "$GITHUB_OUTPUT"
41
+ elif [ "$PUSH_BEFORE" = "0000000000000000000000000000000000000000" ] || [ -z "$PUSH_BEFORE" ]; then
42
+ echo "since=" >> "$GITHUB_OUTPUT"
43
+ else
44
+ echo "since=$PUSH_BEFORE" >> "$GITHUB_OUTPUT"
45
+ fi
46
+
47
+ - name: Install TruffleHog
48
+ run: |
49
+ curl -sSfL https://raw.githubusercontent.com/trufflesecurity/trufflehog/main/scripts/install.sh \
50
+ | sh -s -- -b /usr/local/bin "v${TRUFFLEHOG_VERSION}"
51
+ trufflehog --version
52
+
53
+ - name: Scan for secrets
54
+ env:
55
+ SINCE: ${{ steps.base.outputs.since }}
56
+ run: |
57
+ # --results=verified,unknown: fail on live-verified credentials AND on
58
+ # findings TruffleHog cannot verify (private keys, offline tokens), so a
59
+ # real secret with no live endpoint to check is not silently ignored.
60
+ # --fail exits non-zero when any such result is found; --no-update keeps
61
+ # CI from self-upgrading the pinned binary.
62
+ #
63
+ # --exclude-detectors=Lob: Lob's detector matches any `test_`-prefixed
64
+ # token of about thirty characters, and Lob's API verifies those, so
65
+ # four pytest function names in this repository (for instance
66
+ # `test_the_revisit_note_points_at_the_file`) report as live, verified
67
+ # credentials. Nothing here talks to Lob, so the detector costs
68
+ # nothing to drop; the alternative, excluding the paths, would blind
69
+ # the scan to every test file instead.
70
+ set -- git "file://." --results=verified,unknown --fail --no-update \
71
+ --exclude-detectors=Lob
72
+ if [ -n "$SINCE" ]; then
73
+ set -- "$@" --since-commit "$SINCE"
74
+ fi
75
+ # A repo may ship a .trufflehog-exclude file (newline-separated path
76
+ # regexes) to suppress known-safe committed matches, e.g. throwaway
77
+ # keys in test fixtures or example connection strings in docs.
78
+ if [ -f .trufflehog-exclude ]; then
79
+ set -- "$@" --exclude-paths .trufflehog-exclude
80
+ fi
81
+ trufflehog "$@"
@@ -0,0 +1,92 @@
1
+ name: Tests
2
+
3
+ on:
4
+ pull_request:
5
+ push:
6
+ branches: [main]
7
+
8
+ jobs:
9
+ pytest:
10
+ # What the check reads as on a PR: "Tests / pytest on Python 3.11". The
11
+ # default would be `pytest (3.11)`, a bare number next to two others.
12
+ name: pytest on Python ${{ matrix.python-version }}
13
+ runs-on: ubuntu-latest
14
+ strategy:
15
+ fail-fast: false
16
+ matrix:
17
+ # The floor pyproject declares, through the current release. `sp` is
18
+ # installed onto whatever interpreter a contributor already has, so the
19
+ # supported range is a promise rather than an aspiration.
20
+ python-version: ["3.11", "3.12", "3.13"]
21
+
22
+ steps:
23
+ - uses: actions/checkout@v7
24
+
25
+ - uses: actions/setup-python@v7
26
+ with:
27
+ python-version: ${{ matrix.python-version }}
28
+
29
+ - name: Install
30
+ run: pip install -e '.[dev]'
31
+
32
+ - name: Run tests
33
+ run: pytest -q
34
+
35
+ # A bare `pip install spareparts-cli` pulls Anthropic and nothing else, which
36
+ # is the install most people get. If an OpenAI or Gemini import ever leaks out
37
+ # of its adapter and into a shared path, it surfaces here as a crash on
38
+ # `--help` rather than in a stranger's terminal.
39
+ bare-install:
40
+ name: bare install runs with no provider extras
41
+ runs-on: ubuntu-latest
42
+ steps:
43
+ - uses: actions/checkout@v7
44
+
45
+ - uses: actions/setup-python@v7
46
+ with:
47
+ python-version: "3.12"
48
+
49
+ - name: Install without provider extras
50
+ run: pip install .
51
+
52
+ - name: The CLI must run with only the default vendor installed
53
+ run: |
54
+ if python -c "import openai" 2>/dev/null; then
55
+ echo "openai is installed; this job is not testing what it claims"
56
+ exit 1
57
+ fi
58
+ sp --help
59
+ sp lgtm --help
60
+
61
+ build:
62
+ name: build the wheel and check its contents
63
+ runs-on: ubuntu-latest
64
+ steps:
65
+ - uses: actions/checkout@v7
66
+
67
+ - uses: actions/setup-python@v7
68
+ with:
69
+ python-version: "3.12"
70
+
71
+ - name: Build sdist and wheel
72
+ run: |
73
+ pip install build
74
+ python -m build
75
+
76
+ - name: Check the distribution
77
+ run: |
78
+ pip install twine
79
+ twine check dist/*
80
+
81
+ # The prompt files are data, not code, and a packaging change that drops
82
+ # them leaves a CLI that imports fine and then cannot generate anything.
83
+ - name: The wheel must carry the prompt files
84
+ run: |
85
+ python - <<'PY'
86
+ import pathlib, zipfile
87
+ wheel = next(iter(sorted(pathlib.Path("dist").glob("*.whl"))))
88
+ names = zipfile.ZipFile(wheel).namelist()
89
+ prompts = [n for n in names if "/prompts/" in n and n.endswith(".json")]
90
+ assert prompts, f"no prompt JSON in {wheel.name}: {names}"
91
+ print("\n".join(prompts))
92
+ PY
@@ -0,0 +1,11 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ venv/
5
+ dist/
6
+ build/
7
+ *.egg-info/
8
+ .pytest_cache/
9
+ .env
10
+ *.pem
11
+ specs/
@@ -0,0 +1,227 @@
1
+ Metadata-Version: 2.4
2
+ Name: spareparts-cli
3
+ Version: 0.1.0
4
+ Summary: sp — the Spare Parts command line
5
+ Author: Spare Parts Labs
6
+ License: UNLICENSED
7
+ Requires-Python: >=3.11
8
+ Requires-Dist: pyyaml>=6.0
9
+ Provides-Extra: all
10
+ Requires-Dist: anthropic>=0.72; extra == 'all'
11
+ Requires-Dist: google-genai>=2.0; extra == 'all'
12
+ Requires-Dist: openai>=2.0; extra == 'all'
13
+ Provides-Extra: anthropic
14
+ Requires-Dist: anthropic>=0.72; extra == 'anthropic'
15
+ Provides-Extra: dev
16
+ Requires-Dist: anthropic>=0.72; extra == 'dev'
17
+ Requires-Dist: google-genai>=2.0; extra == 'dev'
18
+ Requires-Dist: openai>=2.0; extra == 'dev'
19
+ Requires-Dist: pytest>=8.0; extra == 'dev'
20
+ Provides-Extra: gemini
21
+ Requires-Dist: google-genai>=2.0; extra == 'gemini'
22
+ Provides-Extra: openai
23
+ Requires-Dist: openai>=2.0; extra == 'openai'
24
+ Description-Content-Type: text/markdown
25
+
26
+ # sp
27
+
28
+ The Spare Parts command line.
29
+
30
+ ```
31
+ pip install ".[anthropic]" # or [openai], or [gemini], or [all]
32
+ sp
33
+ ```
34
+
35
+ ## Modules
36
+
37
+ | Module | What it does |
38
+ |---|---|
39
+ | `sp lgtm` | Prove you read a diff before you merge it |
40
+
41
+ ---
42
+
43
+ ## `sp lgtm`
44
+
45
+ Generates a few multiple-choice questions about a range of commits and asks
46
+ them, in your terminal. The questions are about what the change *does* — what a
47
+ new guard prevents, what the error path now returns, which edit can touch
48
+ existing rows — never about statistics, naming, or formatting.
49
+
50
+ ```sh
51
+ sp lgtm # what this branch adds since it left main
52
+ sp lgtm main...feature/x # someone else's branch, before you merge it
53
+ sp lgtm -n 3 -d hard
54
+ sp lgtm --dry-run # what it would ask about, no model call
55
+ ```
56
+
57
+ Generation is three model calls, so expect it to take a moment — this is a thing
58
+ you run before a merge, not on every commit.
59
+
60
+ ### Providers
61
+
62
+ Anthropic, OpenAI and Gemini, and no vendor is the assumed one. Install the SDK
63
+ for whichever you use:
64
+
65
+ ```sh
66
+ pip install ".[anthropic]" # or [openai], or [gemini], or [all]
67
+ ```
68
+
69
+ Name nobody and `sp` uses whichever key you have set. With more than one set it
70
+ picks in the order below, which is a tie-break rather than a ranking; name
71
+ `provider:` in `.github/lgtm.yml` to decide it yourself.
72
+
73
+ | Vendor | Key | Default model | Typical run |
74
+ |---|---|---|---|
75
+ | `anthropic` | `ANTHROPIC_API_KEY` | `claude-opus-5` | ~55s |
76
+ | `openai` | `OPENAI_API_KEY` | `gpt-5.5` | ~65s |
77
+ | `gemini` | `GEMINI_API_KEY` or `GOOGLE_API_KEY` | `gemini-pro-latest` | ~115s |
78
+
79
+ ```sh
80
+ sp lgtm -p openai
81
+ sp lgtm -p gemini --model gemini-3.6-flash
82
+ sp lgtm -p anthropic --verifier openai # see below
83
+ ```
84
+
85
+ All three are exercised against live APIs, on a 32KB diff, and the timings above
86
+ are from those runs.
87
+
88
+ The defaults were chosen by listing each vendor's models with a live key. Gemini
89
+ uses the tracking alias because the vendor currently publishes no plain
90
+ `gemini-3.x-pro` — only `-image` variants — so pinning a pro model would mean
91
+ pinning to 2.5 indefinitely. The other two pin, because a default that changes
92
+ underneath a quiz changes what the quiz asks.
93
+
94
+ ### Two vendors are better than one
95
+
96
+ `sp lgtm` writes a question with one call and then asks a second call to refute
97
+ it. A candidate that can't be refuted survives; everything else is dropped.
98
+
99
+ Both calls going to the same model is the weak version of that check — a model
100
+ asked to find fault with its own reasoning mostly doesn't. If you have keys for
101
+ two vendors, split them:
102
+
103
+ ```sh
104
+ sp lgtm --provider anthropic --verifier openai
105
+ ```
106
+
107
+ That is the strongest arrangement available, and it is why the provider layer
108
+ exists rather than a bare `--model` flag. It costs one extra vendor's tokens and
109
+ nothing else — the verifier sees the question and the diff, never the
110
+ proposer's reasoning.
111
+
112
+ It also costs no extra wall-clock in practice: proposing and verifying are
113
+ sequential either way, and the cross-vendor run above came in at 68s against
114
+ 55s for Anthropic alone.
115
+
116
+ ### It is a self-check, not a gate
117
+
118
+ `sp lgtm` has no way to stop you doing anything. The answers live in the same
119
+ process as the questions, on your machine, and you can skip the whole thing.
120
+ That is on purpose: the version that actually gates is the
121
+ [GitHub Action](https://github.com/sparepartslabs/spareparts-lgtm), which asks
122
+ the *reviewer* after they approve and holds a check run open until they answer.
123
+
124
+ What local gets in exchange is the thing the Action can't have: the code is
125
+ checked out. Press `?` on any question to print the hunk it came from. Wrong
126
+ answers are never a failure — you get told which files to look at again, and it
127
+ re-asks, as many times as you like.
128
+
129
+ ### Configuration
130
+
131
+ Reads `.github/lgtm.yml` from the repo you're in, the same file the Action uses,
132
+ so a repo is configured once:
133
+
134
+ ```yaml
135
+ questions: 2 # 1-5
136
+ difficulty: medium # easy | medium | hard
137
+ provider: anthropic # anthropic | openai | gemini, or vendor:model
138
+ verifier: openai # optional; defaults to the proposer
139
+ exemptPaths:
140
+ - "docs/**"
141
+ ```
142
+
143
+ `provider` and `verifier` are read here but validated by the provider layer, so
144
+ a typo is reported with the list of known vendors rather than silently ignored.
145
+
146
+ Keys that only mean something to the Action (`enforce`, `webConcepts`,
147
+ `surfaceReading`, `answerQuestions`, `exemptReviewers`) are accepted and
148
+ ignored. `-n` and `-d` override the file.
149
+
150
+ Lockfiles, `dist/`, `vendor/`, `*.pbxproj` and friends are never quizzed.
151
+
152
+ ### As a git hook
153
+
154
+ ```sh
155
+ sp lgtm install # pre-push, advisory
156
+ sp lgtm install --hook pre-commit # earlier, and once per commit
157
+ sp lgtm install --blocking # wrong answers stop the push
158
+ sp lgtm uninstall
159
+ ```
160
+
161
+ **`pre-push` is the default.** The old framing for this tool was "the person
162
+ answering didn't write the code" — that's why it quizzes a *reviewer*. That
163
+ framing is dated: when a model wrote the diff, nobody in the loop wrote it, and
164
+ the author is as much a reader as anyone. The useful question isn't who typed
165
+ it, it's where the last cheap moment to catch it is — and that's before the code
166
+ leaves your machine, which is left of anything the Action can do.
167
+
168
+ It also matches how the cost lands. Generation is three model calls and about a
169
+ minute; per *push* that's fine, per *commit* it taxes every checkpoint you save.
170
+ `--hook pre-commit` is there if you want it, and quizzes the staged diff.
171
+
172
+ **Advisory by default.** A hook that costs a minute *and* can stop you is one
173
+ you delete within a week. `--blocking` is opt-in, and even then only a wrong
174
+ answer (exit 1) blocks — "couldn't ask" (exit 2: no API key, vendor outage,
175
+ nothing quizzable) never costs you a push.
176
+
177
+ Escape hatches, in the order you'll want them:
178
+
179
+ ```sh
180
+ SP_LGTM_SKIP=1 git push ... # skip this once
181
+ git push --no-verify ... # skip every hook
182
+ ```
183
+
184
+ #### What it reads
185
+
186
+ `pre-push` quizzes exactly what you're about to push. Git names the refs on
187
+ stdin, so the hook takes `<remote sha>..<local sha>` — the commits the remote
188
+ doesn't have yet. A branch the remote has never seen has no such range, so it
189
+ falls back to what the branch adds since it left the default branch. A push that
190
+ only *deletes* a remote branch reads nothing at all.
191
+
192
+ That ref list arrives on the same stdin the quiz needs for answers, so the hook
193
+ consumes it before attaching `/dev/tty`.
194
+
195
+ It skips itself silently when there's no terminal — a rebase, a GUI client, CI.
196
+ Git runs hooks with stdin closed; where there's no tty, nobody can answer and
197
+ nobody failed.
198
+
199
+ `sp lgtm install` refuses to overwrite a hook it didn't write (`--force`
200
+ overrides), and honours `core.hooksPath` — writing to an assumed `.git/hooks`
201
+ when that's set installs a hook that never runs, which looks exactly like
202
+ success.
203
+
204
+ ### Exit codes
205
+
206
+ | Code | Meaning |
207
+ |---|---|
208
+ | 0 | Confirmed |
209
+ | 1 | Not confirmed — wrong answers, or you quit |
210
+ | 2 | Couldn't ask — no API key, git failed, nothing quizzable |
211
+
212
+ If you wire this into a git hook, treat only `1` as a failure. A tool that
213
+ cannot run must not block a commit.
214
+
215
+ ## Development
216
+
217
+ ```sh
218
+ python3 -m venv .venv && .venv/bin/pip install -e ".[dev]"
219
+ .venv/bin/python -m pytest
220
+ ```
221
+
222
+ `lgtm`'s diff parsing, config, screening and generator are a port of the
223
+ TypeScript in
224
+ [`spareparts-lgtm`](https://github.com/sparepartslabs/spareparts-lgtm). The
225
+ prompts in `generator.py` are the part worth keeping identical — changing the
226
+ wording here without changing it there produces two tools that disagree about
227
+ the same diff.