pari-cli 0.1.4__tar.gz → 0.1.7__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. {pari_cli-0.1.4 → pari_cli-0.1.7}/PKG-INFO +3 -3
  2. {pari_cli-0.1.4 → pari_cli-0.1.7}/README.md +1 -1
  3. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/__init__.py +1 -1
  4. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/agent/loop.py +2 -1
  5. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/agent/runtime.py +53 -10
  6. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/agent/subagent.py +1 -2
  7. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/auth/account.py +22 -24
  8. pari_cli-0.1.7/pari/auth/credential.py +128 -0
  9. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/auth/interactive.py +51 -21
  10. pari_cli-0.1.7/pari/auth/login.py +149 -0
  11. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/auth/menu.py +17 -12
  12. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/auth/providers.py +15 -0
  13. pari_cli-0.1.7/pari/auth/secret_field.py +50 -0
  14. pari_cli-0.1.7/pari/auth/store.py +62 -0
  15. pari_cli-0.1.7/pari/auth/verify.py +55 -0
  16. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/cli.py +14 -14
  17. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/eval/judge.py +2 -1
  18. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/eval/logging.py +2 -1
  19. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/eval/metrics.py +2 -1
  20. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/eval/runner.py +2 -1
  21. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/fs/__init__.py +2 -1
  22. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/fs/atomic.py +21 -2
  23. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/intelligence/repomap.py +1 -14
  24. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/providers/__init__.py +16 -6
  25. pari_cli-0.1.7/pari/providers/anthropic.py +83 -0
  26. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/providers/base.py +2 -30
  27. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/providers/deepseek.py +1 -0
  28. pari_cli-0.1.7/pari/providers/exceptions/__init__.py +26 -0
  29. pari_cli-0.1.7/pari/providers/exceptions/classifier.py +60 -0
  30. pari_cli-0.1.7/pari/providers/exceptions/mapping.py +90 -0
  31. pari_cli-0.1.7/pari/providers/exceptions/types.py +40 -0
  32. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/providers/grok.py +1 -0
  33. pari_cli-0.1.7/pari/providers/http.py +99 -0
  34. pari_cli-0.1.7/pari/providers/message.py +31 -0
  35. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/providers/mock.py +15 -13
  36. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/providers/ollama.py +7 -2
  37. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/providers/openai.py +1 -0
  38. pari_cli-0.1.7/pari/providers/openai_compat.py +79 -0
  39. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/providers/openrouter.py +1 -0
  40. pari_cli-0.1.7/pari/providers/response.py +33 -0
  41. pari_cli-0.1.7/pari/providers/schemas/__init__.py +42 -0
  42. pari_cli-0.1.7/pari/providers/schemas/anthropic.py +207 -0
  43. pari_cli-0.1.7/pari/providers/schemas/openai.py +259 -0
  44. pari_cli-0.1.7/pari/providers/schemas/types.py +17 -0
  45. pari_cli-0.1.7/pari/providers/stream.py +214 -0
  46. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/routing/router.py +10 -2
  47. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/sandbox/base.py +82 -16
  48. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/sandbox/bwrap.py +3 -0
  49. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/sandbox/e2b.py +4 -3
  50. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/sandbox/process.py +3 -0
  51. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/sandbox/seatbelt.py +4 -4
  52. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/tools/bash.py +4 -1
  53. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/tools/fs.py +81 -17
  54. pari_cli-0.1.7/pari/tui/activity.py +230 -0
  55. pari_cli-0.1.7/pari/tui/app.py +494 -0
  56. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/tui/completer.py +27 -0
  57. pari_cli-0.1.7/pari/tui/errors.py +22 -0
  58. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/tui/keys.py +34 -3
  59. pari_cli-0.1.7/pari/tui/transcript.py +44 -0
  60. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari_cli.egg-info/PKG-INFO +3 -3
  61. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari_cli.egg-info/SOURCES.txt +18 -0
  62. {pari_cli-0.1.4 → pari_cli-0.1.7}/pyproject.toml +2 -2
  63. pari_cli-0.1.4/pari/auth/login.py +0 -207
  64. pari_cli-0.1.4/pari/auth/store.py +0 -43
  65. pari_cli-0.1.4/pari/providers/anthropic.py +0 -167
  66. pari_cli-0.1.4/pari/providers/openai_compat.py +0 -157
  67. pari_cli-0.1.4/pari/tui/app.py +0 -238
  68. {pari_cli-0.1.4 → pari_cli-0.1.7}/LICENSE +0 -0
  69. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/__main__.py +0 -0
  70. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/agent/__init__.py +0 -0
  71. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/agent/guards.py +0 -0
  72. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/agent/lint.py +0 -0
  73. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/agent/reflection.py +0 -0
  74. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/auth/__init__.py +0 -0
  75. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/config.py +0 -0
  76. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/context/__init__.py +0 -0
  77. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/context/cache.py +0 -0
  78. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/context/chunks.py +0 -0
  79. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/context/compaction.py +0 -0
  80. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/context/epoch.py +0 -0
  81. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/context/warmer.py +0 -0
  82. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/edit/__init__.py +0 -0
  83. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/edit/cascade.py +0 -0
  84. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/edit/diff.py +0 -0
  85. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/edit/dmp.py +0 -0
  86. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/edit/exact.py +0 -0
  87. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/edit/git_osr.py +0 -0
  88. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/edit/indenter.py +0 -0
  89. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/eval/__init__.py +0 -0
  90. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/fs/path.py +0 -0
  91. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/fs/queue.py +0 -0
  92. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/intelligence/__init__.py +0 -0
  93. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/intelligence/ast.py +0 -0
  94. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/intelligence/cache.py +0 -0
  95. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/intelligence/ppr.py +0 -0
  96. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/intelligence/search.py +0 -0
  97. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/output/__init__.py +0 -0
  98. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/output/parser.py +0 -0
  99. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/output/spooler.py +0 -0
  100. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/output/truncation.py +0 -0
  101. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/routing/__init__.py +0 -0
  102. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/routing/anti_verbosity.py +0 -0
  103. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/routing/classifier.py +0 -0
  104. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/sandbox/__init__.py +0 -0
  105. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/sandbox/dispatcher.py +0 -0
  106. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/session/__init__.py +0 -0
  107. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/session/checkpoint.py +0 -0
  108. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/session/dag.py +0 -0
  109. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/session/events.py +0 -0
  110. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/session/logger.py +0 -0
  111. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/session/undo.py +0 -0
  112. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/tools/__init__.py +0 -0
  113. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/tools/base.py +0 -0
  114. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/tools/output_reader.py +0 -0
  115. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/tools/registry.py +0 -0
  116. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/tools/search.py +0 -0
  117. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/tui/__init__.py +0 -0
  118. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/tui/commands.py +0 -0
  119. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/tui/diff_view.py +0 -0
  120. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/tui/prompt.py +0 -0
  121. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/tui/slash.py +0 -0
  122. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/tui/status.py +0 -0
  123. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari/tui/style.py +0 -0
  124. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari_cli.egg-info/dependency_links.txt +0 -0
  125. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari_cli.egg-info/entry_points.txt +0 -0
  126. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari_cli.egg-info/requires.txt +0 -0
  127. {pari_cli-0.1.4 → pari_cli-0.1.7}/pari_cli.egg-info/top_level.txt +0 -0
  128. {pari_cli-0.1.4 → pari_cli-0.1.7}/setup.cfg +0 -0
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pari-cli
3
- Version: 0.1.4
3
+ Version: 0.1.7
4
4
  Summary: A resilient, sandbox-first terminal coding agent engineered for high pass rates and low token burn.
5
5
  Author: PARI Contributors
6
6
  License: MIT
7
7
  Project-URL: Homepage, https://pari-cli.in
8
- Project-URL: Documentation, https://pari-cli.in
8
+ Project-URL: Documentation, https://pari-cli.in/docs
9
9
  Project-URL: Repository, https://github.com/MdTanwer/pari
10
10
  Keywords: ai,coding-agent,terminal,llm,developer-tools
11
11
  Classifier: Development Status :: 3 - Alpha
@@ -67,7 +67,7 @@ The exact same frontier reasoning model (Claude 3.7 Sonnet, GPT-4o) demonstrates
67
67
 
68
68
  ## 🚀 Installation & Quick Start
69
69
 
70
- Official Website & Documentation: **[https://pari-cli.in](https://pari-cli.in)**
70
+ Official Website & Documentation: **[https://pari-cli.in/docs](https://pari-cli.in/docs)**
71
71
 
72
72
  Choose your platform's installation command:
73
73
 
@@ -24,7 +24,7 @@ The exact same frontier reasoning model (Claude 3.7 Sonnet, GPT-4o) demonstrates
24
24
 
25
25
  ## 🚀 Installation & Quick Start
26
26
 
27
- Official Website & Documentation: **[https://pari-cli.in](https://pari-cli.in)**
27
+ Official Website & Documentation: **[https://pari-cli.in/docs](https://pari-cli.in/docs)**
28
28
 
29
29
  Choose your platform's installation command:
30
30
 
@@ -1,3 +1,3 @@
1
1
  """PARI — High-performance terminal coding agent."""
2
2
 
3
- __version__ = "0.1.4"
3
+ __version__ = "0.1.7"
@@ -28,7 +28,8 @@ from pari.context.chunks import ContextChunk, ContextTier, assemble_context
28
28
  from pari.context.compaction import compact_context
29
29
  from pari.edit.diff import generate_unified_diff
30
30
  from pari.fs.path import resolve_safe_path
31
- from pari.providers.base import ModelResponse, ToolCall
31
+ from pari.providers.message import ToolCall
32
+ from pari.providers.response import ModelResponse
32
33
  from pari.session.logger import SessionLogger
33
34
  from pari.session.undo import GuardedUndoManager
34
35
 
@@ -24,7 +24,10 @@ from pari.agent.loop import AgentExecutionLoop, TurnResult
24
24
  from pari.agent.reflection import ReflectionManager
25
25
  from pari.intelligence.repomap import RepoMapGenerator
26
26
  from pari.providers import get_provider
27
- from pari.providers.base import LLMProvider, ModelResponse, ProviderMessage, ToolCall
27
+ from pari.providers.base import LLMProvider
28
+ from pari.providers.exceptions import ProviderError
29
+ from pari.providers.message import ProviderMessage, ToolCall
30
+ from pari.providers.response import ModelResponse
28
31
  from pari.routing.router import DualModelRouter, router_for
29
32
  from pari.session.events import SessionEventType
30
33
  from pari.session.logger import SessionLogger
@@ -133,15 +136,33 @@ class AgentRuntime:
133
136
  self.pinned_files.append(clean_rel)
134
137
  return True
135
138
 
136
- async def continue_turn(self) -> str:
139
+ async def continue_turn(
140
+ self,
141
+ on_tool_call: Callable[[str, dict[str, Any]], None] | None = None,
142
+ on_diff: Callable[[str, str], None] | None = None,
143
+ on_tool_result: Callable[[ToolCall, str], None] | None = None,
144
+ on_step_text: Callable[[str], None] | None = None,
145
+ on_delta: Callable[[str], None] | None = None,
146
+ ) -> str:
137
147
  """Run another block of turns after the user chooses Continue."""
138
- return await self.execute_turn("Continue.", max_turns=TURN_EXTENSION)
148
+ return await self.execute_turn(
149
+ "Continue.",
150
+ on_tool_call=on_tool_call,
151
+ on_diff=on_diff,
152
+ on_tool_result=on_tool_result,
153
+ on_step_text=on_step_text,
154
+ on_delta=on_delta,
155
+ max_turns=TURN_EXTENSION,
156
+ )
139
157
 
140
158
  async def execute_turn(
141
159
  self,
142
160
  prompt: str,
143
161
  on_tool_call: Callable[[str, dict[str, Any]], None] | None = None,
144
162
  on_diff: Callable[[str, str], None] | None = None,
163
+ on_tool_result: Callable[[ToolCall, str], None] | None = None,
164
+ on_step_text: Callable[[str], None] | None = None,
165
+ on_delta: Callable[[str], None] | None = None,
145
166
  *,
146
167
  max_turns: int | None = None,
147
168
  ) -> str:
@@ -168,6 +189,7 @@ class AgentRuntime:
168
189
  schemas,
169
190
  prompt=prompt,
170
191
  active_files=self.pinned_files,
192
+ on_delta=on_delta,
171
193
  )
172
194
  # Drop tool calls that were cut off so the next step cannot rerun them.
173
195
  calls = [] if is_length_stop(resp.stop_reason) else list(resp.tool_calls)
@@ -175,6 +197,16 @@ class AgentRuntime:
175
197
  if not call.id:
176
198
  call.id = f"call_{index}"
177
199
  self._tool_followup = bool(calls)
200
+ # Show the note that introduces this tool batch. The final answer
201
+ # has no tool calls and is printed once, after the turn returns.
202
+ # Deltas already painted this text. Printing it again would repeat it.
203
+ if (
204
+ calls
205
+ and resp.text.strip()
206
+ and on_step_text is not None
207
+ and on_delta is None
208
+ ):
209
+ on_step_text(resp.text)
178
210
  self.messages.append(
179
211
  ProviderMessage(
180
212
  role="assistant",
@@ -188,6 +220,8 @@ class AgentRuntime:
188
220
  self.messages.append(
189
221
  ProviderMessage(role="tool", content=text, tool_call_id=call.id)
190
222
  )
223
+ if on_tool_result is not None:
224
+ on_tool_result(call, text)
191
225
 
192
226
  dag = self.workspace / ".pari" / "sessions" / f"{self.session_id}.jsonl"
193
227
 
@@ -206,13 +240,22 @@ class AgentRuntime:
206
240
  on_diff=on_diff,
207
241
  on_warning=remember_warning,
208
242
  )
209
- result = await loop.run_turn(
210
- prompt,
211
- step,
212
- lambda response: is_length_stop(response.stop_reason),
213
- active_files=self._pinned_contents(),
214
- repo_map=self._repo_map(),
215
- )
243
+ # A failed model call must not leave the prompt sitting on the transcript.
244
+ # The next message would otherwise resend a turn the provider never answered.
245
+ sent = len(self.messages)
246
+ try:
247
+ result = await loop.run_turn(
248
+ prompt,
249
+ step,
250
+ lambda response: is_length_stop(response.stop_reason),
251
+ active_files=self._pinned_contents(),
252
+ repo_map=self._repo_map(),
253
+ )
254
+ except ProviderError:
255
+ self._tool_followup = False
256
+ if len(self.messages) == sent + 1 and self.messages[-1].role == "user":
257
+ self.messages.pop()
258
+ raise
216
259
  turn_record = {
217
260
  "event": "turn_complete",
218
261
  "session_id": self.session_id,
@@ -35,9 +35,8 @@ class FiberSubagentPool:
35
35
  self.max_concurrency = max_concurrency
36
36
  self._slots = asyncio.Semaphore(max_concurrency)
37
37
 
38
- async def run_task(self, task_id: str, coro: Coroutine) -> Any:
38
+ async def run_task(self, _task_id: str, coro: Coroutine) -> Any:
39
39
  """Run one research task, waiting if the pool is already full."""
40
- _ = task_id
41
40
  async with self._slots:
42
41
  return await coro
43
42
 
@@ -1,14 +1,14 @@
1
1
  """Account sign-in for providers that have a browser or device login.
2
2
 
3
3
  OpenAI, Anthropic, and OpenRouter open a browser and accept the redirect URL
4
- if the local callback does not finish. Grok uses a device code. DeepSeek has
5
- no account login, so that path does not ask for a token. Radius is not a
6
- provider.
4
+ if the local callback does not finish. Grok uses a device code. A provider
5
+ with ``account=False`` has no account login. Radius is not a provider.
7
6
  """
8
7
 
9
8
  import base64
10
9
  import errno
11
10
  import hashlib
11
+ import html
12
12
  import json
13
13
  import re
14
14
  import secrets
@@ -22,7 +22,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
22
22
  from typing import Protocol
23
23
 
24
24
  from pari.auth.login import sign_in
25
- from pari.auth.providers import ProviderInfo, get_info
25
+ from pari.auth.providers import ProviderInfo, get_info, no_account_message
26
26
  from pari.auth.store import AuthStore
27
27
 
28
28
 
@@ -35,6 +35,8 @@ class AccountUI(Protocol):
35
35
 
36
36
  def read_line(self, prompt: str) -> str | None: ...
37
37
 
38
+ def read_secret(self, prompt: str) -> str | None: ...
39
+
38
40
  def show_auth_url(
39
41
  self,
40
42
  url: str,
@@ -47,6 +49,8 @@ class AccountUI(Protocol):
47
49
 
48
50
  def sleep(self, seconds: float) -> bool: ...
49
51
 
52
+ def progress(self, message: str) -> None: ...
53
+
50
54
  def show_error(self, message: str) -> None: ...
51
55
 
52
56
 
@@ -68,6 +72,7 @@ _ANTHROPIC_SCOPES = (
68
72
  "org:create_api_key user:profile user:inference "
69
73
  "user:sessions:claude_code user:mcp_servers user:file_upload"
70
74
  )
75
+ _CALLBACK_PATH = "/callback"
71
76
  _ANTHROPIC_COPY_REDIRECT = "https://platform.claude.com/oauth/code/callback"
72
77
 
73
78
  _OPENROUTER_AUTHORIZE = "https://openrouter.ai/auth"
@@ -89,11 +94,8 @@ _BROWSER = (
89
94
  def sign_in_account(provider_id: str, ui: AccountUI, store: AuthStore) -> str:
90
95
  """Run that provider's account login and store the credential."""
91
96
  info = get_info(provider_id)
92
- if info.id == "deepseek":
93
- raise ValueError(
94
- "DeepSeek has no account sign-in. "
95
- "Use pari-cli login deepseek --api-key <key>."
96
- )
97
+ if not info.account:
98
+ raise ValueError(no_account_message(info))
97
99
  if info.id == "openai":
98
100
  cred = _login_openai(ui, store)
99
101
  elif info.id == "anthropic":
@@ -176,9 +178,7 @@ def _login_openai(ui: AccountUI, store: AuthStore) -> dict:
176
178
  code, client_id = _code_from(server, pasted, _OPENAI_REDIRECT, state)
177
179
  if not client_id:
178
180
  raise ValueError("Paste the full callback URL from the browser.")
179
- ui_progress = getattr(ui, "progress", None)
180
- if ui_progress:
181
- ui_progress("Exchanging authorization code for tokens...")
181
+ ui.progress("Exchanging authorization code for tokens...")
182
182
  return _exchange_openai(code, verifier, client_id)
183
183
  finally:
184
184
  server.close()
@@ -196,7 +196,7 @@ def _login_anthropic(ui: AccountUI) -> dict:
196
196
  return _anthropic_copy_code(ui, verifier, challenge)
197
197
  server = _anthropic_server(verifier)
198
198
  redirect = (
199
- server.redirect_uri if server else (f"http://localhost:53692{_callback_path()}")
199
+ server.redirect_uri if server else f"http://localhost:53692{_CALLBACK_PATH}"
200
200
  )
201
201
  try:
202
202
  url = _anthropic_url(challenge, verifier, redirect)
@@ -512,20 +512,18 @@ def _anthropic_url(challenge: str, state: str, redirect: str) -> str:
512
512
 
513
513
  def _anthropic_server(state: str) -> "CallbackServer | None":
514
514
  try:
515
- return _listen("127.0.0.1", 53692, "/callback", "localhost", state, "Anthropic")
515
+ return _listen(
516
+ "127.0.0.1", 53692, _CALLBACK_PATH, "localhost", state, "Anthropic"
517
+ )
516
518
  except OSError as exc:
517
519
  if exc.errno != errno.EADDRINUSE:
518
520
  return None
519
521
  try:
520
- return _listen("127.0.0.1", 0, "/callback", "localhost", state, "Anthropic")
522
+ return _listen("127.0.0.1", 0, _CALLBACK_PATH, "localhost", state, "Anthropic")
521
523
  except OSError:
522
524
  return None
523
525
 
524
526
 
525
- def _callback_path() -> str:
526
- return "/callback"
527
-
528
-
529
527
  class CallbackServer:
530
528
  """Loopback redirect. OpenAI's port stays 1455 because the redirect is fixed."""
531
529
 
@@ -620,7 +618,8 @@ def _listen(
620
618
 
621
619
  def _send(request: BaseHTTPRequestHandler, status: int, message: str) -> None:
622
620
  body = (
623
- f"<!doctype html><meta charset=utf-8><title>PARI</title><p>{_html(message)}</p>"
621
+ "<!doctype html><meta charset=utf-8><title>PARI</title>"
622
+ f"<p>{html.escape(message)}</p>"
624
623
  ).encode()
625
624
  request.send_response(status)
626
625
  request.send_header("Content-Type", "text/html; charset=utf-8")
@@ -654,6 +653,9 @@ def _request(url: str, data: bytes, content_type: str) -> tuple[int, dict]:
654
653
  return response.status, _json_body(response.read())
655
654
  except urllib.error.HTTPError as exc:
656
655
  return exc.code, _json_body(exc.read())
656
+ except (urllib.error.URLError, TimeoutError):
657
+ host = urllib.parse.urlparse(url).netloc
658
+ raise RuntimeError(f"Could not reach {host}. Check your connection.") from None
657
659
 
658
660
 
659
661
  def _json_body(raw: bytes) -> dict:
@@ -725,7 +727,3 @@ def _http_error(action: str, status: int, body: dict) -> str:
725
727
  detail = detail.get("message")
726
728
  suffix = f": {detail}" if isinstance(detail, str) and detail else ""
727
729
  return f"{action} failed (HTTP {status}){suffix}."
728
-
729
-
730
- def _html(text: str) -> str:
731
- return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
@@ -0,0 +1,128 @@
1
+ """Validated input for ``sign_in``."""
2
+
3
+ import re
4
+ from typing import Literal
5
+
6
+ from pydantic import BaseModel, ValidationError, field_validator, model_validator
7
+
8
+ from pari.auth.providers import PROVIDERS, ProviderInfo, get_info, no_account_message
9
+
10
+
11
+ _KEY_CHARS = re.compile(r"[A-Za-z0-9_-]+")
12
+ _MIN_API_KEY = 20
13
+ MAX_API_KEY_LENGTH = 256
14
+
15
+
16
+ class SignInInput(BaseModel):
17
+ """One sign-in request. Fields are checked in declaration order."""
18
+
19
+ method: Literal["api_key", "account"]
20
+ provider_id: str
21
+ secret: str
22
+ refresh: str | None = None
23
+ client_id: str | None = None
24
+ expires: int | None = None
25
+
26
+ @property
27
+ def info(self) -> ProviderInfo:
28
+ return get_info(self.provider_id)
29
+
30
+ @field_validator("method", mode="before")
31
+ @classmethod
32
+ def _method(cls, value: object) -> object:
33
+ if value not in ("api_key", "account"):
34
+ raise ValueError("Sign in with an API key or an account token.")
35
+ return value
36
+
37
+ @field_validator("provider_id")
38
+ @classmethod
39
+ def _provider(cls, value: str) -> str:
40
+ return get_info(value).id
41
+
42
+ @field_validator("secret")
43
+ @classmethod
44
+ def _secret(cls, value: str) -> str:
45
+ token = value.strip()
46
+ if not token:
47
+ raise ValueError("The credential is empty.")
48
+ return token
49
+
50
+ @field_validator("refresh", "client_id", mode="before")
51
+ @classmethod
52
+ def _optional_text(cls, value: object) -> str | None:
53
+ if isinstance(value, str) and value.strip():
54
+ return value.strip()
55
+ return None
56
+
57
+ @field_validator("expires", mode="before")
58
+ @classmethod
59
+ def _optional_int(cls, value: object) -> int | None:
60
+ return value if isinstance(value, int) else None
61
+
62
+ @model_validator(mode="after")
63
+ def _check_credential(self) -> "SignInInput":
64
+ if self.method == "account" and not self.info.account:
65
+ raise ValueError(no_account_message(self.info))
66
+ if self.method == "api_key":
67
+ _check_api_key(self.info, self.secret)
68
+ return self
69
+
70
+
71
+ def parse_sign_in(
72
+ provider_id: str,
73
+ secret: str,
74
+ method: str,
75
+ extra: dict | None = None,
76
+ ) -> SignInInput:
77
+ """Validate the input. Raises ``ValueError`` with a message to show as is."""
78
+ extra = extra or {}
79
+ try:
80
+ return SignInInput.model_validate(
81
+ {
82
+ "method": method,
83
+ "provider_id": provider_id,
84
+ "secret": secret,
85
+ "refresh": extra.get("refresh"),
86
+ "client_id": extra.get("client_id"),
87
+ "expires": extra.get("expires"),
88
+ }
89
+ )
90
+ except ValidationError as exc:
91
+ raise ValueError(_message(exc)) from None
92
+
93
+
94
+ def _message(exc: ValidationError) -> str:
95
+ """The first error as a sentence. Rule failures carry their own text."""
96
+ error = exc.errors()[0]
97
+ if error["type"] == "value_error":
98
+ return str(error["ctx"]["error"])
99
+ return f"{error['loc'][0]}: {error['msg']}"
100
+
101
+
102
+ def _check_api_key(info: ProviderInfo, token: str) -> None:
103
+ if any(char.isspace() for char in token):
104
+ raise ValueError(f"{info.label} API keys cannot contain spaces.")
105
+ _check_prefix(info, token)
106
+ if not _KEY_CHARS.fullmatch(token):
107
+ raise ValueError(
108
+ f"That {info.label} API key uses characters that are not allowed."
109
+ )
110
+ if len(token) < _MIN_API_KEY:
111
+ raise ValueError(f"That {info.label} API key is too short.")
112
+ if len(token) > MAX_API_KEY_LENGTH:
113
+ raise ValueError(f"That {info.label} API key is too long.")
114
+
115
+
116
+ def _check_prefix(info: ProviderInfo, token: str) -> None:
117
+ """The most specific prefix wins, so ``sk-ant-`` is Anthropic and not OpenAI."""
118
+ matches = [item for item in PROVIDERS if token.startswith(item.key_prefix)]
119
+ if not matches:
120
+ raise ValueError(f"{info.label} API keys start with {info.key_prefix}.")
121
+ longest = max(len(item.key_prefix) for item in matches)
122
+ owners = [item for item in matches if len(item.key_prefix) == longest]
123
+ if info not in owners:
124
+ names = " or ".join(item.label for item in owners)
125
+ raise ValueError(
126
+ f"That key belongs to {names}. "
127
+ f"{info.label} keys start with {info.key_prefix}."
128
+ )
@@ -2,7 +2,6 @@
2
2
 
3
3
  import base64
4
4
  import ctypes
5
- import getpass
6
5
  import os
7
6
  import select
8
7
  import sys
@@ -22,7 +21,7 @@ except ImportError:
22
21
  tty = None
23
22
 
24
23
  from pari.auth.account import AccountUI, LoginCancelled, sign_in_account
25
- from pari.auth.login import sign_in, sign_out, status_text
24
+ from pari.auth.login import KeyVerifier, sign_in, sign_out, status_text
26
25
  from pari.auth.menu import (
27
26
  API_KEY_TITLE,
28
27
  LOGOUT_TITLE,
@@ -34,8 +33,10 @@ from pari.auth.menu import (
34
33
  provider_rows,
35
34
  signed_in_rows,
36
35
  )
37
- from pari.auth.providers import ProviderInfo, get_info
36
+ from pari.auth.providers import ProviderInfo, get_info, no_account_message
37
+ from pari.auth.secret_field import SecretField
38
38
  from pari.auth.store import AuthStore
39
+ from pari.auth.verify import verify_api_key
39
40
 
40
41
 
41
42
  _ACCENT = "\033[38;2;255;121;198m"
@@ -44,12 +45,15 @@ _WARN = "\033[38;2;230;180;80m"
44
45
  _OK = "\033[38;2;80;200;120m"
45
46
  _BOLD = "\033[1m"
46
47
  _RESET = "\033[0m"
48
+ _PASTE_ON = "\033[?2004h"
49
+ _PASTE_OFF = "\033[?2004l"
47
50
 
48
51
 
49
52
  def run_login(
50
53
  ui: AccountUI,
51
54
  store: AuthStore | None = None,
52
55
  provider_id: str | None = None,
56
+ verify: KeyVerifier | None = None,
53
57
  ) -> str:
54
58
  """Walk the menus and return the success sentence. Empty means cancelled."""
55
59
  auth = store or AuthStore()
@@ -62,7 +66,7 @@ def run_login(
62
66
  return ""
63
67
  kind = "account" if method == METHOD_ACCOUNT else "api_key"
64
68
  if provider_id:
65
- message = _finish(kind, provider_id, ui, auth)
69
+ message = _finish(kind, provider_id, ui, auth, verify)
66
70
  if message is None:
67
71
  continue
68
72
  return message
@@ -70,7 +74,7 @@ def run_login(
70
74
  chosen = ui.choose_provider(kind, provider_rows(kind, auth))
71
75
  if not chosen:
72
76
  break
73
- message = _finish(kind, chosen, ui, auth)
77
+ message = _finish(kind, chosen, ui, auth, verify)
74
78
  if message is None:
75
79
  continue
76
80
  return message
@@ -121,7 +125,7 @@ def login_tty(provider_id: str | None = None) -> int:
121
125
  return 0
122
126
  ui = TerminalLogin()
123
127
  try:
124
- message = run_login(ui, provider_id=provider_id)
128
+ message = run_login(ui, provider_id=provider_id, verify=verify_api_key)
125
129
  except KeyboardInterrupt:
126
130
  return 0
127
131
  except (ValueError, RuntimeError) as exc:
@@ -134,31 +138,39 @@ def login_tty(provider_id: str | None = None) -> int:
134
138
  return 0
135
139
 
136
140
 
137
- def _finish(kind: str, provider_id: str, ui: AccountUI, store: AuthStore) -> str | None:
141
+ def _finish(
142
+ kind: str,
143
+ provider_id: str,
144
+ ui: AccountUI,
145
+ store: AuthStore,
146
+ verify: KeyVerifier | None,
147
+ ) -> str | None:
138
148
  info = get_info(provider_id)
139
149
  try:
140
150
  if kind == "api_key":
141
- return _read_api_key(ui, info, store)
142
- if info.id == "deepseek":
143
- ui.show_error(
144
- "DeepSeek has no account sign-in. "
145
- "Use pari-cli login deepseek --api-key <key>."
146
- )
151
+ return _read_api_key(ui, info, store, verify)
152
+ if not info.account:
153
+ ui.show_error(no_account_message(info))
147
154
  return None
148
155
  return sign_in_account(info.id, ui, store)
149
156
  except LoginCancelled:
150
157
  return None
151
158
 
152
159
 
153
- def _read_api_key(ui: AccountUI, info: ProviderInfo, store: AuthStore) -> str:
154
- """Ask until the key matches that provider, or the user cancels."""
160
+ def _read_api_key(
161
+ ui: AccountUI,
162
+ info: ProviderInfo,
163
+ store: AuthStore,
164
+ verify: KeyVerifier | None,
165
+ ) -> str:
166
+ """Ask until the provider accepts the key, or the user cancels."""
155
167
  prompt = f"Enter your {info.label} API key:"
156
168
  while True:
157
169
  secret = ui.read_secret(prompt)
158
170
  if secret is None:
159
171
  raise LoginCancelled()
160
172
  try:
161
- return sign_in(info.id, secret, "api_key", store)
173
+ return sign_in(info.id, secret, "api_key", store, verify=verify)
162
174
  except ValueError as exc:
163
175
  ui.show_error(str(exc))
164
176
 
@@ -188,14 +200,25 @@ class TerminalLogin:
188
200
  return self._run(ProviderMenu(rows, search=search, title=title))
189
201
 
190
202
  def read_secret(self, prompt: str) -> str | None:
203
+ """Read a key shown as ``x`` per character. None means cancelled."""
191
204
  self._leave()
205
+ # The alternate screen restores the cursor onto the previous prompt.
206
+ sys.stdout.write("\n" + _PASTE_ON)
207
+ field = SecretField()
192
208
  try:
193
- # The alternate screen restores the cursor onto the previous prompt.
194
- sys.stdout.write("\n")
209
+ self._enter_raw()
210
+ while True:
211
+ sys.stdout.write(f"\r\033[K{prompt} {field.masked}")
212
+ sys.stdout.flush()
213
+ result = field.on_key(self._key(None))
214
+ if result == "cancel":
215
+ return None
216
+ if result == "enter":
217
+ return field.value
218
+ finally:
219
+ sys.stdout.write(_PASTE_OFF + "\n")
195
220
  sys.stdout.flush()
196
- return getpass.getpass(prompt + " ")
197
- except (KeyboardInterrupt, EOFError):
198
- return None
221
+ self._leave()
199
222
 
200
223
  def show_error(self, message: str) -> None:
201
224
  self._leave()
@@ -293,6 +316,9 @@ class TerminalLogin:
293
316
  sys.stdout.write("\033[?1049h\033[?25l")
294
317
  sys.stdout.flush()
295
318
  self._alt = True
319
+ self._enter_raw()
320
+
321
+ def _enter_raw(self) -> None:
296
322
  if self._raw:
297
323
  return
298
324
  self._fd = sys.stdin.fileno()
@@ -506,6 +532,10 @@ def _normalize(raw: bytes) -> str:
506
532
  return "cancel"
507
533
  if raw == b"\x18":
508
534
  return "copy"
535
+ if raw == b"\x1b[200~":
536
+ return "paste_start"
537
+ if raw == b"\x1b[201~":
538
+ return "paste_end"
509
539
  if raw == b"\x1b":
510
540
  return "cancel"
511
541
  if raw.startswith(b"\x1b[") or (raw.startswith(b"\x1bO") and len(raw) >= 3):