euron-code 0.1.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.
@@ -0,0 +1,197 @@
1
+ """Credential storage: OS keyring when available, else ~/.euron/credentials.json (0600).
2
+
3
+ Credentials are stored per API base URL, so logging in against a local test gateway
4
+ never overwrites the real key.
5
+
6
+ The API key is never printed in full: use `mask_key`.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import stat
14
+ from dataclasses import dataclass, field
15
+ from pathlib import Path
16
+ from typing import Any, Dict, Optional
17
+
18
+ from euron_code import config
19
+
20
+ SERVICE = "euron-code"
21
+
22
+
23
+ @dataclass
24
+ class Credential:
25
+ api_key: str
26
+ base_url: str
27
+ user: Dict[str, Any] = field(default_factory=dict)
28
+
29
+ def to_json(self) -> Dict[str, Any]:
30
+ return {"apiKey": self.api_key, "baseUrl": self.base_url, "user": self.user}
31
+
32
+ @classmethod
33
+ def from_json(cls, d: Dict[str, Any]) -> "Credential":
34
+ return cls(api_key=d["apiKey"], base_url=d.get("baseUrl", ""), user=d.get("user") or {})
35
+
36
+
37
+ def mask_key(key: Optional[str]) -> str:
38
+ if not key:
39
+ return "(none)"
40
+ if len(key) <= 12:
41
+ return key[:3] + "..."
42
+ return key[:7] + "..." + key[-4:]
43
+
44
+
45
+ def credentials_file() -> Path:
46
+ return config.euron_home() / "credentials.json"
47
+
48
+
49
+ def _store_mode() -> str:
50
+ env = os.environ.get("EURON_CREDENTIAL_STORE")
51
+ if env:
52
+ return env
53
+ return str(config.load_user_settings().get("credentialStore") or "auto")
54
+
55
+
56
+ def _keyring():
57
+ """Return a usable keyring module or None."""
58
+ try:
59
+ import keyring # type: ignore
60
+ from keyring.backends import fail # type: ignore
61
+ except Exception:
62
+ return None
63
+ try:
64
+ kr = keyring.get_keyring()
65
+ if isinstance(kr, fail.Keyring):
66
+ return None
67
+ name = type(kr).__name__.lower()
68
+ if "null" in name or "fail" in name:
69
+ return None
70
+ # chainer with no usable backends also counts as unusable
71
+ if getattr(kr, "backends", None) == []:
72
+ return None
73
+ except Exception:
74
+ return None
75
+ return keyring
76
+
77
+
78
+ def keyring_backend_name() -> str:
79
+ kr = _keyring()
80
+ if kr is None:
81
+ return "unavailable (using file)"
82
+ try:
83
+ k = kr.get_keyring()
84
+ return f"{type(k).__module__}.{type(k).__name__}"
85
+ except Exception:
86
+ return "unknown"
87
+
88
+
89
+ # ---------------------------------------------------------------- file store
90
+
91
+ def _read_file() -> Dict[str, Any]:
92
+ try:
93
+ with open(credentials_file(), "r", encoding="utf-8") as f:
94
+ data = json.load(f)
95
+ return data if isinstance(data, dict) else {}
96
+ except (OSError, ValueError):
97
+ return {}
98
+
99
+
100
+ def _write_file(data: Dict[str, Any]) -> None:
101
+ path = credentials_file()
102
+ path.parent.mkdir(parents=True, exist_ok=True)
103
+ tmp = path.with_suffix(".tmp")
104
+ flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
105
+ fd = os.open(str(tmp), flags, 0o600)
106
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
107
+ json.dump(data, f, indent=2)
108
+ try:
109
+ os.chmod(tmp, stat.S_IRUSR | stat.S_IWUSR)
110
+ except OSError:
111
+ pass
112
+ os.replace(tmp, path)
113
+ try:
114
+ os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
115
+ except OSError:
116
+ pass
117
+
118
+
119
+ # ---------------------------------------------------------------- public API
120
+
121
+ def save(cred: Credential) -> str:
122
+ """Save and return the store used ('keyring' or 'file')."""
123
+ mode = _store_mode()
124
+ if mode in ("auto", "keyring"):
125
+ kr = _keyring()
126
+ if kr is not None:
127
+ try:
128
+ kr.set_password(SERVICE, cred.base_url, json.dumps(cred.to_json()))
129
+ # remove any stale file copy for this URL
130
+ data = _read_file()
131
+ if cred.base_url in data:
132
+ data.pop(cred.base_url)
133
+ _write_file(data)
134
+ return "keyring"
135
+ except Exception:
136
+ if mode == "keyring":
137
+ raise
138
+ data = _read_file()
139
+ data[cred.base_url] = cred.to_json()
140
+ _write_file(data)
141
+ return "file"
142
+
143
+
144
+ def load(base_url: Optional[str] = None) -> Optional[Credential]:
145
+ url = base_url or config.base_url()
146
+ env_key = os.environ.get("EURON_API_KEY")
147
+ if env_key:
148
+ return Credential(api_key=env_key, base_url=url, user={})
149
+ mode = _store_mode()
150
+ if mode in ("auto", "keyring"):
151
+ kr = _keyring()
152
+ if kr is not None:
153
+ try:
154
+ raw = kr.get_password(SERVICE, url)
155
+ if raw:
156
+ return Credential.from_json(json.loads(raw))
157
+ except Exception:
158
+ pass
159
+ d = _read_file().get(url)
160
+ if isinstance(d, dict) and d.get("apiKey"):
161
+ return Credential.from_json(d)
162
+ return None
163
+
164
+
165
+ def where(base_url: Optional[str] = None) -> str:
166
+ url = base_url or config.base_url()
167
+ if os.environ.get("EURON_API_KEY"):
168
+ return "env EURON_API_KEY"
169
+ kr = _keyring() if _store_mode() in ("auto", "keyring") else None
170
+ if kr is not None:
171
+ try:
172
+ if kr.get_password(SERVICE, url):
173
+ return "keyring"
174
+ except Exception:
175
+ pass
176
+ if url in _read_file():
177
+ return f"file {credentials_file()}"
178
+ return "none"
179
+
180
+
181
+ def delete(base_url: Optional[str] = None) -> bool:
182
+ url = base_url or config.base_url()
183
+ removed = False
184
+ kr = _keyring()
185
+ if kr is not None:
186
+ try:
187
+ if kr.get_password(SERVICE, url):
188
+ kr.delete_password(SERVICE, url)
189
+ removed = True
190
+ except Exception:
191
+ pass
192
+ data = _read_file()
193
+ if url in data:
194
+ data.pop(url)
195
+ _write_file(data)
196
+ removed = True
197
+ return removed
euron_code/doctor.py ADDED
@@ -0,0 +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
euron_code/keys.py ADDED
@@ -0,0 +1,92 @@
1
+ """Esc-to-interrupt while the model is generating.
2
+
3
+ A background thread watches the keyboard (msvcrt on Windows, cbreak termios elsewhere) and sets
4
+ an Event when Esc is pressed. Ctrl-C works everywhere as well (KeyboardInterrupt).
5
+ It is only active during streaming, never while a prompt is reading input.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import sys
12
+ import threading
13
+ import time
14
+ from typing import Optional
15
+
16
+
17
+ class EscWatcher:
18
+ def __init__(self, event: threading.Event, enabled: bool = True):
19
+ self.event = event
20
+ self.enabled = enabled and _stdin_is_tty()
21
+ self._stop = threading.Event()
22
+ self._thread: Optional[threading.Thread] = None
23
+ self._old_term = None
24
+
25
+ def __enter__(self) -> "EscWatcher":
26
+ if not self.enabled:
27
+ return self
28
+ try:
29
+ if os.name != "nt":
30
+ import termios
31
+ import tty
32
+ fd = sys.stdin.fileno()
33
+ self._old_term = termios.tcgetattr(fd)
34
+ tty.setcbreak(fd)
35
+ except Exception:
36
+ self._old_term = None
37
+ self.enabled = False
38
+ return self
39
+ self._thread = threading.Thread(target=self._run, name="euron-esc", daemon=True)
40
+ self._thread.start()
41
+ return self
42
+
43
+ def _run(self) -> None:
44
+ if os.name == "nt":
45
+ import msvcrt
46
+ while not self._stop.is_set():
47
+ try:
48
+ if msvcrt.kbhit():
49
+ ch = msvcrt.getwch()
50
+ if ch == "\x1b":
51
+ self.event.set()
52
+ return
53
+ if ch in ("\x00", "\xe0"): # function/arrow key prefix: swallow
54
+ msvcrt.getwch()
55
+ except Exception:
56
+ return
57
+ time.sleep(0.05)
58
+ else:
59
+ import select
60
+ fd = sys.stdin.fileno()
61
+ while not self._stop.is_set():
62
+ try:
63
+ r, _, _ = select.select([fd], [], [], 0.05)
64
+ if r:
65
+ ch = os.read(fd, 1)
66
+ if ch == b"\x1b":
67
+ # a lone Esc, not the start of an arrow-key sequence
68
+ r2, _, _ = select.select([fd], [], [], 0.03)
69
+ if not r2:
70
+ self.event.set()
71
+ return
72
+ os.read(fd, 8)
73
+ except Exception:
74
+ return
75
+
76
+ def __exit__(self, *exc) -> None:
77
+ self._stop.set()
78
+ if self._thread is not None:
79
+ self._thread.join(timeout=0.5)
80
+ if self._old_term is not None:
81
+ try:
82
+ import termios
83
+ termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, self._old_term)
84
+ except Exception:
85
+ pass
86
+
87
+
88
+ def _stdin_is_tty() -> bool:
89
+ try:
90
+ return sys.stdin.isatty() and sys.stdout.isatty()
91
+ except (AttributeError, ValueError):
92
+ return False
euron_code/memory.py ADDED
@@ -0,0 +1,97 @@
1
+ """EURON.md memory files.
2
+
3
+ Loaded (in this order) into the system prompt:
4
+ 1. ~/.euron/EURON.md (user memory, all projects)
5
+ 2. EURON.md in each parent directory from the git root down to the project root
6
+ 3. <project>/EURON.md (project memory, usually committed)
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+ from typing import List, Optional
14
+
15
+ from euron_code import config
16
+
17
+ FILENAME = "EURON.md"
18
+ MAX_MEMORY_CHARS = 12000 # keep memory from eating the 32k window
19
+
20
+
21
+ @dataclass
22
+ class MemoryFile:
23
+ path: Path
24
+ scope: str # user | parent | project
25
+ text: str
26
+
27
+
28
+ def memory_paths(root: Path) -> List[tuple]:
29
+ root = root.resolve()
30
+ out = [(config.euron_home() / FILENAME, "user")]
31
+ git_root = config.find_git_root(root)
32
+ if git_root is not None and git_root != root:
33
+ chain = []
34
+ cur = root.parent
35
+ while True:
36
+ chain.append(cur)
37
+ if cur == git_root or cur == cur.parent:
38
+ break
39
+ cur = cur.parent
40
+ for d in reversed(chain):
41
+ out.append((d / FILENAME, "parent"))
42
+ out.append((root / FILENAME, "project"))
43
+ return out
44
+
45
+
46
+ def load(root: Path) -> List[MemoryFile]:
47
+ files = []
48
+ for p, scope in memory_paths(root):
49
+ try:
50
+ text = p.read_text(encoding="utf-8", errors="replace").strip()
51
+ except OSError:
52
+ continue
53
+ if text:
54
+ files.append(MemoryFile(p, scope, text))
55
+ return files
56
+
57
+
58
+ def render(files: List[MemoryFile]) -> str:
59
+ if not files:
60
+ return ""
61
+ parts = []
62
+ budget = MAX_MEMORY_CHARS
63
+ for f in files:
64
+ text = f.text
65
+ if len(text) > budget:
66
+ text = text[:budget] + "\n[... memory file truncated ...]"
67
+ budget -= len(text)
68
+ parts.append(f"<memory scope=\"{f.scope}\" path=\"{f.path.as_posix()}\">\n{text}\n</memory>")
69
+ if budget <= 0:
70
+ break
71
+ return "\n\n".join(parts)
72
+
73
+
74
+ def project_file(root: Path) -> Path:
75
+ return root.resolve() / FILENAME
76
+
77
+
78
+ def user_file() -> Path:
79
+ return config.euron_home() / FILENAME
80
+
81
+
82
+ def append_note(note: str, root: Path, scope: str = "project") -> Path:
83
+ path = user_file() if scope == "user" else project_file(root)
84
+ path.parent.mkdir(parents=True, exist_ok=True)
85
+ existing: Optional[str] = None
86
+ try:
87
+ existing = path.read_text(encoding="utf-8")
88
+ except OSError:
89
+ existing = None
90
+ note = note.strip()
91
+ with open(path, "a", encoding="utf-8", newline="") as f:
92
+ if existing is None or not existing.strip():
93
+ f.write("# Notes for Euron Code\n\n")
94
+ elif not existing.endswith("\n"):
95
+ f.write("\n")
96
+ f.write(f"- {note}\n")
97
+ return path