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.
- aswap-0.1.0/.gitignore +1 -0
- aswap-0.1.0/LICENSE +22 -0
- aswap-0.1.0/PKG-INFO +44 -0
- aswap-0.1.0/README.md +12 -0
- aswap-0.1.0/pyproject.toml +72 -0
- aswap-0.1.0/src/aswap/__init__.py +8 -0
- aswap-0.1.0/src/aswap/cli.py +131 -0
- aswap-0.1.0/src/claude_swap/__init__.py +9 -0
- aswap-0.1.0/src/claude_swap/__main__.py +6 -0
- aswap-0.1.0/src/claude_swap/appearance.py +218 -0
- aswap-0.1.0/src/claude_swap/autoswitch.py +2364 -0
- aswap-0.1.0/src/claude_swap/cache.py +44 -0
- aswap-0.1.0/src/claude_swap/claude_locks.py +187 -0
- aswap-0.1.0/src/claude_swap/cli.py +1494 -0
- aswap-0.1.0/src/claude_swap/credentials.py +1849 -0
- aswap-0.1.0/src/claude_swap/distribution.py +42 -0
- aswap-0.1.0/src/claude_swap/exceptions.py +96 -0
- aswap-0.1.0/src/claude_swap/fsutil.py +101 -0
- aswap-0.1.0/src/claude_swap/json_output.py +288 -0
- aswap-0.1.0/src/claude_swap/launch_agent.py +289 -0
- aswap-0.1.0/src/claude_swap/locking.py +83 -0
- aswap-0.1.0/src/claude_swap/logging_config.py +64 -0
- aswap-0.1.0/src/claude_swap/macos_keychain.py +226 -0
- aswap-0.1.0/src/claude_swap/mappings.py +142 -0
- aswap-0.1.0/src/claude_swap/menubar.py +1056 -0
- aswap-0.1.0/src/claude_swap/migrations.py +537 -0
- aswap-0.1.0/src/claude_swap/models.py +211 -0
- aswap-0.1.0/src/claude_swap/oauth.py +806 -0
- aswap-0.1.0/src/claude_swap/pace.py +161 -0
- aswap-0.1.0/src/claude_swap/paths.py +221 -0
- aswap-0.1.0/src/claude_swap/poll_policy.py +270 -0
- aswap-0.1.0/src/claude_swap/printer.py +232 -0
- aswap-0.1.0/src/claude_swap/process_detection.py +350 -0
- aswap-0.1.0/src/claude_swap/session.py +1500 -0
- aswap-0.1.0/src/claude_swap/settings.py +488 -0
- aswap-0.1.0/src/claude_swap/snapshot_source.py +100 -0
- aswap-0.1.0/src/claude_swap/switcher.py +7678 -0
- aswap-0.1.0/src/claude_swap/transfer.py +645 -0
- aswap-0.1.0/src/claude_swap/tui/__init__.py +43 -0
- aswap-0.1.0/src/claude_swap/tui/app.py +433 -0
- aswap-0.1.0/src/claude_swap/tui/autoview.py +331 -0
- aswap-0.1.0/src/claude_swap/tui/cswap.tcss +205 -0
- aswap-0.1.0/src/claude_swap/tui/dashboard.py +432 -0
- aswap-0.1.0/src/claude_swap/tui/data.py +194 -0
- aswap-0.1.0/src/claude_swap/tui/modals.py +159 -0
- aswap-0.1.0/src/claude_swap/tui/theme.py +141 -0
- aswap-0.1.0/src/claude_swap/tui/widgets.py +403 -0
- aswap-0.1.0/src/claude_swap/update_check.py +192 -0
- 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,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
|