euron-code 0.4.0__tar.gz → 0.4.1__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 (85) hide show
  1. {euron_code-0.4.0 → euron_code-0.4.1}/CHANGELOG.md +9 -0
  2. {euron_code-0.4.0 → euron_code-0.4.1}/PKG-INFO +16 -18
  3. {euron_code-0.4.0 → euron_code-0.4.1}/README.md +15 -17
  4. {euron_code-0.4.0 → euron_code-0.4.1}/pyproject.toml +1 -1
  5. {euron_code-0.4.0 → euron_code-0.4.1}/src/euron_code/__init__.py +1 -1
  6. {euron_code-0.4.0 → euron_code-0.4.1}/src/euron_code/agent.py +3 -1
  7. {euron_code-0.4.0 → euron_code-0.4.1}/src/euron_code/api.py +3 -2
  8. {euron_code-0.4.0 → euron_code-0.4.1}/src/euron_code/attachments.py +1 -1
  9. {euron_code-0.4.0 → euron_code-0.4.1}/src/euron_code/config.py +1 -1
  10. {euron_code-0.4.0 → euron_code-0.4.1}/src/euron_code/doctor.py +88 -88
  11. {euron_code-0.4.0 → euron_code-0.4.1}/src/euron_code/repl.py +8 -1
  12. euron_code-0.4.1/src/euron_code/textsafe.py +41 -0
  13. {euron_code-0.4.0 → euron_code-0.4.1}/src/euron_code/usage_view.py +1 -1
  14. {euron_code-0.4.0 → euron_code-0.4.1}/tests/test_api_shell.py +160 -160
  15. {euron_code-0.4.0 → euron_code-0.4.1}/tests/test_permissions.py +91 -91
  16. euron_code-0.4.1/tests/test_textsafe.py +45 -0
  17. {euron_code-0.4.0 → euron_code-0.4.1}/tests/test_tools_fs.py +143 -143
  18. {euron_code-0.4.0 → euron_code-0.4.1}/tests/test_v4.py +3 -2
  19. {euron_code-0.4.0 → euron_code-0.4.1}/.gitignore +0 -0
  20. {euron_code-0.4.0 → euron_code-0.4.1}/LICENSE +0 -0
  21. {euron_code-0.4.0 → euron_code-0.4.1}/src/euron_code/__main__.py +0 -0
  22. {euron_code-0.4.0 → euron_code-0.4.1}/src/euron_code/activity.py +0 -0
  23. {euron_code-0.4.0 → euron_code-0.4.1}/src/euron_code/auth.py +0 -0
  24. {euron_code-0.4.0 → euron_code-0.4.1}/src/euron_code/builtin_skills/explain-codebase/SKILL.md +0 -0
  25. {euron_code-0.4.0 → euron_code-0.4.1}/src/euron_code/builtin_skills/review/SKILL.md +0 -0
  26. {euron_code-0.4.0 → euron_code-0.4.1}/src/euron_code/builtin_skills/test-writer/SKILL.md +0 -0
  27. {euron_code-0.4.0 → euron_code-0.4.1}/src/euron_code/cache.py +0 -0
  28. {euron_code-0.4.0 → euron_code-0.4.1}/src/euron_code/checkpoints.py +0 -0
  29. {euron_code-0.4.0 → euron_code-0.4.1}/src/euron_code/cli.py +0 -0
  30. {euron_code-0.4.0 → euron_code-0.4.1}/src/euron_code/context.py +0 -0
  31. {euron_code-0.4.0 → euron_code-0.4.1}/src/euron_code/contextforge/LICENSE +0 -0
  32. {euron_code-0.4.0 → euron_code-0.4.1}/src/euron_code/contextforge/__init__.py +0 -0
  33. {euron_code-0.4.0 → euron_code-0.4.1}/src/euron_code/contextforge/text.py +0 -0
  34. {euron_code-0.4.0 → euron_code-0.4.1}/src/euron_code/contextforge/toolout.py +0 -0
  35. {euron_code-0.4.0 → euron_code-0.4.1}/src/euron_code/credentials.py +0 -0
  36. {euron_code-0.4.0 → euron_code-0.4.1}/src/euron_code/documents.py +0 -0
  37. {euron_code-0.4.0 → euron_code-0.4.1}/src/euron_code/frontmatter.py +0 -0
  38. {euron_code-0.4.0 → euron_code-0.4.1}/src/euron_code/hooks.py +0 -0
  39. {euron_code-0.4.0 → euron_code-0.4.1}/src/euron_code/keys.py +0 -0
  40. {euron_code-0.4.0 → euron_code-0.4.1}/src/euron_code/memory.py +0 -0
  41. {euron_code-0.4.0 → euron_code-0.4.1}/src/euron_code/permissions.py +0 -0
  42. {euron_code-0.4.0 → euron_code-0.4.1}/src/euron_code/project_setup.py +0 -0
  43. {euron_code-0.4.0 → euron_code-0.4.1}/src/euron_code/prompts.py +0 -0
  44. {euron_code-0.4.0 → euron_code-0.4.1}/src/euron_code/repomap.py +0 -0
  45. {euron_code-0.4.0 → euron_code-0.4.1}/src/euron_code/rules.py +0 -0
  46. {euron_code-0.4.0 → euron_code-0.4.1}/src/euron_code/sessions.py +0 -0
  47. {euron_code-0.4.0 → euron_code-0.4.1}/src/euron_code/skills.py +0 -0
  48. {euron_code-0.4.0 → euron_code-0.4.1}/src/euron_code/tools/__init__.py +0 -0
  49. {euron_code-0.4.0 → euron_code-0.4.1}/src/euron_code/tools/background.py +0 -0
  50. {euron_code-0.4.0 → euron_code-0.4.1}/src/euron_code/tools/base.py +0 -0
  51. {euron_code-0.4.0 → euron_code-0.4.1}/src/euron_code/tools/db.py +0 -0
  52. {euron_code-0.4.0 → euron_code-0.4.1}/src/euron_code/tools/document.py +0 -0
  53. {euron_code-0.4.0 → euron_code-0.4.1}/src/euron_code/tools/fs.py +0 -0
  54. {euron_code-0.4.0 → euron_code-0.4.1}/src/euron_code/tools/ignore.py +0 -0
  55. {euron_code-0.4.0 → euron_code-0.4.1}/src/euron_code/tools/reuse.py +0 -0
  56. {euron_code-0.4.0 → euron_code-0.4.1}/src/euron_code/tools/search.py +0 -0
  57. {euron_code-0.4.0 → euron_code-0.4.1}/src/euron_code/tools/shell.py +0 -0
  58. {euron_code-0.4.0 → euron_code-0.4.1}/src/euron_code/tools/skill.py +0 -0
  59. {euron_code-0.4.0 → euron_code-0.4.1}/src/euron_code/tools/symbols.py +0 -0
  60. {euron_code-0.4.0 → euron_code-0.4.1}/src/euron_code/tools/task.py +0 -0
  61. {euron_code-0.4.0 → euron_code-0.4.1}/src/euron_code/tools/todo.py +0 -0
  62. {euron_code-0.4.0 → euron_code-0.4.1}/src/euron_code/tools/web.py +0 -0
  63. {euron_code-0.4.0 → euron_code-0.4.1}/src/euron_code/trust.py +0 -0
  64. {euron_code-0.4.0 → euron_code-0.4.1}/src/euron_code/ui.py +0 -0
  65. {euron_code-0.4.0 → euron_code-0.4.1}/tests/conftest.py +0 -0
  66. {euron_code-0.4.0 → euron_code-0.4.1}/tests/helpers.py +0 -0
  67. {euron_code-0.4.0 → euron_code-0.4.1}/tests/mock_gateway.py +0 -0
  68. {euron_code-0.4.0 → euron_code-0.4.1}/tests/serve_helper.py +0 -0
  69. {euron_code-0.4.0 → euron_code-0.4.1}/tests/test_agent_mock.py +0 -0
  70. {euron_code-0.4.0 → euron_code-0.4.1}/tests/test_cli_e2e.py +0 -0
  71. {euron_code-0.4.0 → euron_code-0.4.1}/tests/test_context_memory_sessions.py +0 -0
  72. {euron_code-0.4.0 → euron_code-0.4.1}/tests/test_db.py +0 -0
  73. {euron_code-0.4.0 → euron_code-0.4.1}/tests/test_powershell_exit_codes.py +0 -0
  74. {euron_code-0.4.0 → euron_code-0.4.1}/tests/test_repl.py +0 -0
  75. {euron_code-0.4.0 → euron_code-0.4.1}/tests/test_search.py +0 -0
  76. {euron_code-0.4.0 → euron_code-0.4.1}/tests/test_v2_rules_skills_hooks.py +0 -0
  77. {euron_code-0.4.0 → euron_code-0.4.1}/tests/test_v2_tokens_shortcuts.py +0 -0
  78. {euron_code-0.4.0 → euron_code-0.4.1}/tests/test_v2_usage_headers.py +0 -0
  79. {euron_code-0.4.0 → euron_code-0.4.1}/tests/test_v31_security.py +0 -0
  80. {euron_code-0.4.0 → euron_code-0.4.1}/tests/test_v3_accounts_value_setup.py +0 -0
  81. {euron_code-0.4.0 → euron_code-0.4.1}/tests/test_v3_activity.py +0 -0
  82. {euron_code-0.4.0 → euron_code-0.4.1}/tests/test_v3_attachments.py +0 -0
  83. {euron_code-0.4.0 → euron_code-0.4.1}/tests/test_v3_context.py +0 -0
  84. {euron_code-0.4.0 → euron_code-0.4.1}/tests/test_v3_live_repl.py +0 -0
  85. {euron_code-0.4.0 → euron_code-0.4.1}/tests/test_v3_single_login.py +0 -0
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.1 (2026-11-05)
4
+
5
+ ### Fixed
6
+ - Pasting text with emoji into a Windows terminal crashed the prompt ("surrogates not allowed") and
7
+ then failed every request. Text is now repaired before it is saved or sent.
8
+
9
+ ### Changed
10
+ - Clearer privacy wording; the limits section shows the daily allowance and where to track usage.
11
+
3
12
  ## 0.4.0 (2026-11-05)
4
13
 
5
14
  ### Run the app, not just the tests
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: euron-code
3
- Version: 0.4.0
3
+ Version: 0.4.1
4
4
  Summary: Euron Code: an agentic coding assistant in your terminal for Euron Plus and Super 30 students.
5
5
  Project-URL: Homepage, https://euron.one/code-agent
6
6
  Author-email: Euron <contact@euron.one>
@@ -167,23 +167,21 @@ Shift+Tab cycles default -> acceptEdits -> plan, and reaches bypass only if you
167
167
 
168
168
  ## Where your data goes
169
169
 
170
- - Your prompts and the code the agent reads are processed on GPUs Euron rents from Vast.ai hosts. They
171
- are not logged. The host operator could technically see them, so never paste secrets.
172
- - Images you attach are sent to Google Gemini to be described in text for the coding model; they are not
173
- stored. You are told this the first time you attach one.
170
+ - Your code stays on your computer. Euron Code runs on Euron's GPUs.
171
+ - Images you attach are turned into a text description by Euron's image understanding and are not stored.
174
172
  - One login at a time: signing in on another computer signs this one out.
173
+ - Don't paste passwords or API keys into prompts.
175
174
 
176
175
  ## Limits and value
177
176
 
178
- Your plan has a token allowance per **day**, **week**, **month** and **year** (Super 30 has a bigger one).
179
- `/usage` (or `euron usage`) shows each with a bar, what is left and when it refills, in your local time.
180
- Days, weeks (Monday), months and years start at midnight IST. If a limit is reached, Euron Code says
181
- which one and when it refills; nothing else breaks.
177
+ **10 million AI tokens. Every day.** Build more. Experiment more. Create without constantly worrying
178
+ about tokens. Included with Euron Plus and Super 30.
182
179
 
183
- It also shows what your usage is worth at a reference price, for example "Used today: 412,300 tokens
184
- ≈ $1.07 (₹94) at Claude Sonnet 5.5 API list prices · included in your plan", and what your monthly
185
- allowance is worth. That is what the same tokens would cost on a paid API. **It is not a bill: you
186
- pay nothing extra.**
180
+ <sub>Up to 10 million tokens per day, subject to fair-use policies, model availability, and plan limits.</sub>
181
+
182
+ Track everything with `/usage` (or `euron usage`) and at https://euron.one/code-agent/usage: what you
183
+ used today, this week, this month and this year, what is left and when it refills, in your local time.
184
+ If a limit is reached, Euron Code says which one and when it refills; nothing else breaks.
187
185
 
188
186
  ## Run and check your app
189
187
 
@@ -393,7 +391,7 @@ Hooks are your own shell commands that run at fixed points. Put them in `setting
393
391
  | `Stop` | when the model finishes its answer | stderr goes to the model and it continues (at most twice) |
394
392
 
395
393
  Any other non-zero exit is shown as a warning and blocks nothing. `matcher` is a regular expression
396
- over the tool name; Claude Code names (`Edit`, `Write`, `MultiEdit`, `Read`, `Bash`, `Grep`, `Glob`,
394
+ over the tool name; common names (`Edit`, `Write`, `MultiEdit`, `Read`, `Bash`, `Grep`, `Glob`,
397
395
  `WebFetch`) and Euron Code names (`edit_file`, `bash`, ...) both work. The command runs in the project
398
396
  folder with `cmd.exe` on Windows and `sh` elsewhere, and gets one JSON object on stdin: `event`,
399
397
  `session_id`, `cwd`, `tool_name`, `tool_input`, `tool_output` (PostToolUse) or `prompt`. Environment:
@@ -409,7 +407,7 @@ always run.
409
407
  ## Context and tokens (automatic)
410
408
 
411
409
  The default model has a 32k-token context and writes at most about 8k tokens per step. Euron Code
412
- manages that the way Claude Code does, with nothing to set up:
410
+ manages that for you, with nothing to set up:
413
411
 
414
412
  - **Room for the answer.** An output budget (about 8k) is always kept free; "100% full" means the
415
413
  prompt leaves no more than that. Each request asks for as many output tokens as really fit, and a
@@ -531,9 +529,9 @@ and the keychain.
531
529
 
532
530
  ## Privacy and safety
533
531
 
534
- - Your files are only sent as part of the conversation with Euron's model (on GPUs Euron rents from
535
- Vast.ai, not logged), and only what the agent reads; attached images go to Google Gemini to be described
536
- and are not stored. There is no telemetry.
532
+ - Your code stays on your computer: only the parts the agent reads are sent, as part of the conversation
533
+ with Euron's model. Attached images are turned into text by Euron's image understanding and are not
534
+ stored.
537
535
  - Your key is never printed in full and never logged.
538
536
  - Edits and commands ask first. Files outside the project folder always ask, even to read.
539
537
  - Each request carries a random session id and task id so your usage page can count sessions and
@@ -101,23 +101,21 @@ Shift+Tab cycles default -> acceptEdits -> plan, and reaches bypass only if you
101
101
 
102
102
  ## Where your data goes
103
103
 
104
- - Your prompts and the code the agent reads are processed on GPUs Euron rents from Vast.ai hosts. They
105
- are not logged. The host operator could technically see them, so never paste secrets.
106
- - Images you attach are sent to Google Gemini to be described in text for the coding model; they are not
107
- stored. You are told this the first time you attach one.
104
+ - Your code stays on your computer. Euron Code runs on Euron's GPUs.
105
+ - Images you attach are turned into a text description by Euron's image understanding and are not stored.
108
106
  - One login at a time: signing in on another computer signs this one out.
107
+ - Don't paste passwords or API keys into prompts.
109
108
 
110
109
  ## Limits and value
111
110
 
112
- Your plan has a token allowance per **day**, **week**, **month** and **year** (Super 30 has a bigger one).
113
- `/usage` (or `euron usage`) shows each with a bar, what is left and when it refills, in your local time.
114
- Days, weeks (Monday), months and years start at midnight IST. If a limit is reached, Euron Code says
115
- which one and when it refills; nothing else breaks.
111
+ **10 million AI tokens. Every day.** Build more. Experiment more. Create without constantly worrying
112
+ about tokens. Included with Euron Plus and Super 30.
116
113
 
117
- It also shows what your usage is worth at a reference price, for example "Used today: 412,300 tokens
118
- ≈ $1.07 (₹94) at Claude Sonnet 5.5 API list prices · included in your plan", and what your monthly
119
- allowance is worth. That is what the same tokens would cost on a paid API. **It is not a bill: you
120
- pay nothing extra.**
114
+ <sub>Up to 10 million tokens per day, subject to fair-use policies, model availability, and plan limits.</sub>
115
+
116
+ Track everything with `/usage` (or `euron usage`) and at https://euron.one/code-agent/usage: what you
117
+ used today, this week, this month and this year, what is left and when it refills, in your local time.
118
+ If a limit is reached, Euron Code says which one and when it refills; nothing else breaks.
121
119
 
122
120
  ## Run and check your app
123
121
 
@@ -327,7 +325,7 @@ Hooks are your own shell commands that run at fixed points. Put them in `setting
327
325
  | `Stop` | when the model finishes its answer | stderr goes to the model and it continues (at most twice) |
328
326
 
329
327
  Any other non-zero exit is shown as a warning and blocks nothing. `matcher` is a regular expression
330
- over the tool name; Claude Code names (`Edit`, `Write`, `MultiEdit`, `Read`, `Bash`, `Grep`, `Glob`,
328
+ over the tool name; common names (`Edit`, `Write`, `MultiEdit`, `Read`, `Bash`, `Grep`, `Glob`,
331
329
  `WebFetch`) and Euron Code names (`edit_file`, `bash`, ...) both work. The command runs in the project
332
330
  folder with `cmd.exe` on Windows and `sh` elsewhere, and gets one JSON object on stdin: `event`,
333
331
  `session_id`, `cwd`, `tool_name`, `tool_input`, `tool_output` (PostToolUse) or `prompt`. Environment:
@@ -343,7 +341,7 @@ always run.
343
341
  ## Context and tokens (automatic)
344
342
 
345
343
  The default model has a 32k-token context and writes at most about 8k tokens per step. Euron Code
346
- manages that the way Claude Code does, with nothing to set up:
344
+ manages that for you, with nothing to set up:
347
345
 
348
346
  - **Room for the answer.** An output budget (about 8k) is always kept free; "100% full" means the
349
347
  prompt leaves no more than that. Each request asks for as many output tokens as really fit, and a
@@ -465,9 +463,9 @@ and the keychain.
465
463
 
466
464
  ## Privacy and safety
467
465
 
468
- - Your files are only sent as part of the conversation with Euron's model (on GPUs Euron rents from
469
- Vast.ai, not logged), and only what the agent reads; attached images go to Google Gemini to be described
470
- and are not stored. There is no telemetry.
466
+ - Your code stays on your computer: only the parts the agent reads are sent, as part of the conversation
467
+ with Euron's model. Attached images are turned into text by Euron's image understanding and are not
468
+ stored.
471
469
  - Your key is never printed in full and never logged.
472
470
  - Edits and commands ask first. Files outside the project folder always ask, even to read.
473
471
  - Each request carries a random session id and task id so your usage page can count sessions and
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "euron-code"
7
- version = "0.4.0"
7
+ version = "0.4.1"
8
8
  description = "Euron Code: an agentic coding assistant in your terminal for Euron Plus and Super 30 students."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -1,3 +1,3 @@
1
1
  """Euron Code: an agentic coding assistant for the terminal."""
2
2
 
3
- __version__ = "0.4.0"
3
+ __version__ = "0.4.1"
@@ -100,7 +100,7 @@ def repair_json(raw: str):
100
100
 
101
101
 
102
102
  def parse_inline_tool_calls(content: str):
103
- """Fallback for servers without a tool parser: Qwen emits <tool_call>{json}</tool_call>.
103
+ """Fallback for servers without a tool parser: some models emit <tool_call>{json}</tool_call>.
104
104
  Slightly broken JSON is repaired; unparseable calls become an error result, never a crash."""
105
105
  calls = []
106
106
  for m in TOOL_CALL_TAG.finditer(content):
@@ -272,6 +272,8 @@ class Agent:
272
272
 
273
273
  # ---------------------------------------------------------- public
274
274
  def run_turn(self, user_text: str, max_turns: Optional[int] = None) -> TurnResult:
275
+ from euron_code import textsafe
276
+ user_text = textsafe.clean(user_text) # emoji pasted into a Windows console (surrogates)
275
277
  if self.is_subagent:
276
278
  return self._run_turn(user_text, max_turns)
277
279
  self._ui_hook("turn_started")
@@ -19,6 +19,7 @@ from typing import Any, Callable, Dict, Iterator, List, Optional, Tuple
19
19
 
20
20
  import httpx
21
21
 
22
+ from euron_code import textsafe
22
23
  from euron_code import __version__, config
23
24
 
24
25
  NO_PLUS_MESSAGE = (
@@ -366,7 +367,7 @@ class Client:
366
367
  def chat(self, payload: Dict[str, Any]) -> Dict[str, Any]:
367
368
  """Non-streaming completion (used for compaction summaries)."""
368
369
  with self._lock:
369
- body = dict(payload)
370
+ body = textsafe.clean_obj(dict(payload))
370
371
  body["stream"] = False
371
372
  data = self._json("POST", "/v1/chat/completions", json=body, headers=self._chat_headers())
372
373
  self.takeover_once = False
@@ -383,7 +384,7 @@ class Client:
383
384
  raise EuronError("Internal error: a model request is already running.")
384
385
  q: "queue.Queue[Tuple[str, Any]]" = queue.Queue()
385
386
  holder: Dict[str, Any] = {}
386
- body = dict(payload)
387
+ body = textsafe.clean_obj(dict(payload))
387
388
  body["stream"] = True
388
389
  body.setdefault("stream_options", {"include_usage": True})
389
390
 
@@ -130,7 +130,7 @@ def load_image(path: Path) -> Tuple[Dict, str]:
130
130
  return {"type": "image_url", "image_url": {"url": url}}, note
131
131
 
132
132
 
133
- IMAGE_PRIVACY = ("Images you attach are sent to Google Gemini to be described in text for the coding model; "
133
+ IMAGE_PRIVACY = ("Images are turned into a text description by Euron's image understanding and are not stored; "
134
134
  "they are not stored. Don't attach images that show secrets.")
135
135
 
136
136
 
@@ -17,7 +17,7 @@ from typing import Any, Dict, List, Optional, Tuple
17
17
 
18
18
  DEFAULT_BASE_URL = "https://api.euron.one/api/v1/code-agent"
19
19
 
20
- #: Context window and output cap of the default model (Qwen3.6-27B, see BENCHMARK_RESULTS.md).
20
+ #: Context window and output cap of the default model.
21
21
  DEFAULT_CONTEXT_WINDOW = 32768
22
22
  DEFAULT_MAX_OUTPUT = 8192
23
23
 
@@ -1,88 +1,88 @@
1
- """`euron doctor`: check the local setup and the connection to Euron Code."""
2
-
3
- from __future__ import annotations
4
-
5
- import os
6
- import platform
7
- import sys
8
- from typing import List, Tuple
9
-
10
- import httpx
11
- from rich.markup import escape
12
-
13
- from euron_code import __version__, api, config, credentials
14
- from euron_code.tools.search import ripgrep_path
15
- from euron_code.tools.shell import detect_shell
16
-
17
- OK, WARN, FAIL = "ok", "warn", "fail"
18
-
19
-
20
- def run_checks() -> List[Tuple[str, str, str]]:
21
- checks: List[Tuple[str, str, str]] = []
22
- v = sys.version_info
23
- checks.append(("Python", OK if v >= (3, 9) else FAIL,
24
- f"{platform.python_version()} ({sys.executable})"))
25
- checks.append(("euron-code", OK, __version__))
26
- checks.append(("Platform", OK, f"{platform.system()} {platform.release()} {platform.machine()}"))
27
- url = config.base_url()
28
- src = "EURON_CODE_API" if os.environ.get("EURON_CODE_API") else "default"
29
- checks.append(("API base URL", OK, f"{url} ({src})"))
30
-
31
- # connectivity: any HTTP answer means the gateway is reachable
32
- try:
33
- r = httpx.get(url + "/v1/models", timeout=10.0, headers={"User-Agent": api.user_agent()})
34
- checks.append(("Connectivity", OK, f"reachable (HTTP {r.status_code})"))
35
- reachable = True
36
- except httpx.HTTPError as e:
37
- checks.append(("Connectivity", FAIL, f"cannot reach {url}: {type(e).__name__}"))
38
- reachable = False
39
-
40
- cred = credentials.load(url)
41
- if cred is None:
42
- checks.append(("Login", WARN, "not logged in; run `euron login`"))
43
- else:
44
- checks.append(("Login", OK, f"key {credentials.mask_key(cred.api_key)} in {credentials.where(url)}"))
45
- if reachable:
46
- c = api.Client(api_key=cred.api_key, base_url=url)
47
- try:
48
- me = c.me(use_cache=False)
49
- access = me.get("access") or {}
50
- if access.get("entitled"):
51
- q = me.get("quota") or {}
52
- checks.append(("Entitlement", OK, f"{api.plan_label(access)}; "
53
- f"{q.get('remainingToday', '?'):,} tokens left today"
54
- if isinstance(q.get("remainingToday"), int) else api.plan_label(access)))
55
- else:
56
- checks.append(("Entitlement", FAIL, api.NO_PLUS_MESSAGE))
57
- except api.AuthError:
58
- checks.append(("Entitlement", FAIL, "the saved key was rejected (401); run `euron login`"))
59
- except api.EuronError as e:
60
- checks.append(("Entitlement", WARN, f"could not check: {e.message}"))
61
- finally:
62
- c.close()
63
-
64
- rg = ripgrep_path()
65
- checks.append(("ripgrep", OK if rg else WARN, rg or "not found; grep uses the slower Python fallback"))
66
- checks.append(("Keyring", OK if "unavailable" not in credentials.keyring_backend_name() else WARN,
67
- credentials.keyring_backend_name()))
68
- kind, exe = detect_shell(config.load_user_settings().get("shell"))
69
- checks.append(("Shell", OK, f"{kind} ({exe})"))
70
- term = os.environ.get("TERM_PROGRAM") or os.environ.get("TERM") or ("Windows console" if os.name == "nt" else "?")
71
- checks.append(("Terminal", OK if sys.stdout.isatty() else WARN,
72
- f"{term}{'' if sys.stdout.isatty() else ' (output is not a terminal)'}"))
73
- checks.append(("Config", OK, f"{config.user_settings_path()}"))
74
- return checks
75
-
76
-
77
- def doctor(ui) -> int:
78
- checks = run_checks()
79
- style = {OK: "[green]OK [/green]", WARN: "[yellow]WARN[/yellow]", FAIL: "[red]FAIL[/red]"}
80
- for name, st, detail in checks:
81
- ui.info(f"{style[st]} {name:<14} {escape(detail)}")
82
- fails = [c for c in checks if c[1] == FAIL]
83
- ui.info("")
84
- if fails:
85
- ui.info(f"[red]{len(fails)} problem(s) found.[/red]")
86
- return 1
87
- ui.info("[green]All checks passed.[/green]")
88
- return 0
1
+ """`euron doctor`: check the local setup and the connection to Euron Code."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import platform
7
+ import sys
8
+ from typing import List, Tuple
9
+
10
+ import httpx
11
+ from rich.markup import escape
12
+
13
+ from euron_code import __version__, api, config, credentials
14
+ from euron_code.tools.search import ripgrep_path
15
+ from euron_code.tools.shell import detect_shell
16
+
17
+ OK, WARN, FAIL = "ok", "warn", "fail"
18
+
19
+
20
+ def run_checks() -> List[Tuple[str, str, str]]:
21
+ checks: List[Tuple[str, str, str]] = []
22
+ v = sys.version_info
23
+ checks.append(("Python", OK if v >= (3, 9) else FAIL,
24
+ f"{platform.python_version()} ({sys.executable})"))
25
+ checks.append(("euron-code", OK, __version__))
26
+ checks.append(("Platform", OK, f"{platform.system()} {platform.release()} {platform.machine()}"))
27
+ url = config.base_url()
28
+ src = "EURON_CODE_API" if os.environ.get("EURON_CODE_API") else "default"
29
+ checks.append(("API base URL", OK, f"{url} ({src})"))
30
+
31
+ # connectivity: any HTTP answer means the gateway is reachable
32
+ try:
33
+ r = httpx.get(url + "/v1/models", timeout=10.0, headers={"User-Agent": api.user_agent()})
34
+ checks.append(("Connectivity", OK, f"reachable (HTTP {r.status_code})"))
35
+ reachable = True
36
+ except httpx.HTTPError as e:
37
+ checks.append(("Connectivity", FAIL, f"cannot reach {url}: {type(e).__name__}"))
38
+ reachable = False
39
+
40
+ cred = credentials.load(url)
41
+ if cred is None:
42
+ checks.append(("Login", WARN, "not logged in; run `euron login`"))
43
+ else:
44
+ checks.append(("Login", OK, f"key {credentials.mask_key(cred.api_key)} in {credentials.where(url)}"))
45
+ if reachable:
46
+ c = api.Client(api_key=cred.api_key, base_url=url)
47
+ try:
48
+ me = c.me(use_cache=False)
49
+ access = me.get("access") or {}
50
+ if access.get("entitled"):
51
+ q = me.get("quota") or {}
52
+ checks.append(("Entitlement", OK, f"{api.plan_label(access)}; "
53
+ f"{q.get('remainingToday', '?'):,} tokens left today"
54
+ if isinstance(q.get("remainingToday"), int) else api.plan_label(access)))
55
+ else:
56
+ checks.append(("Entitlement", FAIL, api.NO_PLUS_MESSAGE))
57
+ except api.AuthError:
58
+ checks.append(("Entitlement", FAIL, "the saved key was rejected (401); run `euron login`"))
59
+ except api.EuronError as e:
60
+ checks.append(("Entitlement", WARN, f"could not check: {e.message}"))
61
+ finally:
62
+ c.close()
63
+
64
+ rg = ripgrep_path()
65
+ checks.append(("ripgrep", OK if rg else WARN, rg or "not found; grep uses the slower Python fallback"))
66
+ checks.append(("Keyring", OK if "unavailable" not in credentials.keyring_backend_name() else WARN,
67
+ credentials.keyring_backend_name()))
68
+ kind, exe = detect_shell(config.load_user_settings().get("shell"))
69
+ checks.append(("Shell", OK, f"{kind} ({exe})"))
70
+ term = os.environ.get("TERM_PROGRAM") or os.environ.get("TERM") or ("Windows console" if os.name == "nt" else "?")
71
+ checks.append(("Terminal", OK if sys.stdout.isatty() else WARN,
72
+ f"{term}{'' if sys.stdout.isatty() else ' (output is not a terminal)'}"))
73
+ checks.append(("Config", OK, f"{config.user_settings_path()}"))
74
+ return checks
75
+
76
+
77
+ def doctor(ui) -> int:
78
+ checks = run_checks()
79
+ style = {OK: "[green]OK [/green]", WARN: "[yellow]WARN[/yellow]", FAIL: "[red]FAIL[/red]"}
80
+ for name, st, detail in checks:
81
+ ui.info(f"{style[st]} {name:<14} {escape(detail)}")
82
+ fails = [c for c in checks if c[1] == FAIL]
83
+ ui.info("")
84
+ if fails:
85
+ ui.info(f"[red]{len(fails)} problem(s) found.[/red]")
86
+ return 1
87
+ ui.info("[green]All checks passed.[/green]")
88
+ return 0
@@ -342,6 +342,13 @@ class Repl:
342
342
  from prompt_toolkit import PromptSession
343
343
  from prompt_toolkit.filters import Condition
344
344
  from prompt_toolkit.history import FileHistory
345
+
346
+ class _SafeHistory(FileHistory):
347
+ """History that never fails on emoji pasted into a Windows console."""
348
+
349
+ def store_string(self, string: str) -> None:
350
+ from euron_code import textsafe
351
+ super().store_string(textsafe.clean(string))
345
352
  from prompt_toolkit.key_binding import KeyBindings
346
353
  from prompt_toolkit.styles import Style
347
354
 
@@ -413,7 +420,7 @@ class Repl:
413
420
  return (t.startswith("/") and " " not in t) or (last.startswith("@") and len(last) > 1)
414
421
 
415
422
  session = PromptSession(
416
- history=FileHistory(str(hist)),
423
+ history=_SafeHistory(str(hist)),
417
424
  completer=_completer(self),
418
425
  complete_while_typing=Condition(auto_menu),
419
426
  key_bindings=kb,
@@ -0,0 +1,41 @@
1
+ """Text that is always valid UTF-8.
2
+
3
+ Windows consoles hand Python emoji and other non-BMP characters as two UTF-16
4
+ surrogate halves (seen live 2026-11-05: pasting text with emoji crashed the
5
+ prompt history and then every request with "surrogates not allowed"). File
6
+ contents decoded with errors="surrogateescape" can carry lone surrogates too.
7
+ `clean` rejoins valid pairs into the real character and replaces anything still
8
+ broken, so nothing downstream (history file, session file, JSON body) can fail.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Any
14
+
15
+
16
+ def clean(s: str) -> str:
17
+ if not isinstance(s, str) or s.isascii():
18
+ return s
19
+ try:
20
+ s.encode("utf-8")
21
+ return s # already valid
22
+ except UnicodeEncodeError:
23
+ pass
24
+ try:
25
+ s = s.encode("utf-16", "surrogatepass").decode("utf-16") # rejoin pairs
26
+ except UnicodeError:
27
+ pass
28
+ return s.encode("utf-8", "replace").decode("utf-8") # lone halves become "?"
29
+
30
+
31
+ def clean_obj(o: Any) -> Any:
32
+ """Recursively clean every string in a JSON-like value (dicts, lists, strings)."""
33
+ if isinstance(o, str):
34
+ return clean(o)
35
+ if isinstance(o, list):
36
+ return [clean_obj(x) for x in o]
37
+ if isinstance(o, tuple):
38
+ return tuple(clean_obj(x) for x in o)
39
+ if isinstance(o, dict):
40
+ return {clean(k) if isinstance(k, str) else k: clean_obj(v) for k, v in o.items()}
41
+ return o
@@ -160,7 +160,7 @@ def money(v: Optional[Dict[str, Any]], unicode: bool = True) -> str:
160
160
 
161
161
  def reference_label(value: Optional[Dict[str, Any]]) -> str:
162
162
  ref = (value or {}).get("reference") or {}
163
- label = ref.get("label") or "Claude Sonnet 5.5 API list price"
163
+ label = ref.get("label") or "reference market API prices"
164
164
  return "at " + (label if label.endswith("s") else label + "s")
165
165
 
166
166