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.
- ninchi_cli-0.2.0/PKG-INFO +138 -0
- ninchi_cli-0.2.0/README.md +113 -0
- ninchi_cli-0.2.0/pyproject.toml +52 -0
- ninchi_cli-0.2.0/setup.cfg +4 -0
- ninchi_cli-0.2.0/src/ninchi_cli/__init__.py +5 -0
- ninchi_cli-0.2.0/src/ninchi_cli/client.py +191 -0
- ninchi_cli-0.2.0/src/ninchi_cli/config.py +79 -0
- ninchi_cli-0.2.0/src/ninchi_cli/diffs.py +66 -0
- ninchi_cli-0.2.0/src/ninchi_cli/main.py +328 -0
- ninchi_cli-0.2.0/src/ninchi_cli/mcp_server.py +300 -0
- ninchi_cli-0.2.0/src/ninchi_cli.egg-info/PKG-INFO +138 -0
- ninchi_cli-0.2.0/src/ninchi_cli.egg-info/SOURCES.txt +19 -0
- ninchi_cli-0.2.0/src/ninchi_cli.egg-info/dependency_links.txt +1 -0
- ninchi_cli-0.2.0/src/ninchi_cli.egg-info/entry_points.txt +2 -0
- ninchi_cli-0.2.0/src/ninchi_cli.egg-info/requires.txt +5 -0
- ninchi_cli-0.2.0/src/ninchi_cli.egg-info/top_level.txt +1 -0
- ninchi_cli-0.2.0/tests/test_client.py +98 -0
- ninchi_cli-0.2.0/tests/test_config.py +30 -0
- ninchi_cli-0.2.0/tests/test_diffs.py +47 -0
- ninchi_cli-0.2.0/tests/test_main.py +65 -0
- ninchi_cli-0.2.0/tests/test_mcp_server.py +215 -0
|
@@ -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,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
|