aswap 0.1.0__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 (49) hide show
  1. aswap-0.1.0/.gitignore +1 -0
  2. aswap-0.1.0/LICENSE +22 -0
  3. aswap-0.1.0/PKG-INFO +44 -0
  4. aswap-0.1.0/README.md +12 -0
  5. aswap-0.1.0/pyproject.toml +72 -0
  6. aswap-0.1.0/src/aswap/__init__.py +8 -0
  7. aswap-0.1.0/src/aswap/cli.py +131 -0
  8. aswap-0.1.0/src/claude_swap/__init__.py +9 -0
  9. aswap-0.1.0/src/claude_swap/__main__.py +6 -0
  10. aswap-0.1.0/src/claude_swap/appearance.py +218 -0
  11. aswap-0.1.0/src/claude_swap/autoswitch.py +2364 -0
  12. aswap-0.1.0/src/claude_swap/cache.py +44 -0
  13. aswap-0.1.0/src/claude_swap/claude_locks.py +187 -0
  14. aswap-0.1.0/src/claude_swap/cli.py +1494 -0
  15. aswap-0.1.0/src/claude_swap/credentials.py +1849 -0
  16. aswap-0.1.0/src/claude_swap/distribution.py +42 -0
  17. aswap-0.1.0/src/claude_swap/exceptions.py +96 -0
  18. aswap-0.1.0/src/claude_swap/fsutil.py +101 -0
  19. aswap-0.1.0/src/claude_swap/json_output.py +288 -0
  20. aswap-0.1.0/src/claude_swap/launch_agent.py +289 -0
  21. aswap-0.1.0/src/claude_swap/locking.py +83 -0
  22. aswap-0.1.0/src/claude_swap/logging_config.py +64 -0
  23. aswap-0.1.0/src/claude_swap/macos_keychain.py +226 -0
  24. aswap-0.1.0/src/claude_swap/mappings.py +142 -0
  25. aswap-0.1.0/src/claude_swap/menubar.py +1056 -0
  26. aswap-0.1.0/src/claude_swap/migrations.py +537 -0
  27. aswap-0.1.0/src/claude_swap/models.py +211 -0
  28. aswap-0.1.0/src/claude_swap/oauth.py +806 -0
  29. aswap-0.1.0/src/claude_swap/pace.py +161 -0
  30. aswap-0.1.0/src/claude_swap/paths.py +221 -0
  31. aswap-0.1.0/src/claude_swap/poll_policy.py +270 -0
  32. aswap-0.1.0/src/claude_swap/printer.py +232 -0
  33. aswap-0.1.0/src/claude_swap/process_detection.py +350 -0
  34. aswap-0.1.0/src/claude_swap/session.py +1500 -0
  35. aswap-0.1.0/src/claude_swap/settings.py +488 -0
  36. aswap-0.1.0/src/claude_swap/snapshot_source.py +100 -0
  37. aswap-0.1.0/src/claude_swap/switcher.py +7678 -0
  38. aswap-0.1.0/src/claude_swap/transfer.py +645 -0
  39. aswap-0.1.0/src/claude_swap/tui/__init__.py +43 -0
  40. aswap-0.1.0/src/claude_swap/tui/app.py +433 -0
  41. aswap-0.1.0/src/claude_swap/tui/autoview.py +331 -0
  42. aswap-0.1.0/src/claude_swap/tui/cswap.tcss +205 -0
  43. aswap-0.1.0/src/claude_swap/tui/dashboard.py +432 -0
  44. aswap-0.1.0/src/claude_swap/tui/data.py +194 -0
  45. aswap-0.1.0/src/claude_swap/tui/modals.py +159 -0
  46. aswap-0.1.0/src/claude_swap/tui/theme.py +141 -0
  47. aswap-0.1.0/src/claude_swap/tui/widgets.py +403 -0
  48. aswap-0.1.0/src/claude_swap/update_check.py +192 -0
  49. aswap-0.1.0/src/claude_swap/usage_store.py +1234 -0
aswap-0.1.0/.gitignore ADDED
@@ -0,0 +1 @@
1
+ *
aswap-0.1.0/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kevin Cui
4
+ Copyright (c) 2025 Onur Cetinkol (claude-swap)
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
aswap-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,44 @@
1
+ Metadata-Version: 2.5
2
+ Name: aswap
3
+ Version: 0.1.0
4
+ Summary: Account swap for Claude Code: claude-swap 0.27.0b1 (cswap) with 2 maintained patches, as the aswap command
5
+ Project-URL: Homepage, https://github.com/BlackHole1/aswap
6
+ Project-URL: Repository, https://github.com/BlackHole1/aswap
7
+ Project-URL: Issues, https://github.com/BlackHole1/aswap/issues
8
+ Project-URL: Upstream, https://github.com/realiti4/claude-swap
9
+ Author-email: Onur Cetinkol <onurcetinkol@gmail.com>
10
+ License: MIT
11
+ License-File: LICENSE
12
+ Keywords: account-switcher,claude,claude-code,cli
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Environment :: Console
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Operating System :: MacOS
18
+ Classifier: Operating System :: Microsoft :: Windows
19
+ Classifier: Operating System :: POSIX :: Linux
20
+ Classifier: Programming Language :: Python :: 3
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Programming Language :: Python :: 3.14
24
+ Classifier: Topic :: Utilities
25
+ Requires-Python: >=3.12
26
+ Requires-Dist: keyring>=25.0.0; sys_platform == 'win32'
27
+ Requires-Dist: textual<9,>=8.2.8
28
+ Requires-Dist: truststore>=0.10.4
29
+ Provides-Extra: menubar
30
+ Requires-Dist: rumps>=0.4.0; extra == 'menubar'
31
+ Description-Content-Type: text/markdown
32
+
33
+ # aswap
34
+
35
+ Account swap for Claude Code: [claude-swap](https://github.com/realiti4/claude-swap) (`cswap`) with a maintained patch series on top, installed as the `aswap` command.
36
+
37
+ ```bash
38
+ uv tool install aswap # or: pipx install aswap
39
+ aswap --version
40
+ aswap link # optional: also answer to `cswap`
41
+ aswap upgrade # later
42
+ ```
43
+
44
+ `aswap` accepts everything `cswap` does. What the patches fix, and how the package is built from unmodified upstream plus patches, is documented at <https://github.com/BlackHole1/aswap>.
aswap-0.1.0/README.md ADDED
@@ -0,0 +1,12 @@
1
+ # aswap
2
+
3
+ Account swap for Claude Code: [claude-swap](https://github.com/realiti4/claude-swap) (`cswap`) with a maintained patch series on top, installed as the `aswap` command.
4
+
5
+ ```bash
6
+ uv tool install aswap # or: pipx install aswap
7
+ aswap --version
8
+ aswap link # optional: also answer to `cswap`
9
+ aswap upgrade # later
10
+ ```
11
+
12
+ `aswap` accepts everything `cswap` does. What the patches fix, and how the package is built from unmodified upstream plus patches, is documented at <https://github.com/BlackHole1/aswap>.
@@ -0,0 +1,72 @@
1
+ [project]
2
+ name = "aswap"
3
+ version = "0.1.0"
4
+ description = "Account swap for Claude Code: claude-swap 0.27.0b1 (cswap) with 2 maintained patches, as the aswap command"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ dependencies = [
8
+ # Windows-only: needed solely by the one-time keyring -> files migration
9
+ # (migrations.py) for users upgrading from <=0.10.x. macOS/Linux never
10
+ # import it on the hot path; the macOS migration has a security-CLI
11
+ # fallback and the legacy purge sweep no-ops when it's absent.
12
+ "keyring>=25.0.0; sys_platform == 'win32'",
13
+ "textual>=8.2.8,<9",
14
+ "truststore>=0.10.4",
15
+ ]
16
+ authors = [{name = "Onur Cetinkol", email = "onurcetinkol@gmail.com"}]
17
+ license = {text = "MIT"}
18
+ keywords = ["claude", "claude-code", "account-switcher", "cli"]
19
+ classifiers = [
20
+ "Development Status :: 4 - Beta",
21
+ "Environment :: Console",
22
+ "Intended Audience :: Developers",
23
+ "License :: OSI Approved :: MIT License",
24
+ "Operating System :: MacOS",
25
+ "Operating System :: Microsoft :: Windows",
26
+ "Operating System :: POSIX :: Linux",
27
+ "Programming Language :: Python :: 3",
28
+ "Programming Language :: Python :: 3.12",
29
+ "Programming Language :: Python :: 3.13",
30
+ "Programming Language :: Python :: 3.14",
31
+ "Topic :: Utilities",
32
+ ]
33
+
34
+ [project.optional-dependencies]
35
+ menubar = ["rumps>=0.4.0"]
36
+
37
+ [project.urls]
38
+ Homepage = "https://github.com/BlackHole1/aswap"
39
+ Repository = "https://github.com/BlackHole1/aswap"
40
+ Issues = "https://github.com/BlackHole1/aswap/issues"
41
+ Upstream = "https://github.com/realiti4/claude-swap"
42
+
43
+ [project.scripts]
44
+ aswap = "aswap.cli:main"
45
+
46
+ [build-system]
47
+ requires = ["hatchling"]
48
+ build-backend = "hatchling.build"
49
+
50
+ [tool.hatch.build.targets.wheel]
51
+ packages = ["src/claude_swap", "src/aswap"]
52
+
53
+ [dependency-groups]
54
+ dev = ["pytest>=8.0", "pytest-asyncio>=0.24", "pytest-xdist>=3.5"]
55
+
56
+ [tool.pytest.ini_options]
57
+ testpaths = ["tests"]
58
+ pythonpath = ["src"]
59
+ # Parallel by default. The suite is ~1900 tests of mostly-idle work (file
60
+ # locks, subprocess fakes, Textual pilots), so wall clock was dominated by
61
+ # doing them one at a time, not by any single slow test: 25 ms/test spread
62
+ # across the whole suite, with the top five totalling under 7 s of a 51 s run.
63
+ # Measured here, 48 cores: 51.0s serial -> 12.1s (-n 8) -> 7.7s (-n 16), same
64
+ # 1929 passed / 4 skipped every time, three consecutive runs, real account
65
+ # store hash unmoved. `auto` rather than a fixed number so CI runners (2-4
66
+ # cores) and this box both get the cores they have.
67
+ addopts = "-n auto --dist loadgroup"
68
+ asyncio_default_fixture_loop_scope = "function"
69
+ markers = [
70
+ "no_keychain_fake: opt out of the autouse in-memory Keychain guard (test mocks subprocess itself, or runs against a temp keychain on GitHub Actions)",
71
+ "no_oauth_profile_fake: opt out of the autouse fetch_oauth_profile stub (test exercises fetch_oauth_profile itself against a mocked urlopen)",
72
+ ]
@@ -0,0 +1,8 @@
1
+ """The ``aswap`` command: claude-swap with the aswap patch series applied."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("aswap")
7
+ except PackageNotFoundError: # running from a source checkout
8
+ __version__ = "0.0.0"
@@ -0,0 +1,131 @@
1
+ """Entry point for ``aswap``.
2
+
3
+ Everything is claude-swap's own CLI except two commands of ours:
4
+
5
+ aswap link make ``cswap`` run this install (a ``cswap`` launcher next to ``aswap``)
6
+ aswap unlink remove that launcher again
7
+
8
+ Installers (uv tool, pipx) install every entry point a package declares and
9
+ cannot skip one, so shipping a ``cswap`` entry point would collide with an
10
+ installed upstream claude-swap. A launcher the user creates on purpose keeps
11
+ both choices open: side by side by default, replacement on request.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ import shutil
18
+ import sys
19
+ from pathlib import Path
20
+
21
+ WINDOWS = sys.platform == "win32"
22
+ LAUNCHER = "cswap.exe" if WINDOWS else "cswap"
23
+
24
+ USAGE = """usage: aswap link [--force]
25
+ aswap unlink [--force]
26
+ aswap link --status
27
+
28
+ Create (or remove) a `cswap` launcher next to the `aswap` command, so `cswap`
29
+ runs this install instead of upstream claude-swap.
30
+
31
+ --status show what `cswap` currently resolves to
32
+ --force replace (or remove) a `cswap` that aswap did not create
33
+ """
34
+
35
+
36
+ def _own_launcher() -> Path:
37
+ """The ``aswap`` launcher the user ran, resolved through PATH when it was
38
+ typed bare."""
39
+ argv0 = Path(sys.argv[0] or "")
40
+ if argv0.parent != Path(".") and argv0.exists():
41
+ return argv0.absolute()
42
+ found = shutil.which("aswap")
43
+ if found:
44
+ return Path(found).absolute()
45
+ return argv0.absolute()
46
+
47
+
48
+ def _links_to(target: Path, launcher: Path) -> bool:
49
+ """Whether ``target`` is a launcher aswap created for ``launcher``."""
50
+ try:
51
+ if target.is_symlink():
52
+ return target.resolve() == launcher.resolve()
53
+ if WINDOWS and target.is_file() and launcher.is_file():
54
+ return target.read_bytes() == launcher.read_bytes()
55
+ except OSError:
56
+ pass
57
+ return False
58
+
59
+
60
+ def _status(target: Path, launcher: Path) -> int:
61
+ on_path = shutil.which("cswap")
62
+ if not target.exists() and not target.is_symlink():
63
+ print(f"cswap: not linked (would be created at {target})")
64
+ elif _links_to(target, launcher):
65
+ print(f"cswap -> {launcher} (linked by aswap)")
66
+ else:
67
+ print(f"cswap at {target} is not aswap's (upstream claude-swap or another tool)")
68
+ if on_path and Path(on_path).absolute() != target:
69
+ print(f"note: `cswap` on PATH resolves to {on_path} first")
70
+ return 0
71
+
72
+
73
+ def _link(force: bool) -> int:
74
+ launcher = _own_launcher()
75
+ if not launcher.exists():
76
+ print(f"error: cannot locate the aswap launcher (looked at {launcher})", file=sys.stderr)
77
+ return 1
78
+ target = launcher.parent / LAUNCHER
79
+ if _links_to(target, launcher):
80
+ print(f"cswap already runs aswap ({target})")
81
+ return 0
82
+ if target.exists() or target.is_symlink():
83
+ if not force:
84
+ print(
85
+ f"error: {target} exists and is not aswap's.\n"
86
+ "If it is upstream claude-swap, remove it first (uv tool uninstall claude-swap "
87
+ "or pipx uninstall claude-swap), or pass --force to replace it.",
88
+ file=sys.stderr,
89
+ )
90
+ return 1
91
+ target.unlink()
92
+ if WINDOWS:
93
+ shutil.copy2(launcher, target)
94
+ else:
95
+ os.symlink(launcher.name, target)
96
+ print(f"linked: {target} -> {launcher.name}")
97
+ other = shutil.which("cswap")
98
+ if other and Path(other).absolute() != target:
99
+ print(f"note: `cswap` on PATH still resolves to {other} first; remove it or reorder PATH")
100
+ return 0
101
+
102
+
103
+ def _unlink(force: bool) -> int:
104
+ launcher = _own_launcher()
105
+ target = launcher.parent / LAUNCHER
106
+ if not target.exists() and not target.is_symlink():
107
+ print("cswap is not linked")
108
+ return 0
109
+ if not _links_to(target, launcher) and not force:
110
+ print(f"error: {target} was not created by aswap; pass --force to remove it anyway", file=sys.stderr)
111
+ return 1
112
+ target.unlink()
113
+ print(f"unlinked: {target}")
114
+ return 0
115
+
116
+
117
+ def main(argv: list[str] | None = None) -> int | None:
118
+ args = list(sys.argv[1:] if argv is None else argv)
119
+ if args and args[0] in ("link", "unlink"):
120
+ flags = args[1:]
121
+ unknown = [a for a in flags if a not in ("--force", "--status", "-h", "--help")]
122
+ if unknown or "-h" in flags or "--help" in flags:
123
+ print(USAGE, end="", file=sys.stderr if unknown else sys.stdout)
124
+ return 2 if unknown else 0
125
+ launcher = _own_launcher()
126
+ if "--status" in flags:
127
+ return _status(launcher.parent / LAUNCHER, launcher)
128
+ return (_link if args[0] == "link" else _unlink)("--force" in flags)
129
+ from claude_swap.cli import main as cswap_main
130
+
131
+ return cswap_main()
@@ -0,0 +1,9 @@
1
+ """Multi-account switcher for Claude Code."""
2
+
3
+ from claude_swap.distribution import distribution_version
4
+
5
+ __version__ = distribution_version()
6
+
7
+ from claude_swap.switcher import ClaudeAccountSwitcher
8
+
9
+ __all__ = ["ClaudeAccountSwitcher", "__version__"]
@@ -0,0 +1,6 @@
1
+ """Allow running as `python -m claude_swap`."""
2
+
3
+ from claude_swap.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -0,0 +1,218 @@
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 when ``--json`` is present (the OSC query must never precede
177
+ machine-readable output on stdout).
178
+ """
179
+ if not colors_enabled:
180
+ return False
181
+ if argv and argv[0] == "run":
182
+ return False
183
+ if "--json" in argv:
184
+ return False
185
+ return True
186
+
187
+
188
+ def cli_theme(setting: str, *, detect=detect_terminal_background, colors: bool) -> str:
189
+ """Resolve a theme for a plain-CLI invocation: probe only when color will
190
+ actually be emitted; otherwise auto degrades to dark without a tty query."""
191
+ if setting == "auto" and not colors:
192
+ return "dark"
193
+ return resolve_theme(setting, detect=detect)
194
+
195
+
196
+ def drain_stdin() -> None:
197
+ """Discard any pending terminal input (e.g. a late OSC reply) so it isn't
198
+ reissued as keystrokes once Textual takes over. Best-effort; POSIX only.
199
+
200
+ A reply that arrives after the detection deadline and after this drain
201
+ can, in principle, still reach the running app as stray keystrokes —
202
+ inherent to any finite-timeout OSC probe, not fully closeable here.
203
+ """
204
+ if os.name == "nt":
205
+ return
206
+ try:
207
+ import termios
208
+ except ImportError:
209
+ return
210
+ try:
211
+ if not sys.stdin.isatty():
212
+ return
213
+ except (ValueError, OSError):
214
+ return
215
+ try:
216
+ termios.tcflush(sys.stdin.fileno(), termios.TCIFLUSH)
217
+ except (termios.error, OSError):
218
+ pass