altero 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.
Files changed (53) hide show
  1. altero/__init__.py +9 -0
  2. altero/__main__.py +6 -0
  3. altero/appearance.py +219 -0
  4. altero/assets/menubar-template.png +0 -0
  5. altero/assets/menubar-template@2x.png +0 -0
  6. altero/autoswitch.py +2859 -0
  7. altero/bundle.py +120 -0
  8. altero/cache.py +44 -0
  9. altero/claude_locks.py +187 -0
  10. altero/cli.py +1989 -0
  11. altero/credentials.py +1849 -0
  12. altero/exceptions.py +91 -0
  13. altero/fsutil.py +101 -0
  14. altero/json_output.py +289 -0
  15. altero/launch_agent.py +923 -0
  16. altero/locking.py +269 -0
  17. altero/logging_config.py +64 -0
  18. altero/macos_keychain.py +226 -0
  19. altero/mappings.py +142 -0
  20. altero/menubar.py +1288 -0
  21. altero/migrations.py +537 -0
  22. altero/models.py +211 -0
  23. altero/oauth.py +806 -0
  24. altero/pace.py +196 -0
  25. altero/paths.py +110 -0
  26. altero/poll_policy.py +270 -0
  27. altero/printer.py +232 -0
  28. altero/process_detection.py +350 -0
  29. altero/session.py +1499 -0
  30. altero/settings.py +527 -0
  31. altero/snapshot_json.py +295 -0
  32. altero/snapshot_source.py +100 -0
  33. altero/state_watch.py +110 -0
  34. altero/switcher.py +7449 -0
  35. altero/transfer.py +645 -0
  36. altero/tui/__init__.py +66 -0
  37. altero/tui/altero.tcss +205 -0
  38. altero/tui/app.py +467 -0
  39. altero/tui/autoview.py +454 -0
  40. altero/tui/dashboard.py +432 -0
  41. altero/tui/data.py +194 -0
  42. altero/tui/modals.py +159 -0
  43. altero/tui/theme.py +141 -0
  44. altero/tui/widgets.py +403 -0
  45. altero/update_check.py +215 -0
  46. altero/usage_history.py +145 -0
  47. altero/usage_store.py +1234 -0
  48. altero/widget_requests.py +217 -0
  49. altero-0.1.0.dist-info/METADATA +166 -0
  50. altero-0.1.0.dist-info/RECORD +53 -0
  51. altero-0.1.0.dist-info/WHEEL +4 -0
  52. altero-0.1.0.dist-info/entry_points.txt +2 -0
  53. altero-0.1.0.dist-info/licenses/LICENSE +22 -0
altero/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """Multi-account switcher for Claude Code."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ __version__ = version("altero")
6
+
7
+ from altero.switcher import ClaudeAccountSwitcher
8
+
9
+ __all__ = ["ClaudeAccountSwitcher", "__version__"]
altero/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Allow running as `python -m altero`."""
2
+
3
+ from altero.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
altero/appearance.py ADDED
@@ -0,0 +1,219 @@
1
+ """Terminal appearance detection and theme resolution.
2
+
3
+ Determines whether the terminal has a light or dark background by querying it
4
+ with OSC 11 (``ESC ] 11 ; ? BEL``) followed by DA1 (``ESC [ c``), reading
5
+ through the ordered DA1 reply, and classifying the preceding ``rgb:…`` reply by
6
+ perceived luminance. Cross-cutting: both the CLI printer and the TUI resolve
7
+ their theme through here.
8
+
9
+ The query MUST happen while this process owns the terminal in cooked mode —
10
+ before Textual's input driver starts — or the reply is reissued as keystrokes.
11
+ Everything fails safe to ``None`` (→ resolved ``dark``): a terminal that doesn't
12
+ answer, a pipe, Windows, or a parse failure never blocks indefinitely and never
13
+ errors.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ import re
20
+ import select
21
+ import sys
22
+ import time
23
+
24
+ _QUERY = b"\x1b]11;?\x07" # OSC 11, BEL-terminated
25
+ _DA1_QUERY = b"\x1b[c" # ordered response boundary
26
+ # DA1 lets unsupported terminals return promptly; the cap mainly covers SSH
27
+ # latency or a non-conforming terminal that answers neither query.
28
+ _TIMEOUT_S = 1.0
29
+ _MAX_REPLY = 256
30
+ # The full `ESC ]11;` opener is required so interleaved or echoed input (e.g.
31
+ # a shell echoing back a pasted escape sequence) can't be misparsed as a
32
+ # background reply just because `]11;rgb:`/`]11;#` appears somewhere in the
33
+ # buffer without the ESC that actually starts an OSC sequence.
34
+ _RGB = re.compile(
35
+ rb"\x1b\]11;rgb:([0-9a-fA-F]+)/([0-9a-fA-F]+)/([0-9a-fA-F]+)"
36
+ rb"(?:\x07|\x1b\\)"
37
+ )
38
+ _HEX = re.compile(rb"\x1b\]11;#([0-9a-fA-F]{6})(?:\x07|\x1b\\)")
39
+ # A primary device-attributes reply is CSI ? Ps c. Requiring ``?`` and at
40
+ # least the private marker keeps an echoed DA1 query (CSI c) from looking
41
+ # complete; Ps itself may legally be empty.
42
+ _DA1_REPLY = re.compile(rb"(?:\x1b\[|\x9b)\?[0-9;:]*c")
43
+
44
+ # Cache: the terminal background can't change within a process, so query once.
45
+ _UNSET = object()
46
+ _cache: object | str | None = _UNSET
47
+
48
+
49
+ def _reset_cache() -> None:
50
+ """Test helper: forget any cached detection result."""
51
+ global _cache
52
+ _cache = _UNSET
53
+
54
+
55
+ def _parse_osc11(reply: bytes) -> tuple[float, float, float] | None:
56
+ """Parse an OSC 11 reply into (r, g, b) each normalised to 0..1."""
57
+ m = _RGB.search(reply)
58
+ if m:
59
+ return tuple(
60
+ int(h, 16) / (16 ** len(h) - 1) for h in m.groups()
61
+ ) # type: ignore[return-value]
62
+ m = _HEX.search(reply)
63
+ if m:
64
+ h = m.group(1).decode("ascii")
65
+ return tuple(int(h[i:i + 2], 16) / 255 for i in (0, 2, 4)) # type: ignore[return-value]
66
+ return None
67
+
68
+
69
+ def _classify(reply: bytes) -> str | None:
70
+ """Light/dark from an OSC 11 reply, or None if unparseable."""
71
+ rgb = _parse_osc11(reply)
72
+ if rgb is None:
73
+ return None
74
+ r, g, b = rgb
75
+ luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b # BT.709-weighted brightness on gamma-encoded channels (approx.)
76
+ return "light" if luminance > 0.5 else "dark"
77
+
78
+
79
+ def _query_terminal_background() -> bytes | None:
80
+ """Send OSC 11 + DA1 and read through the DA1 reply. None on any failure.
81
+
82
+ Isolated so tests can substitute a canned reply without a real tty.
83
+
84
+ Terminals process queries in order. DA1 is widely supported, so its reply
85
+ marks the point after any OSC 11 reply and prevents a slower colour reply
86
+ from being reissued as shell input after this process restores the tty.
87
+ The timeout remains a safety cap for high-latency or non-conforming
88
+ terminals.
89
+
90
+ ``TERM=dumb`` and the Linux console don't support this colour query.
91
+ Likewise, tmux and screen don't pass it through to the outer terminal by
92
+ default. Those environments short-circuit to ``None`` (resolving to
93
+ ``dark``) without probing. Fails safe either way.
94
+ """
95
+ if os.name == "nt":
96
+ return None
97
+ if os.environ.get("TERM") in ("dumb", "linux"):
98
+ return None
99
+ if os.environ.get("TMUX") or os.environ.get("STY"):
100
+ return None
101
+ try:
102
+ import termios
103
+ import tty
104
+ except ImportError:
105
+ return None
106
+ try:
107
+ if not (sys.stdin.isatty() and sys.stdout.isatty()):
108
+ return None
109
+ except (ValueError, OSError):
110
+ # isatty() can raise on a closed stream.
111
+ return None
112
+ try:
113
+ fd = sys.stdin.fileno()
114
+ old = termios.tcgetattr(fd)
115
+ except (termios.error, OSError):
116
+ return None
117
+ try:
118
+ # TCSANOW (not TCSADRAIN): draining first can block under terminal
119
+ # flow control, and there's no pending output to drain anyway.
120
+ tty.setcbreak(fd, termios.TCSANOW)
121
+ sys.stdout.write((_QUERY + _DA1_QUERY).decode("latin-1"))
122
+ sys.stdout.flush()
123
+ deadline = time.monotonic() + _TIMEOUT_S
124
+ buf = b""
125
+ while time.monotonic() < deadline and len(buf) < _MAX_REPLY:
126
+ remaining = deadline - time.monotonic()
127
+ ready, _, _ = select.select([fd], [], [], max(0.0, remaining))
128
+ if not ready:
129
+ break
130
+ chunk = os.read(fd, 32)
131
+ if not chunk:
132
+ break
133
+ buf += chunk
134
+ # Do not stop at the OSC reply: the DA1 response is the ordered
135
+ # boundary proving that no colour-query bytes are still in flight.
136
+ if _DA1_REPLY.search(buf) is not None:
137
+ break
138
+ return buf or None
139
+ except (termios.error, OSError, ValueError):
140
+ return None
141
+ finally:
142
+ try:
143
+ termios.tcsetattr(fd, termios.TCSANOW, old)
144
+ except (termios.error, OSError):
145
+ pass
146
+
147
+
148
+ def detect_terminal_background() -> str | None:
149
+ """'light' | 'dark' from the terminal background, or None if undetectable.
150
+
151
+ Cached per process. MUST be first called in cooked mode (before app.run()).
152
+ """
153
+ global _cache
154
+ if _cache is _UNSET:
155
+ reply = _query_terminal_background()
156
+ _cache = _classify(reply) if reply is not None else None
157
+ return _cache # type: ignore[return-value]
158
+
159
+
160
+ def resolve_theme(setting: str, detect=detect_terminal_background) -> str:
161
+ """Resolve a ui.theme setting to a concrete 'light'/'dark'.
162
+
163
+ 'dark'/'light' pass through without probing; 'auto' follows ``detect()``,
164
+ falling back to 'dark' when detection yields None.
165
+ """
166
+ if setting in ("dark", "light"):
167
+ return setting
168
+ return detect() or "dark"
169
+
170
+
171
+ def cli_should_probe(argv: list[str], *, colors_enabled: bool) -> bool:
172
+ """Whether the CLI should probe the terminal background before dispatch.
173
+
174
+ False when colors are off (nothing will render the theme anyway), when
175
+ the first token is ``run`` (execs a child that takes over the terminal)
176
+ or ``snapshot`` (stdout is always machine-readable there), or when
177
+ ``--json`` is present (the OSC query must never precede machine-readable
178
+ output on stdout).
179
+ """
180
+ if not colors_enabled:
181
+ return False
182
+ if argv and argv[0] in ("run", "snapshot"):
183
+ return False
184
+ if "--json" in argv:
185
+ return False
186
+ return True
187
+
188
+
189
+ def cli_theme(setting: str, *, detect=detect_terminal_background, colors: bool) -> str:
190
+ """Resolve a theme for a plain-CLI invocation: probe only when color will
191
+ actually be emitted; otherwise auto degrades to dark without a tty query."""
192
+ if setting == "auto" and not colors:
193
+ return "dark"
194
+ return resolve_theme(setting, detect=detect)
195
+
196
+
197
+ def drain_stdin() -> None:
198
+ """Discard any pending terminal input (e.g. a late OSC reply) so it isn't
199
+ reissued as keystrokes once Textual takes over. Best-effort; POSIX only.
200
+
201
+ A reply that arrives after the detection deadline and after this drain
202
+ can, in principle, still reach the running app as stray keystrokes —
203
+ inherent to any finite-timeout OSC probe, not fully closeable here.
204
+ """
205
+ if os.name == "nt":
206
+ return
207
+ try:
208
+ import termios
209
+ except ImportError:
210
+ return
211
+ try:
212
+ if not sys.stdin.isatty():
213
+ return
214
+ except (ValueError, OSError):
215
+ return
216
+ try:
217
+ termios.tcflush(sys.stdin.fileno(), termios.TCIFLUSH)
218
+ except (termios.error, OSError):
219
+ pass
Binary file
Binary file