ninchi-cli 0.2.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.
@@ -0,0 +1,138 @@
1
+ Metadata-Version: 2.4
2
+ Name: ninchi-cli
3
+ Version: 0.2.0
4
+ Summary: Command-line client and MCP server for Ninchi checks
5
+ Author-email: Ninchi <support@ninchi.ai>
6
+ License: Proprietary
7
+ Project-URL: Homepage, https://ninchi.ai
8
+ Project-URL: Documentation, https://ninchi.ai/docs/cli
9
+ Keywords: ninchi,code-review,verification,mcp,cli
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: Other/Proprietary License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Software Development :: Quality Assurance
19
+ Requires-Python: >=3.11
20
+ Description-Content-Type: text/markdown
21
+ Requires-Dist: httpx<1.0,>=0.27
22
+ Requires-Dist: mcp<2,>=1.2
23
+ Provides-Extra: dev
24
+ Requires-Dist: pytest<9,>=8; extra == "dev"
25
+
26
+ # Ninchi CLI
27
+
28
+ Thin HTTP client for running Ninchi checks from a terminal, hook, or CI job.
29
+ The CLI does not import the backend or core engine; it talks to the public API.
30
+
31
+ ## Install
32
+
33
+ ```bash
34
+ pipx install .
35
+ ```
36
+
37
+ From this repo during development:
38
+
39
+ ```bash
40
+ cd cli
41
+ python -m pip install -e ".[dev]"
42
+ ```
43
+
44
+ ## Auth
45
+
46
+ Interactive login uses the Ninchi device-code flow and stores a personal
47
+ access token in `~/.config/ninchi/credentials` with mode `0600`:
48
+
49
+ ```bash
50
+ ninchi login
51
+ ```
52
+
53
+ CI can skip the credentials file:
54
+
55
+ ```bash
56
+ export NINCHI_API_TOKEN="ninchi_pat_example_for_docs"
57
+ ```
58
+
59
+ Use `NINCHI_API_URL` or `--api-url` to target a local/dev backend.
60
+
61
+ ## Checks
62
+
63
+ Submit a diff from stdin and answer the generated challenge interactively:
64
+
65
+ ```bash
66
+ git diff | ninchi check --stdin --org-id "$NINCHI_ORG_ID"
67
+ ```
68
+
69
+ Other subject sources:
70
+
71
+ ```bash
72
+ ninchi check --staged --org-id "$NINCHI_ORG_ID"
73
+ ninchi check --commit HEAD --org-id "$NINCHI_ORG_ID"
74
+ ninchi check --file src/app.py --language python --org-id "$NINCHI_ORG_ID"
75
+ ```
76
+
77
+ Automation mode uses JSON output and exit codes:
78
+
79
+ ```bash
80
+ git diff --staged | ninchi check --stdin --answer "$ANSWER" --json --org-id "$NINCHI_ORG_ID"
81
+ ```
82
+
83
+ Exit codes:
84
+
85
+ - `0`: challenge passed
86
+ - `1`: challenge failed
87
+ - `2`: pending, configuration, auth, API, or other operational error
88
+
89
+ When an answer is typed interactively, the CLI submits
90
+ `answering_actor=human_attested`. When `--answer` is supplied or the process has
91
+ no terminal to prompt from, it submits `answering_actor=agent_assisted` unless
92
+ `--actor` overrides it. The server enforces org policy.
93
+
94
+ ## Pre-push Hook
95
+
96
+ Example `.git/hooks/pre-push`:
97
+
98
+ ```bash
99
+ #!/usr/bin/env bash
100
+ set -euo pipefail
101
+
102
+ base="${NINCHI_BASE_REF:-origin/main}"
103
+ git diff --patch "$base"...HEAD | ninchi check \
104
+ --stdin \
105
+ --org-id "${NINCHI_ORG_ID:?set NINCHI_ORG_ID}" \
106
+ ```
107
+
108
+ For human-attested hook usage, omit `--answer` and let the CLI prompt in the
109
+ terminal.
110
+
111
+ ## MCP Server (`ninchi mcp`)
112
+
113
+ The same client, served over the Model Context Protocol for agent-capable
114
+ editors (Cursor, Claude Code, etc.). Authenticate once with `ninchi login`,
115
+ then register the server — e.g. in Cursor (`.cursor/mcp.json` or global MCP
116
+ settings):
117
+
118
+ ```json
119
+ {
120
+ "mcpServers": {
121
+ "ninchi": { "command": "ninchi", "args": ["mcp"] }
122
+ }
123
+ }
124
+ ```
125
+
126
+ Tools: `submit_check`, `get_challenge`, `submit_answer`, `get_result`,
127
+ `list_history`.
128
+
129
+ Two things agents (and their humans) should know:
130
+
131
+ - `submit_answer` requires an honest `actor` argument
132
+ (`human_attested` / `agent_assisted` / `agent_autonomous`). Provenance is
133
+ recorded with the attempt; organizations may block agent-authored answers
134
+ by policy, in which case the tool returns guidance to hand the question to
135
+ the human. There is no client-side human-only rule — policy lives with the
136
+ org.
137
+ - Submissions carry `client_attribution: "mcp:<client-name>"` from the MCP
138
+ handshake, which feeds Ninchi's AI-authorship analytics.
@@ -0,0 +1,113 @@
1
+ # Ninchi CLI
2
+
3
+ Thin HTTP client for running Ninchi checks from a terminal, hook, or CI job.
4
+ The CLI does not import the backend or core engine; it talks to the public API.
5
+
6
+ ## Install
7
+
8
+ ```bash
9
+ pipx install .
10
+ ```
11
+
12
+ From this repo during development:
13
+
14
+ ```bash
15
+ cd cli
16
+ python -m pip install -e ".[dev]"
17
+ ```
18
+
19
+ ## Auth
20
+
21
+ Interactive login uses the Ninchi device-code flow and stores a personal
22
+ access token in `~/.config/ninchi/credentials` with mode `0600`:
23
+
24
+ ```bash
25
+ ninchi login
26
+ ```
27
+
28
+ CI can skip the credentials file:
29
+
30
+ ```bash
31
+ export NINCHI_API_TOKEN="ninchi_pat_example_for_docs"
32
+ ```
33
+
34
+ Use `NINCHI_API_URL` or `--api-url` to target a local/dev backend.
35
+
36
+ ## Checks
37
+
38
+ Submit a diff from stdin and answer the generated challenge interactively:
39
+
40
+ ```bash
41
+ git diff | ninchi check --stdin --org-id "$NINCHI_ORG_ID"
42
+ ```
43
+
44
+ Other subject sources:
45
+
46
+ ```bash
47
+ ninchi check --staged --org-id "$NINCHI_ORG_ID"
48
+ ninchi check --commit HEAD --org-id "$NINCHI_ORG_ID"
49
+ ninchi check --file src/app.py --language python --org-id "$NINCHI_ORG_ID"
50
+ ```
51
+
52
+ Automation mode uses JSON output and exit codes:
53
+
54
+ ```bash
55
+ git diff --staged | ninchi check --stdin --answer "$ANSWER" --json --org-id "$NINCHI_ORG_ID"
56
+ ```
57
+
58
+ Exit codes:
59
+
60
+ - `0`: challenge passed
61
+ - `1`: challenge failed
62
+ - `2`: pending, configuration, auth, API, or other operational error
63
+
64
+ When an answer is typed interactively, the CLI submits
65
+ `answering_actor=human_attested`. When `--answer` is supplied or the process has
66
+ no terminal to prompt from, it submits `answering_actor=agent_assisted` unless
67
+ `--actor` overrides it. The server enforces org policy.
68
+
69
+ ## Pre-push Hook
70
+
71
+ Example `.git/hooks/pre-push`:
72
+
73
+ ```bash
74
+ #!/usr/bin/env bash
75
+ set -euo pipefail
76
+
77
+ base="${NINCHI_BASE_REF:-origin/main}"
78
+ git diff --patch "$base"...HEAD | ninchi check \
79
+ --stdin \
80
+ --org-id "${NINCHI_ORG_ID:?set NINCHI_ORG_ID}" \
81
+ ```
82
+
83
+ For human-attested hook usage, omit `--answer` and let the CLI prompt in the
84
+ terminal.
85
+
86
+ ## MCP Server (`ninchi mcp`)
87
+
88
+ The same client, served over the Model Context Protocol for agent-capable
89
+ editors (Cursor, Claude Code, etc.). Authenticate once with `ninchi login`,
90
+ then register the server — e.g. in Cursor (`.cursor/mcp.json` or global MCP
91
+ settings):
92
+
93
+ ```json
94
+ {
95
+ "mcpServers": {
96
+ "ninchi": { "command": "ninchi", "args": ["mcp"] }
97
+ }
98
+ }
99
+ ```
100
+
101
+ Tools: `submit_check`, `get_challenge`, `submit_answer`, `get_result`,
102
+ `list_history`.
103
+
104
+ Two things agents (and their humans) should know:
105
+
106
+ - `submit_answer` requires an honest `actor` argument
107
+ (`human_attested` / `agent_assisted` / `agent_autonomous`). Provenance is
108
+ recorded with the attempt; organizations may block agent-authored answers
109
+ by policy, in which case the tool returns guidance to hand the question to
110
+ the human. There is no client-side human-only rule — policy lives with the
111
+ org.
112
+ - Submissions carry `client_attribution: "mcp:<client-name>"` from the MCP
113
+ handshake, which feeds Ninchi's AI-authorship analytics.
@@ -0,0 +1,52 @@
1
+ [build-system]
2
+ requires = ["setuptools>=69", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "ninchi-cli"
7
+ version = "0.2.0"
8
+ description = "Command-line client and MCP server for Ninchi checks"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ # License decision pending founder sign-off before first publish: Proprietary
12
+ # is the conservative default; flipping to MIT/Apache-2.0 is a one-line change.
13
+ license = { text = "Proprietary" }
14
+ authors = [{ name = "Ninchi", email = "support@ninchi.ai" }]
15
+ keywords = ["ninchi", "code-review", "verification", "mcp", "cli"]
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "Environment :: Console",
19
+ "Intended Audience :: Developers",
20
+ "License :: Other/Proprietary License",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ "Topic :: Software Development :: Quality Assurance",
26
+ ]
27
+
28
+ dependencies = [
29
+ "httpx>=0.27,<1.0",
30
+ # ENT-040: `ninchi mcp` serves the same client over the Model Context
31
+ # Protocol. Hard dependency so a pipx install is MCP-ready out of the box.
32
+ "mcp>=1.2,<2",
33
+ ]
34
+
35
+ [project.urls]
36
+ Homepage = "https://ninchi.ai"
37
+ Documentation = "https://ninchi.ai/docs/cli"
38
+
39
+ [project.optional-dependencies]
40
+ dev = [
41
+ "pytest>=8,<9",
42
+ ]
43
+
44
+ [project.scripts]
45
+ ninchi = "ninchi_cli.main:main"
46
+
47
+ [tool.setuptools.packages.find]
48
+ where = ["src"]
49
+
50
+ [tool.pytest.ini_options]
51
+ testpaths = ["tests"]
52
+ pythonpath = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,5 @@
1
+ """Ninchi command-line client."""
2
+
3
+ __all__ = ["__version__"]
4
+
5
+ __version__ = "0.1.0"
@@ -0,0 +1,191 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from dataclasses import dataclass
5
+ from typing import Any
6
+
7
+ import httpx
8
+
9
+
10
+ class NinchiAPIError(RuntimeError):
11
+ def __init__(self, message: str, *, status_code: int | None = None, payload: Any = None):
12
+ super().__init__(message)
13
+ self.status_code = status_code
14
+ self.payload = payload
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class DeviceCode:
19
+ device_code: str
20
+ user_code: str
21
+ verification_url: str
22
+ interval: int
23
+ expires_in: int | None = None
24
+
25
+
26
+ def _detail(response: httpx.Response) -> str:
27
+ try:
28
+ payload = response.json()
29
+ except ValueError:
30
+ return response.text.strip() or response.reason_phrase
31
+ detail = payload.get("detail") if isinstance(payload, dict) else None
32
+ if isinstance(detail, str):
33
+ return detail
34
+ if detail is not None:
35
+ return str(detail)
36
+ return response.reason_phrase
37
+
38
+
39
+ class NinchiClient:
40
+ def __init__(
41
+ self,
42
+ *,
43
+ api_url: str,
44
+ token: str | None = None,
45
+ timeout: float = 30.0,
46
+ transport: httpx.BaseTransport | None = None,
47
+ ):
48
+ self.api_url = api_url.rstrip("/")
49
+ self.token = token
50
+ self._client = httpx.Client(base_url=self.api_url, timeout=timeout, transport=transport)
51
+
52
+ def close(self) -> None:
53
+ self._client.close()
54
+
55
+ def __enter__(self) -> "NinchiClient":
56
+ return self
57
+
58
+ def __exit__(self, *_exc) -> None:
59
+ self.close()
60
+
61
+ def _request(
62
+ self,
63
+ method: str,
64
+ path: str,
65
+ *,
66
+ json: dict[str, Any] | None = None,
67
+ params: dict[str, Any] | None = None,
68
+ expected: tuple[int, ...] = (200,),
69
+ allow: tuple[int, ...] = (),
70
+ ) -> httpx.Response:
71
+ headers = {}
72
+ if self.token:
73
+ headers["Authorization"] = f"Bearer {self.token}"
74
+ try:
75
+ response = self._client.request(method, path, json=json, params=params, headers=headers)
76
+ except httpx.HTTPError as exc:
77
+ raise NinchiAPIError(f"HTTP request failed: {exc}") from exc
78
+ if response.status_code in expected or response.status_code in allow:
79
+ return response
80
+ raise NinchiAPIError(_detail(response), status_code=response.status_code, payload=_safe_json(response))
81
+
82
+ def start_device_login(self) -> DeviceCode:
83
+ response = self._request(
84
+ "POST",
85
+ "/auth/device/code",
86
+ json={"client_label": "Ninchi CLI"},
87
+ expected=(200, 201),
88
+ )
89
+ payload = response.json()
90
+ verification_url = (
91
+ payload.get("verification_url")
92
+ or payload.get("verification_uri_complete")
93
+ or payload.get("verification_uri")
94
+ or payload.get("user_code_url")
95
+ )
96
+ if not payload.get("device_code") or not payload.get("user_code") or not verification_url:
97
+ raise NinchiAPIError("Device login response was missing required fields.", payload=payload)
98
+ return DeviceCode(
99
+ device_code=payload["device_code"],
100
+ user_code=payload["user_code"],
101
+ verification_url=verification_url,
102
+ interval=int(payload.get("interval") or 5),
103
+ expires_in=payload.get("expires_in"),
104
+ )
105
+
106
+ def poll_device_token(self, device_code: str) -> str | None:
107
+ response = self._request(
108
+ "POST",
109
+ "/auth/device/token",
110
+ json={"device_code": device_code},
111
+ expected=(200,),
112
+ allow=(428,),
113
+ )
114
+ if response.status_code == 428:
115
+ return None
116
+ payload = response.json()
117
+ token = payload.get("access_token") or payload.get("token") or payload.get("api_token")
118
+ if not token:
119
+ raise NinchiAPIError("Device token response was missing a token.", payload=payload)
120
+ return token
121
+
122
+ def wait_for_device_token(self, code: DeviceCode, *, timeout_seconds: int = 600) -> str:
123
+ deadline = time.monotonic() + timeout_seconds
124
+ while time.monotonic() < deadline:
125
+ token = self.poll_device_token(code.device_code)
126
+ if token:
127
+ return token
128
+ time.sleep(max(1, code.interval))
129
+ raise NinchiAPIError("Timed out waiting for device approval.")
130
+
131
+ def submit_check(self, org_id: str, payload: dict[str, Any]) -> str:
132
+ response = self._request("POST", f"/orgs/{org_id}/checks", json=payload, expected=(202,))
133
+ body = response.json()
134
+ challenge_id = body.get("challenge_id") or body.get("id")
135
+ if not challenge_id:
136
+ raise NinchiAPIError("Check submission response was missing challenge_id.", payload=body)
137
+ return str(challenge_id)
138
+
139
+ def get_challenge(self, challenge_id: str) -> dict[str, Any] | None:
140
+ response = self._request("GET", f"/challenges/{challenge_id}", allow=(202,))
141
+ if response.status_code == 202:
142
+ return None
143
+ return response.json()
144
+
145
+ def wait_for_challenge(self, challenge_id: str, *, interval: float = 2.0, timeout_seconds: int = 600) -> dict[str, Any]:
146
+ deadline = time.monotonic() + timeout_seconds
147
+ while time.monotonic() < deadline:
148
+ challenge = self.get_challenge(challenge_id)
149
+ if challenge is not None:
150
+ return challenge
151
+ time.sleep(interval)
152
+ raise NinchiAPIError("Timed out waiting for challenge generation.")
153
+
154
+ def submit_answer(
155
+ self,
156
+ challenge_id: str,
157
+ *,
158
+ answer_text: str,
159
+ answering_actor: str,
160
+ time_taken_seconds: int | None = None,
161
+ ) -> dict[str, Any]:
162
+ payload: dict[str, Any] = {
163
+ "answer_text": answer_text,
164
+ "answering_actor": answering_actor,
165
+ }
166
+ if time_taken_seconds is not None:
167
+ payload["time_taken_seconds"] = time_taken_seconds
168
+ response = self._request("POST", f"/challenges/{challenge_id}/answer", json=payload, expected=(202,))
169
+ return response.json()
170
+
171
+ def get_result(self, challenge_id: str) -> dict[str, Any]:
172
+ return self._request("GET", f"/challenges/{challenge_id}/result").json()
173
+
174
+ def wait_for_result(self, challenge_id: str, *, interval: float = 2.0, timeout_seconds: int = 600) -> dict[str, Any]:
175
+ deadline = time.monotonic() + timeout_seconds
176
+ while time.monotonic() < deadline:
177
+ result = self.get_result(challenge_id)
178
+ if result.get("status") in {"passed", "failed", "expired", "error"}:
179
+ return result
180
+ time.sleep(interval)
181
+ raise NinchiAPIError("Timed out waiting for challenge result.")
182
+
183
+ def history(self, *, limit: int = 20) -> dict[str, Any]:
184
+ return self._request("GET", "/analytics/me/challenges", params={"limit": limit}).json()
185
+
186
+
187
+ def _safe_json(response: httpx.Response) -> Any:
188
+ try:
189
+ return response.json()
190
+ except ValueError:
191
+ return None
@@ -0,0 +1,79 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+
9
+ DEFAULT_API_URL = "https://api.ninchi.ai"
10
+ ENV_API_URL = "NINCHI_API_URL"
11
+ ENV_TOKEN = "NINCHI_API_TOKEN"
12
+ ENV_CONFIG_DIR = "NINCHI_CONFIG_DIR"
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class Credentials:
17
+ token: str | None
18
+ api_url: str
19
+ source: str
20
+
21
+
22
+ def config_dir() -> Path:
23
+ override = os.environ.get(ENV_CONFIG_DIR)
24
+ if override:
25
+ return Path(override).expanduser()
26
+ xdg_home = os.environ.get("XDG_CONFIG_HOME")
27
+ if xdg_home:
28
+ return Path(xdg_home).expanduser() / "ninchi"
29
+ return Path.home() / ".config" / "ninchi"
30
+
31
+
32
+ def credentials_path() -> Path:
33
+ return config_dir() / "credentials"
34
+
35
+
36
+ def _read_file(path: Path) -> Credentials | None:
37
+ if not path.exists():
38
+ return None
39
+ raw = path.read_text(encoding="utf-8").strip()
40
+ if not raw:
41
+ return None
42
+ if raw.startswith("{"):
43
+ payload = json.loads(raw)
44
+ return Credentials(
45
+ token=payload.get("token"),
46
+ api_url=payload.get("api_url") or DEFAULT_API_URL,
47
+ source=str(path),
48
+ )
49
+ return Credentials(token=raw, api_url=DEFAULT_API_URL, source=str(path))
50
+
51
+
52
+ def load_credentials(path: Path | None = None) -> Credentials:
53
+ path = path or credentials_path()
54
+ file_creds = _read_file(path)
55
+ api_url = os.environ.get(ENV_API_URL) or (file_creds.api_url if file_creds else DEFAULT_API_URL)
56
+ token = os.environ.get(ENV_TOKEN)
57
+ if token:
58
+ return Credentials(token=token, api_url=api_url, source=ENV_TOKEN)
59
+ if file_creds:
60
+ return Credentials(token=file_creds.token, api_url=api_url, source=file_creds.source)
61
+ return Credentials(token=None, api_url=api_url, source="none")
62
+
63
+
64
+ def save_credentials(token: str, api_url: str, path: Path | None = None) -> Path:
65
+ path = path or credentials_path()
66
+ path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
67
+ try:
68
+ path.parent.chmod(0o700)
69
+ except PermissionError:
70
+ # Best effort on shared/dev filesystems.
71
+ pass
72
+
73
+ tmp = path.with_name(f"{path.name}.tmp")
74
+ payload = {"api_url": api_url, "token": token}
75
+ tmp.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
76
+ tmp.chmod(0o600)
77
+ tmp.replace(path)
78
+ path.chmod(0o600)
79
+ return path
@@ -0,0 +1,66 @@
1
+ from __future__ import annotations
2
+
3
+ import subprocess
4
+ import sys
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+
9
+ class DiffError(RuntimeError):
10
+ pass
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class CheckSubject:
15
+ payload: dict
16
+ label: str
17
+
18
+
19
+ def collect_check_subject(args) -> CheckSubject:
20
+ if args.file:
21
+ path = Path(args.file)
22
+ content = path.read_text(encoding="utf-8")
23
+ if not content.strip():
24
+ raise DiffError(f"{path} is empty.")
25
+ region = {"path": str(path), "content": content}
26
+ if args.language:
27
+ region["language"] = args.language
28
+ return CheckSubject(payload={"code_region": region}, label=str(path))
29
+
30
+ if args.stdin:
31
+ diff_text = sys.stdin.read()
32
+ return _diff_subject(diff_text, "stdin")
33
+
34
+ if args.commit:
35
+ diff_text = _git(["show", "--format=", "--no-ext-diff", "--unified=3", args.commit])
36
+ return _diff_subject(diff_text, args.commit)
37
+
38
+ if args.staged:
39
+ diff_text = _git(["diff", "--staged", "--no-ext-diff"])
40
+ return _diff_subject(diff_text, "staged diff")
41
+
42
+ diff_text = _git(["diff", "HEAD", "--no-ext-diff"])
43
+ return _diff_subject(diff_text, "working tree diff")
44
+
45
+
46
+ def _diff_subject(diff_text: str, label: str) -> CheckSubject:
47
+ if not diff_text.strip():
48
+ raise DiffError(f"No diff content found for {label}.")
49
+ return CheckSubject(payload={"diff_text": diff_text}, label=label)
50
+
51
+
52
+ def _git(args: list[str]) -> str:
53
+ try:
54
+ completed = subprocess.run(
55
+ ["git", *args],
56
+ check=True,
57
+ stdout=subprocess.PIPE,
58
+ stderr=subprocess.PIPE,
59
+ text=True,
60
+ )
61
+ except FileNotFoundError as exc:
62
+ raise DiffError("git is not installed or not on PATH.") from exc
63
+ except subprocess.CalledProcessError as exc:
64
+ message = (exc.stderr or exc.stdout or "git command failed").strip()
65
+ raise DiffError(message) from exc
66
+ return completed.stdout