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 ADDED
@@ -0,0 +1,5 @@
1
+ """Ninchi command-line client."""
2
+
3
+ __all__ = ["__version__"]
4
+
5
+ __version__ = "0.1.0"
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())
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ ninchi = ninchi_cli.main:main
@@ -0,0 +1 @@
1
+ ninchi_cli