ninchi-cli 0.2.0__py3-none-any.whl
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/__init__.py +5 -0
- ninchi_cli/client.py +191 -0
- ninchi_cli/config.py +79 -0
- ninchi_cli/diffs.py +66 -0
- ninchi_cli/main.py +328 -0
- ninchi_cli/mcp_server.py +300 -0
- ninchi_cli-0.2.0.dist-info/METADATA +138 -0
- ninchi_cli-0.2.0.dist-info/RECORD +11 -0
- ninchi_cli-0.2.0.dist-info/WHEEL +5 -0
- ninchi_cli-0.2.0.dist-info/entry_points.txt +2 -0
- ninchi_cli-0.2.0.dist-info/top_level.txt +1 -0
ninchi_cli/__init__.py
ADDED
ninchi_cli/client.py
ADDED
|
@@ -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
|
ninchi_cli/config.py
ADDED
|
@@ -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
|
ninchi_cli/diffs.py
ADDED
|
@@ -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
|
ninchi_cli/main.py
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import textwrap
|
|
8
|
+
import time
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .client import NinchiAPIError, NinchiClient
|
|
12
|
+
from .config import DEFAULT_API_URL, load_credentials, save_credentials
|
|
13
|
+
from .diffs import DiffError, collect_check_subject
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
EXIT_PASS = 0
|
|
17
|
+
EXIT_FAIL = 1
|
|
18
|
+
EXIT_PENDING_OR_ERROR = 2
|
|
19
|
+
|
|
20
|
+
ACTORS = ("human_attested", "agent_assisted", "agent_autonomous")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class CLIError(RuntimeError):
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def main(argv: list[str] | None = None) -> int:
|
|
28
|
+
parser = build_parser()
|
|
29
|
+
args = parser.parse_args(argv)
|
|
30
|
+
if not hasattr(args, "func"):
|
|
31
|
+
parser.print_help()
|
|
32
|
+
return EXIT_PENDING_OR_ERROR
|
|
33
|
+
try:
|
|
34
|
+
return args.func(args)
|
|
35
|
+
except (CLIError, DiffError, NinchiAPIError) as exc:
|
|
36
|
+
return fail(exc, json_output=getattr(args, "json", False))
|
|
37
|
+
except KeyboardInterrupt:
|
|
38
|
+
return fail(CLIError("Interrupted."), json_output=getattr(args, "json", False))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
42
|
+
common = argparse.ArgumentParser(add_help=False)
|
|
43
|
+
common.add_argument("--api-url", default=None, help=f"Ninchi API URL. Default: {DEFAULT_API_URL}")
|
|
44
|
+
common.add_argument("--json", action="store_true", help="Emit machine-readable JSON.")
|
|
45
|
+
|
|
46
|
+
parser = argparse.ArgumentParser(prog="ninchi", description="Ninchi command-line client")
|
|
47
|
+
sub = parser.add_subparsers(dest="command")
|
|
48
|
+
|
|
49
|
+
login = sub.add_parser("login", parents=[common], help="Authenticate with the Ninchi device flow.")
|
|
50
|
+
login.add_argument("--timeout", type=int, default=600, help="Seconds to wait for approval.")
|
|
51
|
+
login.set_defaults(func=cmd_login)
|
|
52
|
+
|
|
53
|
+
check = sub.add_parser("check", parents=[common], help="Submit a local subject and answer its challenge.")
|
|
54
|
+
source = check.add_mutually_exclusive_group()
|
|
55
|
+
source.add_argument("--stdin", action="store_true", help="Read a diff from stdin.")
|
|
56
|
+
source.add_argument("--staged", action="store_true", help="Use git diff --staged.")
|
|
57
|
+
source.add_argument("--commit", metavar="SHA", help="Use a single commit diff.")
|
|
58
|
+
source.add_argument("--file", metavar="PATH", help="Submit one file as a code region.")
|
|
59
|
+
check.add_argument("--org-id", default=None, help="Target organization ID. Defaults to NINCHI_ORG_ID.")
|
|
60
|
+
check.add_argument("--language", default=None, help="Language hint for --file.")
|
|
61
|
+
check.add_argument("--answer", default=None, help="Answer non-interactively.")
|
|
62
|
+
check.add_argument("--actor", choices=ACTORS, default=None, help="Answering actor provenance override.")
|
|
63
|
+
check.add_argument("--poll-interval", type=float, default=2.0, help="Polling interval in seconds.")
|
|
64
|
+
check.add_argument("--timeout", type=int, default=600, help="Seconds to wait for generation/evaluation.")
|
|
65
|
+
check.set_defaults(func=cmd_check)
|
|
66
|
+
|
|
67
|
+
result = sub.add_parser("result", parents=[common], help="Fetch a challenge result.")
|
|
68
|
+
result.add_argument("challenge_id")
|
|
69
|
+
result.set_defaults(func=cmd_result)
|
|
70
|
+
|
|
71
|
+
history = sub.add_parser("history", parents=[common], help="List your recent challenge history.")
|
|
72
|
+
history.add_argument("--limit", type=int, default=20)
|
|
73
|
+
history.set_defaults(func=cmd_history)
|
|
74
|
+
|
|
75
|
+
mcp = sub.add_parser(
|
|
76
|
+
"mcp",
|
|
77
|
+
help="Run the Ninchi MCP server over stdio (for Cursor, Claude Code, etc.).",
|
|
78
|
+
)
|
|
79
|
+
mcp.set_defaults(func=cmd_mcp)
|
|
80
|
+
|
|
81
|
+
return parser
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def cmd_mcp(args) -> int:
|
|
85
|
+
# Lazy import: the MCP SDK is only loaded when actually serving, so every
|
|
86
|
+
# other command stays fast and works even if the SDK install is broken.
|
|
87
|
+
try:
|
|
88
|
+
from .mcp_server import run_stdio
|
|
89
|
+
except ImportError as exc:
|
|
90
|
+
raise CLIError(
|
|
91
|
+
f"MCP support unavailable ({exc}). Reinstall the package: "
|
|
92
|
+
"pipx install ninchi-cli"
|
|
93
|
+
) from exc
|
|
94
|
+
return run_stdio()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def cmd_login(args) -> int:
|
|
98
|
+
api_url = resolve_api_url(args)
|
|
99
|
+
with NinchiClient(api_url=api_url) as client:
|
|
100
|
+
code = client.start_device_login()
|
|
101
|
+
if args.json:
|
|
102
|
+
emit_json({
|
|
103
|
+
"ok": True,
|
|
104
|
+
"user_code": code.user_code,
|
|
105
|
+
"verification_url": code.verification_url,
|
|
106
|
+
"interval": code.interval,
|
|
107
|
+
"expires_in": code.expires_in,
|
|
108
|
+
})
|
|
109
|
+
else:
|
|
110
|
+
print("Ninchi device login")
|
|
111
|
+
print()
|
|
112
|
+
print(f"Open: {code.verification_url}")
|
|
113
|
+
print(f"Code: {code.user_code}")
|
|
114
|
+
print()
|
|
115
|
+
print("Waiting for approval...")
|
|
116
|
+
token = client.wait_for_device_token(code, timeout_seconds=args.timeout)
|
|
117
|
+
path = save_credentials(token, api_url)
|
|
118
|
+
if args.json:
|
|
119
|
+
emit_json({"ok": True, "credentials_path": str(path), "api_url": api_url})
|
|
120
|
+
else:
|
|
121
|
+
print(f"Saved credentials to {path}")
|
|
122
|
+
return EXIT_PASS
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def cmd_check(args) -> int:
|
|
126
|
+
org_id = args.org_id or os.environ.get("NINCHI_ORG_ID")
|
|
127
|
+
if not org_id:
|
|
128
|
+
raise CLIError("Missing --org-id or NINCHI_ORG_ID.")
|
|
129
|
+
|
|
130
|
+
creds = resolve_credentials(args)
|
|
131
|
+
require_token(creds.token)
|
|
132
|
+
subject = collect_check_subject(args)
|
|
133
|
+
payload = dict(subject.payload)
|
|
134
|
+
payload["client_attribution"] = "cli"
|
|
135
|
+
|
|
136
|
+
with NinchiClient(api_url=creds.api_url, token=creds.token) as client:
|
|
137
|
+
challenge_id = client.submit_check(org_id, payload)
|
|
138
|
+
if not args.json:
|
|
139
|
+
print(f"Submitted {subject.label}. Challenge: {challenge_id}")
|
|
140
|
+
print("Waiting for question...")
|
|
141
|
+
challenge = client.wait_for_challenge(
|
|
142
|
+
challenge_id,
|
|
143
|
+
interval=args.poll_interval,
|
|
144
|
+
timeout_seconds=args.timeout,
|
|
145
|
+
)
|
|
146
|
+
if args.json and args.answer is None:
|
|
147
|
+
emit_json({
|
|
148
|
+
"ok": False,
|
|
149
|
+
"challenge": challenge,
|
|
150
|
+
"error": "Pass --answer to complete a check in JSON mode.",
|
|
151
|
+
})
|
|
152
|
+
return EXIT_PENDING_OR_ERROR
|
|
153
|
+
|
|
154
|
+
if not args.json:
|
|
155
|
+
render_question(challenge)
|
|
156
|
+
|
|
157
|
+
started = time.monotonic()
|
|
158
|
+
answer, prompted = resolve_answer(args)
|
|
159
|
+
if not answer.strip():
|
|
160
|
+
raise CLIError("Answer cannot be empty.")
|
|
161
|
+
actor = args.actor or ("human_attested" if prompted else "agent_assisted")
|
|
162
|
+
elapsed = max(0, int(time.monotonic() - started))
|
|
163
|
+
client.submit_answer(
|
|
164
|
+
challenge_id,
|
|
165
|
+
answer_text=answer,
|
|
166
|
+
answering_actor=actor,
|
|
167
|
+
time_taken_seconds=elapsed,
|
|
168
|
+
)
|
|
169
|
+
if not args.json:
|
|
170
|
+
print("Answer submitted. Waiting for result...")
|
|
171
|
+
result = client.wait_for_result(
|
|
172
|
+
challenge_id,
|
|
173
|
+
interval=args.poll_interval,
|
|
174
|
+
timeout_seconds=args.timeout,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
exit_code = result_exit_code(result)
|
|
178
|
+
if args.json:
|
|
179
|
+
emit_json({"ok": exit_code == EXIT_PASS, "challenge": challenge, "result": result, "actor": actor})
|
|
180
|
+
else:
|
|
181
|
+
render_result(result)
|
|
182
|
+
return exit_code
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def cmd_result(args) -> int:
|
|
186
|
+
creds = resolve_credentials(args)
|
|
187
|
+
require_token(creds.token)
|
|
188
|
+
with NinchiClient(api_url=creds.api_url, token=creds.token) as client:
|
|
189
|
+
result = client.get_result(args.challenge_id)
|
|
190
|
+
if args.json:
|
|
191
|
+
emit_json({"ok": result_exit_code(result) == EXIT_PASS, "result": result})
|
|
192
|
+
else:
|
|
193
|
+
render_result(result)
|
|
194
|
+
return result_exit_code(result)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def cmd_history(args) -> int:
|
|
198
|
+
creds = resolve_credentials(args)
|
|
199
|
+
require_token(creds.token)
|
|
200
|
+
with NinchiClient(api_url=creds.api_url, token=creds.token) as client:
|
|
201
|
+
history = client.history(limit=args.limit)
|
|
202
|
+
if args.json:
|
|
203
|
+
emit_json({"ok": True, "history": history})
|
|
204
|
+
else:
|
|
205
|
+
items = history.get("items") if isinstance(history, dict) else history
|
|
206
|
+
if not items:
|
|
207
|
+
print("No challenges found.")
|
|
208
|
+
return EXIT_PASS
|
|
209
|
+
for item in items:
|
|
210
|
+
challenge_id = item.get("challenge_id") or item.get("id")
|
|
211
|
+
status = item.get("status", "unknown")
|
|
212
|
+
title = item.get("title") or item.get("pr_title") or item.get("source_title") or "Untitled"
|
|
213
|
+
print(f"{challenge_id} {status:>9} {title}")
|
|
214
|
+
return EXIT_PASS
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def resolve_credentials(args):
|
|
218
|
+
creds = load_credentials()
|
|
219
|
+
api_url = resolve_api_url(args, fallback=creds.api_url)
|
|
220
|
+
return type(creds)(token=creds.token, api_url=api_url, source=creds.source)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def resolve_api_url(args, *, fallback: str | None = None) -> str:
|
|
224
|
+
return args.api_url or os.environ.get("NINCHI_API_URL") or fallback or DEFAULT_API_URL
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def require_token(token: str | None) -> None:
|
|
228
|
+
if not token:
|
|
229
|
+
raise CLIError("Not authenticated. Run `ninchi login` or set NINCHI_API_TOKEN.")
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def resolve_answer(args) -> tuple[str, bool]:
|
|
233
|
+
if args.answer is not None:
|
|
234
|
+
return args.answer, False
|
|
235
|
+
if not can_prompt():
|
|
236
|
+
raise CLIError("No terminal available for an interactive answer. Pass --answer for automation.")
|
|
237
|
+
return prompt_multiline("Your answer (press Enter twice when done):"), True
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def can_prompt() -> bool:
|
|
241
|
+
if sys.stdin.isatty():
|
|
242
|
+
return True
|
|
243
|
+
try:
|
|
244
|
+
with open("/dev/tty", "r", encoding="utf-8"):
|
|
245
|
+
return True
|
|
246
|
+
except OSError:
|
|
247
|
+
return False
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def prompt_multiline(prompt: str) -> str:
|
|
251
|
+
print(prompt)
|
|
252
|
+
print()
|
|
253
|
+
lines: list[str] = []
|
|
254
|
+
if sys.stdin.isatty():
|
|
255
|
+
stream = sys.stdin
|
|
256
|
+
close = False
|
|
257
|
+
else:
|
|
258
|
+
stream = open("/dev/tty", "r", encoding="utf-8")
|
|
259
|
+
close = True
|
|
260
|
+
try:
|
|
261
|
+
while True:
|
|
262
|
+
line = stream.readline()
|
|
263
|
+
if line == "":
|
|
264
|
+
break
|
|
265
|
+
line = line.rstrip("\n")
|
|
266
|
+
if line == "" and lines and lines[-1] == "":
|
|
267
|
+
break
|
|
268
|
+
lines.append(line)
|
|
269
|
+
finally:
|
|
270
|
+
if close:
|
|
271
|
+
stream.close()
|
|
272
|
+
return "\n".join(lines).strip()
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def render_question(challenge: dict[str, Any]) -> None:
|
|
276
|
+
print()
|
|
277
|
+
print("=" * 72)
|
|
278
|
+
print(f"Difficulty: {challenge.get('difficulty', 'unknown')}")
|
|
279
|
+
target = challenge.get("target_filename") or challenge.get("source_title")
|
|
280
|
+
if target:
|
|
281
|
+
print(f"Target: {target}")
|
|
282
|
+
tags = challenge.get("tags") or []
|
|
283
|
+
if tags:
|
|
284
|
+
print(f"Tags: {', '.join(tags)}")
|
|
285
|
+
print("-" * 72)
|
|
286
|
+
question = challenge.get("question_text") or ""
|
|
287
|
+
for line in textwrap.wrap(question, width=72):
|
|
288
|
+
print(line)
|
|
289
|
+
print("=" * 72)
|
|
290
|
+
print()
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def render_result(result: dict[str, Any]) -> None:
|
|
294
|
+
status = result.get("status", "unknown")
|
|
295
|
+
score = result.get("score")
|
|
296
|
+
print()
|
|
297
|
+
print(f"Status: {status}")
|
|
298
|
+
if score is not None:
|
|
299
|
+
print(f"Score: {score}/100")
|
|
300
|
+
feedback = result.get("feedback")
|
|
301
|
+
if feedback:
|
|
302
|
+
print()
|
|
303
|
+
print(feedback)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def result_exit_code(result: dict[str, Any]) -> int:
|
|
307
|
+
status = result.get("status")
|
|
308
|
+
if status == "passed" or result.get("passed") is True:
|
|
309
|
+
return EXIT_PASS
|
|
310
|
+
if status == "failed" or result.get("passed") is False:
|
|
311
|
+
return EXIT_FAIL
|
|
312
|
+
return EXIT_PENDING_OR_ERROR
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def emit_json(payload: dict[str, Any]) -> None:
|
|
316
|
+
print(json.dumps(payload, sort_keys=True))
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def fail(exc: Exception, *, json_output: bool) -> int:
|
|
320
|
+
if json_output:
|
|
321
|
+
emit_json({"ok": False, "error": str(exc), "type": exc.__class__.__name__})
|
|
322
|
+
else:
|
|
323
|
+
print(f"ninchi: {exc}", file=sys.stderr)
|
|
324
|
+
return EXIT_PENDING_OR_ERROR
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
if __name__ == "__main__":
|
|
328
|
+
raise SystemExit(main())
|
ninchi_cli/mcp_server.py
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"""Ninchi MCP server (ENT-040): the CLI's HTTP client exposed as MCP tools.
|
|
2
|
+
|
|
3
|
+
Run via ``ninchi mcp`` (stdio transport). Any MCP-capable editor/agent
|
|
4
|
+
(Cursor, Claude Code, etc.) can then submit checks, fetch questions, submit
|
|
5
|
+
answers, and read results through the same authenticated API surface the
|
|
6
|
+
CLI uses (ENT-036 PATs + ENT-037 submission API).
|
|
7
|
+
|
|
8
|
+
Design decisions (see the New Surfaces plan + Decision Log 2026-07-16):
|
|
9
|
+
|
|
10
|
+
- ``submit_answer`` REQUIRES an ``actor`` argument. There is deliberately
|
|
11
|
+
no default: honesty about who authored the answer is the contract. Agents
|
|
12
|
+
must pass ``agent_autonomous`` when they wrote the answer themselves,
|
|
13
|
+
``agent_assisted`` when a human meaningfully directed it, and
|
|
14
|
+
``human_attested`` only when relaying a human's own words. The server
|
|
15
|
+
enforces org policy regardless — orgs may reject agent answers entirely,
|
|
16
|
+
and that rejection is surfaced with guidance to hand the question to the
|
|
17
|
+
human user. No human-in-the-loop assumption is baked in here: it is org
|
|
18
|
+
policy, not client behavior.
|
|
19
|
+
- Client attribution: submissions carry ``client_attribution:
|
|
20
|
+
"mcp:<client-name>"`` from the MCP handshake (GTM-013's high-fidelity
|
|
21
|
+
authorship feed).
|
|
22
|
+
- Auth reuses the CLI credentials file / ``NINCHI_API_TOKEN`` env var; run
|
|
23
|
+
``ninchi login`` first.
|
|
24
|
+
|
|
25
|
+
The tool bodies live in ``_impl``-suffixed functions that take an explicit
|
|
26
|
+
``NinchiClient`` so tests exercise them hermetically; the FastMCP-decorated
|
|
27
|
+
wrappers only resolve credentials and construct the client.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import re
|
|
33
|
+
from typing import Any, Literal
|
|
34
|
+
|
|
35
|
+
# Imported at module level so PEP 563 string annotations on the tool
|
|
36
|
+
# functions resolve against module globals. Laziness is preserved one level
|
|
37
|
+
# up: main.py only imports this module when `ninchi mcp` actually runs.
|
|
38
|
+
from mcp.server.fastmcp import Context, FastMCP
|
|
39
|
+
|
|
40
|
+
from .client import NinchiAPIError, NinchiClient
|
|
41
|
+
from .config import load_credentials
|
|
42
|
+
|
|
43
|
+
# Bounded server-side waits so tool calls return promptly enough for agent
|
|
44
|
+
# UIs while still absorbing normal generation/evaluation latency.
|
|
45
|
+
DEFAULT_WAIT_SECONDS = 90
|
|
46
|
+
MAX_WAIT_SECONDS = 300
|
|
47
|
+
|
|
48
|
+
Actor = Literal["human_attested", "agent_assisted", "agent_autonomous"]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class NinchiMCPError(RuntimeError):
|
|
52
|
+
"""Raised for tool-level failures; FastMCP surfaces the message."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _make_client() -> NinchiClient:
|
|
56
|
+
creds = load_credentials()
|
|
57
|
+
if not creds.token:
|
|
58
|
+
raise NinchiMCPError(
|
|
59
|
+
"Not authenticated. Run `ninchi login` in a terminal first (or set "
|
|
60
|
+
"NINCHI_API_TOKEN); the MCP server reuses the CLI credentials."
|
|
61
|
+
)
|
|
62
|
+
return NinchiClient(api_url=creds.api_url, token=creds.token)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _client_attribution(ctx: Any) -> str:
|
|
66
|
+
"""``mcp:<client-name>`` from the MCP handshake, fail-open to ``mcp``."""
|
|
67
|
+
try:
|
|
68
|
+
name = ctx.session.client_params.clientInfo.name # type: ignore[union-attr]
|
|
69
|
+
except AttributeError:
|
|
70
|
+
name = None
|
|
71
|
+
if not name:
|
|
72
|
+
return "mcp"
|
|
73
|
+
slug = re.sub(r"[^a-z0-9._-]+", "-", str(name).strip().lower())[:48].strip("-")
|
|
74
|
+
return f"mcp:{slug}" if slug else "mcp"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _clamp_wait(seconds: int) -> int:
|
|
78
|
+
return max(0, min(int(seconds), MAX_WAIT_SECONDS))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ─── Tool bodies (hermetically testable) ──────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
def submit_check_impl(
|
|
84
|
+
client: NinchiClient,
|
|
85
|
+
*,
|
|
86
|
+
org_id: str,
|
|
87
|
+
attribution: str,
|
|
88
|
+
diff_text: str | None = None,
|
|
89
|
+
content: str | None = None,
|
|
90
|
+
title: str | None = None,
|
|
91
|
+
file_path: str | None = None,
|
|
92
|
+
file_content: str | None = None,
|
|
93
|
+
language: str | None = None,
|
|
94
|
+
) -> dict[str, Any]:
|
|
95
|
+
subjects = [s for s in (diff_text, content, file_content) if s is not None]
|
|
96
|
+
if len(subjects) != 1:
|
|
97
|
+
raise NinchiMCPError(
|
|
98
|
+
"Provide exactly one subject: diff_text, OR content (+ optional "
|
|
99
|
+
"title), OR file_content + file_path (+ optional language)."
|
|
100
|
+
)
|
|
101
|
+
payload: dict[str, Any] = {"client_attribution": attribution}
|
|
102
|
+
if diff_text is not None:
|
|
103
|
+
payload["diff_text"] = diff_text
|
|
104
|
+
elif content is not None:
|
|
105
|
+
payload["content"] = content
|
|
106
|
+
if title:
|
|
107
|
+
payload["title"] = title
|
|
108
|
+
else:
|
|
109
|
+
if not file_path:
|
|
110
|
+
raise NinchiMCPError("file_content requires file_path.")
|
|
111
|
+
region: dict[str, Any] = {"path": file_path, "content": file_content}
|
|
112
|
+
if language:
|
|
113
|
+
region["language"] = language
|
|
114
|
+
payload["code_region"] = region
|
|
115
|
+
|
|
116
|
+
challenge_id = client.submit_check(org_id, payload)
|
|
117
|
+
return {
|
|
118
|
+
"challenge_id": challenge_id,
|
|
119
|
+
"status": "generating",
|
|
120
|
+
"next_step": "Call get_challenge to retrieve the question.",
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def get_challenge_impl(
|
|
125
|
+
client: NinchiClient, *, challenge_id: str, wait_seconds: int
|
|
126
|
+
) -> dict[str, Any]:
|
|
127
|
+
wait = _clamp_wait(wait_seconds)
|
|
128
|
+
if wait == 0:
|
|
129
|
+
challenge = client.get_challenge(challenge_id)
|
|
130
|
+
else:
|
|
131
|
+
try:
|
|
132
|
+
challenge = client.wait_for_challenge(challenge_id, timeout_seconds=wait)
|
|
133
|
+
except NinchiAPIError as exc:
|
|
134
|
+
if "Timed out" in str(exc):
|
|
135
|
+
return {
|
|
136
|
+
"status": "generating",
|
|
137
|
+
"challenge_id": challenge_id,
|
|
138
|
+
"note": "Still generating — call get_challenge again.",
|
|
139
|
+
}
|
|
140
|
+
raise
|
|
141
|
+
if challenge is None:
|
|
142
|
+
return {
|
|
143
|
+
"status": "generating",
|
|
144
|
+
"challenge_id": challenge_id,
|
|
145
|
+
"note": "Still generating — call get_challenge again.",
|
|
146
|
+
}
|
|
147
|
+
return challenge
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def submit_answer_impl(
|
|
151
|
+
client: NinchiClient, *, challenge_id: str, answer_text: str, actor: str
|
|
152
|
+
) -> dict[str, Any]:
|
|
153
|
+
if not answer_text.strip():
|
|
154
|
+
raise NinchiMCPError("answer_text cannot be empty.")
|
|
155
|
+
try:
|
|
156
|
+
return client.submit_answer(
|
|
157
|
+
challenge_id, answer_text=answer_text, answering_actor=actor
|
|
158
|
+
)
|
|
159
|
+
except NinchiAPIError as exc:
|
|
160
|
+
if exc.status_code == 403 and actor != "human_attested":
|
|
161
|
+
raise NinchiMCPError(
|
|
162
|
+
f"The organization's policy rejected an agent-authored answer: "
|
|
163
|
+
f"{exc}. Hand the question text to the human user, let them "
|
|
164
|
+
f"answer in their own words, and relay it with "
|
|
165
|
+
f"actor='human_attested' — do not answer on their behalf."
|
|
166
|
+
) from exc
|
|
167
|
+
raise
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def get_result_impl(
|
|
171
|
+
client: NinchiClient, *, challenge_id: str, wait_seconds: int
|
|
172
|
+
) -> dict[str, Any]:
|
|
173
|
+
wait = _clamp_wait(wait_seconds)
|
|
174
|
+
if wait == 0:
|
|
175
|
+
return client.get_result(challenge_id)
|
|
176
|
+
try:
|
|
177
|
+
return client.wait_for_result(challenge_id, timeout_seconds=wait)
|
|
178
|
+
except NinchiAPIError as exc:
|
|
179
|
+
if "Timed out" in str(exc):
|
|
180
|
+
return {
|
|
181
|
+
"status": "evaluating",
|
|
182
|
+
"challenge_id": challenge_id,
|
|
183
|
+
"note": "Still evaluating — call get_result again.",
|
|
184
|
+
}
|
|
185
|
+
raise
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def list_history_impl(client: NinchiClient, *, limit: int) -> dict[str, Any]:
|
|
189
|
+
return client.history(limit=max(1, min(int(limit), 100)))
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# ─── FastMCP registration ─────────────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
def build_server() -> FastMCP:
|
|
195
|
+
"""Construct the FastMCP server with the five Ninchi tools registered."""
|
|
196
|
+
server = FastMCP(
|
|
197
|
+
"ninchi",
|
|
198
|
+
instructions=(
|
|
199
|
+
"Ninchi verifies that a person understands a code change by asking "
|
|
200
|
+
"a comprehension question and grading the answer. Typical flow: "
|
|
201
|
+
"submit_check → get_challenge → (a HUMAN answers) → submit_answer "
|
|
202
|
+
"→ get_result. Be honest in submit_answer's actor argument: if "
|
|
203
|
+
"you (the agent) authored the answer, that is agent_autonomous. "
|
|
204
|
+
"Some organizations block agent answers by policy; in that case "
|
|
205
|
+
"show the question to the human user and relay their answer."
|
|
206
|
+
),
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
@server.tool()
|
|
210
|
+
def submit_check(
|
|
211
|
+
org_id: str,
|
|
212
|
+
ctx: Context,
|
|
213
|
+
diff_text: str | None = None,
|
|
214
|
+
content: str | None = None,
|
|
215
|
+
title: str | None = None,
|
|
216
|
+
file_path: str | None = None,
|
|
217
|
+
file_content: str | None = None,
|
|
218
|
+
language: str | None = None,
|
|
219
|
+
) -> dict:
|
|
220
|
+
"""Submit a verification subject to Ninchi and start challenge
|
|
221
|
+
generation. Provide EXACTLY ONE subject: `diff_text` (a unified git
|
|
222
|
+
diff), OR `content` (arbitrary text/code, optional `title`), OR
|
|
223
|
+
`file_content` + `file_path` (a code region, optional `language`).
|
|
224
|
+
Returns a challenge_id; the question is ready shortly after
|
|
225
|
+
(get_challenge). Requires the org to have programmatic checks
|
|
226
|
+
enabled; counts against the developer's programmatic-check quota."""
|
|
227
|
+
with _make_client() as client:
|
|
228
|
+
return submit_check_impl(
|
|
229
|
+
client,
|
|
230
|
+
org_id=org_id,
|
|
231
|
+
attribution=_client_attribution(ctx),
|
|
232
|
+
diff_text=diff_text,
|
|
233
|
+
content=content,
|
|
234
|
+
title=title,
|
|
235
|
+
file_path=file_path,
|
|
236
|
+
file_content=file_content,
|
|
237
|
+
language=language,
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
@server.tool()
|
|
241
|
+
def get_challenge(
|
|
242
|
+
challenge_id: str, wait_seconds: int = DEFAULT_WAIT_SECONDS
|
|
243
|
+
) -> dict:
|
|
244
|
+
"""Fetch a challenge's question (waits up to `wait_seconds` for
|
|
245
|
+
generation to finish; returns status="generating" if not ready yet —
|
|
246
|
+
call again). The question is meant to be answered by the HUMAN
|
|
247
|
+
developer unless their organization permits agent answers."""
|
|
248
|
+
with _make_client() as client:
|
|
249
|
+
return get_challenge_impl(
|
|
250
|
+
client, challenge_id=challenge_id, wait_seconds=wait_seconds
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
@server.tool()
|
|
254
|
+
def submit_answer(challenge_id: str, answer_text: str, actor: Actor) -> dict:
|
|
255
|
+
"""Submit an answer for evaluation. `actor` is REQUIRED and must be
|
|
256
|
+
honest — it is recorded as evidence provenance:
|
|
257
|
+
- "human_attested": the human developer wrote this answer themselves;
|
|
258
|
+
you are only relaying it verbatim.
|
|
259
|
+
- "agent_assisted": a human meaningfully directed the answer's content.
|
|
260
|
+
- "agent_autonomous": you (the agent) authored the answer.
|
|
261
|
+
Never upgrade the actor to make an answer acceptable; organizations
|
|
262
|
+
may block agent answers by policy, in which case show the question to
|
|
263
|
+
the human and relay their own answer as human_attested."""
|
|
264
|
+
with _make_client() as client:
|
|
265
|
+
return submit_answer_impl(
|
|
266
|
+
client,
|
|
267
|
+
challenge_id=challenge_id,
|
|
268
|
+
answer_text=answer_text,
|
|
269
|
+
actor=actor,
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
@server.tool()
|
|
273
|
+
def get_result(
|
|
274
|
+
challenge_id: str, wait_seconds: int = DEFAULT_WAIT_SECONDS
|
|
275
|
+
) -> dict:
|
|
276
|
+
"""Fetch the evaluation result (waits up to `wait_seconds`; returns
|
|
277
|
+
status="evaluating" if grading is still running — call again).
|
|
278
|
+
Status "passed"/"failed" is final, with score and feedback."""
|
|
279
|
+
with _make_client() as client:
|
|
280
|
+
return get_result_impl(
|
|
281
|
+
client, challenge_id=challenge_id, wait_seconds=wait_seconds
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
@server.tool()
|
|
285
|
+
def list_history(limit: int = 20) -> dict:
|
|
286
|
+
"""List the authenticated developer's recent challenges (most recent
|
|
287
|
+
first): ids, statuses, and titles."""
|
|
288
|
+
with _make_client() as client:
|
|
289
|
+
return list_history_impl(client, limit=limit)
|
|
290
|
+
|
|
291
|
+
return server
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def run_stdio() -> int:
|
|
295
|
+
"""Entry point for `ninchi mcp`: serve MCP over stdio until the client
|
|
296
|
+
disconnects. Never print to stdout here — stdout is the protocol
|
|
297
|
+
channel."""
|
|
298
|
+
server = build_server()
|
|
299
|
+
server.run("stdio")
|
|
300
|
+
return 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,11 @@
|
|
|
1
|
+
ninchi_cli/__init__.py,sha256=njyPL_0adBPuFykUXoPqdX47C38uHuillF45sy8ORpw,84
|
|
2
|
+
ninchi_cli/client.py,sha256=k3kQPE1FQju2QvHJ1r-38onPOlY9V427X4CvFfNDccM,6956
|
|
3
|
+
ninchi_cli/config.py,sha256=9I1_lB5DtC8wHBdwfXmxR-q0uoxoacccDUdinMUnjhQ,2344
|
|
4
|
+
ninchi_cli/diffs.py,sha256=yC5rOmr-Gdbdu_cLAfN9B2a6MRSykTrMM8eLS0-bdQI,1985
|
|
5
|
+
ninchi_cli/main.py,sha256=UyIYHrBZlgEABIWbITD4KEaeITDkteUnC2FXw7pP8C8,11369
|
|
6
|
+
ninchi_cli/mcp_server.py,sha256=b0iIQ6w3tMLWQzPI9oe8s5qLqKcIefXvjnlJnPTHvig,11925
|
|
7
|
+
ninchi_cli-0.2.0.dist-info/METADATA,sha256=ONQJFVOD4P2g2mXvhSYmVrL8JxGiQs1PyZU2eh03duQ,3870
|
|
8
|
+
ninchi_cli-0.2.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
9
|
+
ninchi_cli-0.2.0.dist-info/entry_points.txt,sha256=ptm9Wpp87DNeYkpHlTiQrjzacH9TZVIw0Jo6EklL4ug,48
|
|
10
|
+
ninchi_cli-0.2.0.dist-info/top_level.txt,sha256=0P8L4w-RrYp6TLzhl4C4RzFwdSiFRvw7nMfFFVy49bU,11
|
|
11
|
+
ninchi_cli-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ninchi_cli
|