rlmness-cli 0.2.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 (203) hide show
  1. rlmagent/__init__.py +1 -0
  2. rlmagent/__main__.py +8 -0
  3. rlmagent/acp/__init__.py +1 -0
  4. rlmagent/acp/runtime.py +71 -0
  5. rlmagent/acp/server.py +598 -0
  6. rlmagent/acp/updates.py +128 -0
  7. rlmagent/agentnet.py +237 -0
  8. rlmagent/agents/__init__.py +1 -0
  9. rlmagent/agents/approval.py +90 -0
  10. rlmagent/agents/bridge.py +109 -0
  11. rlmagent/agents/budget.py +212 -0
  12. rlmagent/agents/child_handles.py +46 -0
  13. rlmagent/agents/child_mailbox.py +30 -0
  14. rlmagent/agents/kernel_api.py +156 -0
  15. rlmagent/agents/output.py +34 -0
  16. rlmagent/agents/patch.py +107 -0
  17. rlmagent/agents/prompts.py +156 -0
  18. rlmagent/agents/trace.py +290 -0
  19. rlmagent/agents/tree.py +682 -0
  20. rlmagent/agents/wasm_api.py +105 -0
  21. rlmagent/agents/workspace.py +357 -0
  22. rlmagent/autopilot.py +447 -0
  23. rlmagent/backend.py +946 -0
  24. rlmagent/backend_context.py +74 -0
  25. rlmagent/cli/__init__.py +5 -0
  26. rlmagent/cli/config_cmd.py +177 -0
  27. rlmagent/cli/health.py +156 -0
  28. rlmagent/cli/main.py +1194 -0
  29. rlmagent/cli/update.py +188 -0
  30. rlmagent/clipboard.py +63 -0
  31. rlmagent/config/__init__.py +37 -0
  32. rlmagent/config/config.py +297 -0
  33. rlmagent/config/loader.py +119 -0
  34. rlmagent/config/prompts.py +124 -0
  35. rlmagent/config/secure_input.py +213 -0
  36. rlmagent/config/store.py +275 -0
  37. rlmagent/context/__init__.py +47 -0
  38. rlmagent/context/budget.py +557 -0
  39. rlmagent/contract.py +311 -0
  40. rlmagent/contract_run.py +232 -0
  41. rlmagent/conversation.py +2547 -0
  42. rlmagent/cron.py +77 -0
  43. rlmagent/directives.py +893 -0
  44. rlmagent/discovery.py +356 -0
  45. rlmagent/execution_log.py +66 -0
  46. rlmagent/factory.py +189 -0
  47. rlmagent/factory_graph.py +413 -0
  48. rlmagent/harness/__init__.py +63 -0
  49. rlmagent/harness/contracts/__init__.py +1 -0
  50. rlmagent/harness/contracts/stream.py +129 -0
  51. rlmagent/harness/contracts/tooling.py +127 -0
  52. rlmagent/harness/contracts/transcript/__init__.py +68 -0
  53. rlmagent/harness/contracts/transcript/_base.py +29 -0
  54. rlmagent/harness/contracts/transcript/diagnostics.py +52 -0
  55. rlmagent/harness/contracts/transcript/entries.py +180 -0
  56. rlmagent/harness/contracts/transcript/segments.py +64 -0
  57. rlmagent/harness/contracts/values.py +6 -0
  58. rlmagent/harness/driver.py +397 -0
  59. rlmagent/harness/engine.py +489 -0
  60. rlmagent/harness/mending.py +153 -0
  61. rlmagent/harness/provider/__init__.py +1 -0
  62. rlmagent/harness/provider/base.py +27 -0
  63. rlmagent/harness/provider/wire.py +189 -0
  64. rlmagent/harness/py.typed +0 -0
  65. rlmagent/harness/session/__init__.py +5 -0
  66. rlmagent/harness/session/index.py +218 -0
  67. rlmagent/harness/session/lease.py +66 -0
  68. rlmagent/harness/session/records.py +118 -0
  69. rlmagent/harness/session/replay.py +165 -0
  70. rlmagent/harness/session/store.py +155 -0
  71. rlmagent/harness/session/summary.py +46 -0
  72. rlmagent/hints.py +47 -0
  73. rlmagent/history.py +92 -0
  74. rlmagent/hooks/__init__.py +1 -0
  75. rlmagent/hooks/hooks.py +1 -0
  76. rlmagent/instructions.py +157 -0
  77. rlmagent/job_runner.py +117 -0
  78. rlmagent/jobs.py +365 -0
  79. rlmagent/kept_agents.py +397 -0
  80. rlmagent/kernel/__init__.py +5 -0
  81. rlmagent/kernel/session.py +205 -0
  82. rlmagent/kernel/wasm.py +169 -0
  83. rlmagent/logs.py +208 -0
  84. rlmagent/managed_release.py +368 -0
  85. rlmagent/mcp.py +411 -0
  86. rlmagent/mcp_oauth.py +213 -0
  87. rlmagent/model/__init__.py +29 -0
  88. rlmagent/model/_claude/__init__.py +1 -0
  89. rlmagent/model/_claude/courier.py +117 -0
  90. rlmagent/model/_claude/emitter.py +378 -0
  91. rlmagent/model/_oai/__init__.py +1 -0
  92. rlmagent/model/_oai/helpers.py +256 -0
  93. rlmagent/model/_oai/normalize.py +263 -0
  94. rlmagent/model/_oai/parsers.py +317 -0
  95. rlmagent/model/_oai/payloads.py +226 -0
  96. rlmagent/model/_oai/transport.py +127 -0
  97. rlmagent/model/claude.py +552 -0
  98. rlmagent/model/copilot.py +142 -0
  99. rlmagent/model/correlation.py +21 -0
  100. rlmagent/model/deadline.py +19 -0
  101. rlmagent/model/discovery.py +181 -0
  102. rlmagent/model/external/__init__.py +11 -0
  103. rlmagent/model/external/acp.py +202 -0
  104. rlmagent/model/external/acp_install.py +109 -0
  105. rlmagent/model/external/cli.py +248 -0
  106. rlmagent/model/external/textcalls.py +57 -0
  107. rlmagent/model/failover.py +129 -0
  108. rlmagent/model/google.py +199 -0
  109. rlmagent/model/limits.py +46 -0
  110. rlmagent/model/oai_compatible.py +414 -0
  111. rlmagent/model/preferences.py +56 -0
  112. rlmagent/model/py.typed +0 -0
  113. rlmagent/model/registered_oauth.py +195 -0
  114. rlmagent/model/scripted.py +55 -0
  115. rlmagent/model/settings.py +271 -0
  116. rlmagent/model/subscription.py +398 -0
  117. rlmagent/model/token_bounds.py +15 -0
  118. rlmagent/model/transport/__init__.py +1 -0
  119. rlmagent/model/transport/backoff.py +87 -0
  120. rlmagent/model/transport/client.py +87 -0
  121. rlmagent/model/transport/faults.py +65 -0
  122. rlmagent/model/transport/responses_socket.py +223 -0
  123. rlmagent/model/vertex.py +93 -0
  124. rlmagent/network.py +123 -0
  125. rlmagent/packages.py +246 -0
  126. rlmagent/planning.py +84 -0
  127. rlmagent/playbook/__init__.py +1 -0
  128. rlmagent/playbook/apply.py +106 -0
  129. rlmagent/playbook/digest.py +63 -0
  130. rlmagent/playbook/improver.py +272 -0
  131. rlmagent/playbook/plan.py +99 -0
  132. rlmagent/playbook/store.py +67 -0
  133. rlmagent/plugins.py +327 -0
  134. rlmagent/policy.py +105 -0
  135. rlmagent/prompt_refs.py +57 -0
  136. rlmagent/prompts.py +331 -0
  137. rlmagent/prune.py +60 -0
  138. rlmagent/py.typed +0 -0
  139. rlmagent/reasoning.py +135 -0
  140. rlmagent/refinement/__init__.py +1 -0
  141. rlmagent/refinement/controller.py +448 -0
  142. rlmagent/refinement/engine.py +220 -0
  143. rlmagent/refinement/evidence.py +131 -0
  144. rlmagent/refinement/scoped.py +90 -0
  145. rlmagent/refinement/store.py +193 -0
  146. rlmagent/refresh.py +202 -0
  147. rlmagent/routing.py +52 -0
  148. rlmagent/runtime.py +575 -0
  149. rlmagent/safety.py +267 -0
  150. rlmagent/session_html.py +57 -0
  151. rlmagent/session_import.py +52 -0
  152. rlmagent/session_share.py +55 -0
  153. rlmagent/sessions.py +249 -0
  154. rlmagent/shell_sandbox.py +88 -0
  155. rlmagent/skillset.py +320 -0
  156. rlmagent/steering.py +148 -0
  157. rlmagent/stops.py +62 -0
  158. rlmagent/subscriptions.py +219 -0
  159. rlmagent/tasks.py +98 -0
  160. rlmagent/toolhost.py +310 -0
  161. rlmagent/tools/__init__.py +56 -0
  162. rlmagent/tools/_shared.py +210 -0
  163. rlmagent/tools/files.py +462 -0
  164. rlmagent/tools/kernel.py +93 -0
  165. rlmagent/tools/patch_tool.py +68 -0
  166. rlmagent/tools/search.py +188 -0
  167. rlmagent/trust.py +383 -0
  168. rlmagent/tui/__init__.py +1 -0
  169. rlmagent/tui/adapter.py +506 -0
  170. rlmagent/tui/app.py +711 -0
  171. rlmagent/tui/approval_screen.py +57 -0
  172. rlmagent/tui/choice_screen.py +47 -0
  173. rlmagent/tui/completions.py +116 -0
  174. rlmagent/tui/components.py +25 -0
  175. rlmagent/tui/composer.py +40 -0
  176. rlmagent/tui/factory_view.py +86 -0
  177. rlmagent/tui/inspect.py +193 -0
  178. rlmagent/tui/messages.py +215 -0
  179. rlmagent/tui/model_screen.py +307 -0
  180. rlmagent/tui/prompt.py +98 -0
  181. rlmagent/tui/question_screen.py +52 -0
  182. rlmagent/tui/replay.py +141 -0
  183. rlmagent/tui/rlm_agent.tcss +405 -0
  184. rlmagent/tui/search_box.py +91 -0
  185. rlmagent/tui/sidebar.py +182 -0
  186. rlmagent/tui/status.py +49 -0
  187. rlmagent/tui/steps.py +283 -0
  188. rlmagent/tui/suggest.py +778 -0
  189. rlmagent/tui/transcript.py +172 -0
  190. rlmagent/tui/trust_dialog.py +58 -0
  191. rlmagent/tui/worker_attach.py +141 -0
  192. rlmagent/ui/__init__.py +1 -0
  193. rlmagent/ui/render.py +211 -0
  194. rlmagent/undo.py +109 -0
  195. rlmagent/utils/__init__.py +1 -0
  196. rlmagent/websearch.py +75 -0
  197. rlmagent/worker.py +1275 -0
  198. rlmagent/worker_service.py +203 -0
  199. rlmness_cli-0.2.0.dist-info/METADATA +882 -0
  200. rlmness_cli-0.2.0.dist-info/RECORD +203 -0
  201. rlmness_cli-0.2.0.dist-info/WHEEL +4 -0
  202. rlmness_cli-0.2.0.dist-info/entry_points.txt +2 -0
  203. rlmness_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,119 @@
1
+ """Resolve provider, model, and credentials from environment then saved config."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import tomllib
7
+ from pathlib import Path
8
+
9
+ from rlmagent.config.store import RlmAgentConfig, load_config, provider_info
10
+
11
+ _DEFAULT_PROVIDER = "anthropic"
12
+
13
+ #: Per-project settings, inside the project. Only these keys are read: a
14
+ #: repository may pick its provider, model and fallbacks, never credentials,
15
+ #: endpoints or the trust policy.
16
+ PROJECT_SETTINGS = Path(".rlm-agent") / "settings.toml"
17
+ PROJECT_KEYS = ("provider", "model", "fallback")
18
+
19
+
20
+ def project_settings(cwd: str | os.PathLike[str] | None = None) -> dict[str, str]:
21
+ """The trusted project's own settings, or ``{}``."""
22
+ folder = Path(cwd or os.getcwd())
23
+ path = folder / PROJECT_SETTINGS
24
+ if not path.is_file():
25
+ return {}
26
+ from rlmagent.trust import project_settings_allowed
27
+
28
+ if not project_settings_allowed(folder):
29
+ return {}
30
+ try:
31
+ data = tomllib.loads(path.read_text(encoding="utf-8"))
32
+ except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError):
33
+ return {}
34
+ return {key: value.strip() for key in PROJECT_KEYS
35
+ if isinstance(value := data.get(key), str) and value.strip()}
36
+
37
+
38
+ def load_provider(config: RlmAgentConfig | None = None) -> str:
39
+ """Active provider: ``RLM_AGENT_PROVIDER``, then config, then detection."""
40
+ env = os.environ.get("RLM_AGENT_PROVIDER")
41
+ if env:
42
+ return env.strip().lower()
43
+
44
+ if config is None and (project := project_settings().get("provider")):
45
+ return project.lower()
46
+ cfg = load_config() if config is None else config
47
+ if cfg.provider:
48
+ return cfg.provider.strip().lower()
49
+
50
+ # No explicit choice — infer from whichever key is present.
51
+ if os.environ.get("ANTHROPIC_API_KEY"):
52
+ return "anthropic"
53
+ if os.environ.get("OPENAI_API_KEY"):
54
+ return "openai"
55
+ return _DEFAULT_PROVIDER
56
+
57
+
58
+ def load_model(provider: str, config: RlmAgentConfig | None = None) -> str | None:
59
+ """Model for *provider*: ``RLM_AGENT_MODEL``, then config, then ``None``."""
60
+ env = os.environ.get("RLM_AGENT_MODEL")
61
+ if env:
62
+ return env
63
+ if config is None:
64
+ project = project_settings()
65
+ if project.get("model") and project.get("provider", provider).lower() == provider:
66
+ return project["model"]
67
+ cfg = load_config() if config is None else config
68
+ if cfg.model and cfg.provider == provider:
69
+ return cfg.model
70
+ return None
71
+
72
+
73
+ def load_api_key(provider: str, config: RlmAgentConfig | None = None) -> str | None:
74
+ """Key for *provider*: its env var, then config, then ``None``."""
75
+ try:
76
+ info = provider_info(provider)
77
+ except KeyError:
78
+ return None
79
+ env = os.environ.get(info.key_env)
80
+ if env:
81
+ return env
82
+ cfg = load_config() if config is None else config
83
+ return cfg.api_keys.get(provider)
84
+
85
+
86
+ def apply_config(provider: str, config: RlmAgentConfig | None = None) -> None:
87
+ """Export saved credentials for *provider* into this process's environment."""
88
+ try:
89
+ info = provider_info(provider)
90
+ except KeyError:
91
+ return
92
+
93
+ if provider in ("vertex", "google", "mistral", "azure", "codex", "copilot", "claude-subscription", "xai", "xai-subscription"):
94
+ return
95
+ cfg = load_config() if config is None else config
96
+ key = cfg.api_keys.get(provider)
97
+ base_url = cfg.base_urls.get(provider) or info.base_url
98
+
99
+ if provider == "anthropic":
100
+ key_var, url_var = "ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL"
101
+ else:
102
+ key_var, url_var = "OPENAI_API_KEY", "OPENAI_BASE_URL"
103
+
104
+ # A real env var for the provider's own key name wins over the config file.
105
+ native = os.environ.get(info.key_env)
106
+ if native:
107
+ key = native
108
+
109
+ if key and not os.environ.get(key_var):
110
+ os.environ[key_var] = key
111
+ if not os.environ.get(url_var):
112
+ os.environ[url_var] = base_url
113
+
114
+ # Ollama needs no credential but the OpenAI loader requires a non-empty key.
115
+ if not info.needs_key and not os.environ.get(key_var):
116
+ os.environ[key_var] = "ollama"
117
+
118
+
119
+ __all__ = ["apply_config", "load_api_key", "load_model", "load_provider"]
@@ -0,0 +1,124 @@
1
+ """Interactive prompts for ``rlm-agent config``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from rlmagent.config.secure_input import read_secret
8
+
9
+
10
+ class PromptAborted(Exception):
11
+ """Raised when the user cancels the flow (EOF or Ctrl-C)."""
12
+
13
+
14
+ def _write(text: str) -> None:
15
+ sys.stderr.write(text)
16
+ sys.stderr.flush()
17
+
18
+
19
+ def _readline(prompt: str) -> str:
20
+ _write(prompt)
21
+ try:
22
+ line = sys.stdin.readline()
23
+ except (EOFError, KeyboardInterrupt):
24
+ raise PromptAborted from None
25
+ if not line:
26
+ raise PromptAborted
27
+ return line.strip()
28
+
29
+
30
+ def choose(title: str, options: list[str], *, default: int = 0) -> int:
31
+ """Show a numbered menu and return the chosen zero-based index."""
32
+ _write(f"\n{title}\n\n")
33
+ for i, option in enumerate(options):
34
+ marker = ">" if i == default else " "
35
+ _write(f" {marker} {i + 1}. {option}\n")
36
+
37
+ while True:
38
+ raw = _readline(f"\nSelect [1-{len(options)}] ({default + 1}): ")
39
+ if not raw:
40
+ return default
41
+ try:
42
+ index = int(raw) - 1
43
+ except ValueError:
44
+ _write("Enter a number.\n")
45
+ continue
46
+ if 0 <= index < len(options):
47
+ return index
48
+ _write(f"Enter a number between 1 and {len(options)}.\n")
49
+
50
+
51
+ def choose_model(models: list[str], *, current: str | None = None) -> str:
52
+ """Pick a model from *models*, or enter a custom identifier."""
53
+ options = [*models, "Other (enter manually)"]
54
+ default = models.index(current) if current in models else 0
55
+ index = choose("Default model", options, default=default)
56
+ if index < len(models):
57
+ return models[index]
58
+
59
+ while True:
60
+ value = _readline("\nModel identifier: ")
61
+ if value:
62
+ return value
63
+ _write("Model identifier must not be empty.\n")
64
+
65
+
66
+ def _read_secret(*, visible: bool = False) -> str:
67
+ """Read a line without echoing it, treating EOF/Ctrl+C as cancellation."""
68
+ try:
69
+ return read_secret(_write, visible=visible).strip()
70
+ except (EOFError, KeyboardInterrupt):
71
+ raise PromptAborted from None
72
+
73
+
74
+ def ask_api_key(
75
+ label: str, *, required: bool, has_existing: bool, visible: bool = False,
76
+ ) -> str | None:
77
+ """Read an API key without echoing it. Returns ``None`` to keep the existing one."""
78
+ suffix = " (press Enter to keep the saved key)" if has_existing else ""
79
+ while True:
80
+ _write(f"\nAPI Key{suffix}:\n")
81
+ if not visible:
82
+ # Hidden input shows nothing at all — say so, or a working prompt
83
+ # is indistinguishable from a frozen one.
84
+ _write(
85
+ " (input is hidden: type or paste, then press Enter.\n"
86
+ " Nothing will appear on screen. Ctrl+C cancels.)\n"
87
+ )
88
+ value = _read_secret(visible=visible)
89
+
90
+ if value:
91
+ return value
92
+ if has_existing:
93
+ return None
94
+ if not required:
95
+ return ""
96
+ _write(f"{label} requires an API key.\n")
97
+
98
+
99
+ def confirm(question: str, *, default: bool = True) -> bool:
100
+ """Ask a yes/no question."""
101
+ hint = "Y/n" if default else "y/N"
102
+ while True:
103
+ raw = _readline(f"\n{question} ({hint}) ").lower()
104
+ if not raw:
105
+ return default
106
+ if raw in {"y", "yes"}:
107
+ return True
108
+ if raw in {"n", "no"}:
109
+ return False
110
+
111
+
112
+ def info(text: str) -> None:
113
+ """Print a status line."""
114
+ _write(f"{text}\n")
115
+
116
+
117
+ __all__ = [
118
+ "PromptAborted",
119
+ "ask_api_key",
120
+ "choose",
121
+ "choose_model",
122
+ "confirm",
123
+ "info",
124
+ ]
@@ -0,0 +1,213 @@
1
+ """Hidden (non-echoing) line input that works across Windows and POSIX terminals."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from collections.abc import Callable
7
+
8
+ # Windows console mode flags (winbase.h)
9
+ _ENABLE_PROCESSED_INPUT = 0x0001
10
+ _ENABLE_LINE_INPUT = 0x0002
11
+ _ENABLE_ECHO_INPUT = 0x0004
12
+ _STD_INPUT_HANDLE = -10
13
+
14
+
15
+ class SecureInputUnavailable(Exception):
16
+ """Raised by a strategy that cannot apply on this terminal."""
17
+
18
+
19
+ def _read_line() -> str:
20
+ """Read one line from stdin, raising ``EOFError`` at end of input."""
21
+ line = sys.stdin.readline()
22
+ if not line:
23
+ raise EOFError
24
+ return line.rstrip("\r\n")
25
+
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # Strategy 1 — Windows console
29
+ # ---------------------------------------------------------------------------
30
+
31
+
32
+ def _read_windows(write: Callable[[str], None]) -> str:
33
+ """Read with echo disabled via the Windows console API."""
34
+ if sys.platform != "win32":
35
+ raise SecureInputUnavailable("not Windows")
36
+
37
+ import ctypes
38
+ from ctypes import wintypes
39
+
40
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
41
+ kernel32.GetStdHandle.restype = wintypes.HANDLE
42
+ kernel32.GetStdHandle.argtypes = [wintypes.DWORD]
43
+ kernel32.GetConsoleMode.argtypes = [
44
+ wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD),
45
+ ]
46
+ kernel32.SetConsoleMode.argtypes = [wintypes.HANDLE, wintypes.DWORD]
47
+
48
+ handle = kernel32.GetStdHandle(_STD_INPUT_HANDLE)
49
+ if not handle or handle == wintypes.HANDLE(-1).value:
50
+ raise SecureInputUnavailable("no stdin console handle")
51
+
52
+ original = wintypes.DWORD()
53
+ # Authoritative probe: this fails when stdin is a pipe or ConPTY stream,
54
+ # even though isatty() may still report True.
55
+ if not kernel32.GetConsoleMode(handle, ctypes.byref(original)):
56
+ raise SecureInputUnavailable("stdin is not a console")
57
+
58
+ # Keep LINE_INPUT (paste, backspace, arrow keys) and PROCESSED_INPUT
59
+ # (Ctrl+C -> SIGINT). Only echo goes away.
60
+ quiet = (
61
+ original.value | _ENABLE_LINE_INPUT | _ENABLE_PROCESSED_INPUT
62
+ ) & ~_ENABLE_ECHO_INPUT
63
+
64
+ if not kernel32.SetConsoleMode(handle, quiet):
65
+ raise SecureInputUnavailable("could not disable console echo")
66
+ try:
67
+ value = _read_line()
68
+ finally:
69
+ kernel32.SetConsoleMode(handle, original.value)
70
+ write("\n") # the user's Enter was not echoed
71
+ return value
72
+
73
+
74
+ # ---------------------------------------------------------------------------
75
+ # Strategy 2 — POSIX terminal
76
+ # ---------------------------------------------------------------------------
77
+
78
+
79
+ def _read_posix(write: Callable[[str], None]) -> str:
80
+ """Read with echo disabled via termios."""
81
+ if sys.platform == "win32":
82
+ raise SecureInputUnavailable("Windows uses the console strategy")
83
+ try:
84
+ import termios
85
+ except ImportError as exc:
86
+ raise SecureInputUnavailable("termios unavailable") from exc
87
+
88
+ try:
89
+ fd = sys.stdin.fileno()
90
+ original = termios.tcgetattr(fd)
91
+ except (OSError, ValueError, termios.error) as exc:
92
+ raise SecureInputUnavailable("stdin is not a terminal") from exc
93
+
94
+ quiet = list(original)
95
+ quiet[3] &= ~termios.ECHO # lflags
96
+ try:
97
+ termios.tcsetattr(fd, termios.TCSADRAIN, quiet)
98
+ value = _read_line()
99
+ finally:
100
+ termios.tcsetattr(fd, termios.TCSADRAIN, original)
101
+ write("\n")
102
+ return value
103
+
104
+
105
+ # ---------------------------------------------------------------------------
106
+ # Strategy 3 — non-interactive stdin
107
+ # ---------------------------------------------------------------------------
108
+
109
+
110
+ def _read_piped(write: Callable[[str], None]) -> str:
111
+ """Read plainly when stdin is not a terminal (piped or scripted input)."""
112
+ if sys.stdin.isatty():
113
+ raise SecureInputUnavailable("stdin is interactive")
114
+ return _read_line()
115
+
116
+
117
+ # ---------------------------------------------------------------------------
118
+ # Strategy 4 — visible fallback
119
+ # ---------------------------------------------------------------------------
120
+
121
+
122
+ def _read_visible(write: Callable[[str], None]) -> str:
123
+ """Last resort: read with echo on, after warning the user."""
124
+ write(
125
+ "\nWarning: this terminal does not support hidden input.\n"
126
+ "The value you type will be VISIBLE on screen.\n"
127
+ )
128
+ return _read_line()
129
+
130
+
131
+ _STRATEGIES: tuple[Callable[[Callable[[str], None]], str], ...] = (
132
+ _read_piped,
133
+ _read_windows,
134
+ _read_posix,
135
+ _read_visible,
136
+ )
137
+
138
+ _STRATEGY_NAMES = {
139
+ "_read_piped": "piped stdin (not a terminal)",
140
+ "_read_windows": "Windows console, echo disabled",
141
+ "_read_posix": "POSIX terminal, echo disabled",
142
+ "_read_visible": "visible input (no hidden-input support)",
143
+ }
144
+
145
+
146
+ def read_secret(
147
+ write: Callable[[str], None] | None = None, *, visible: bool = False,
148
+ ) -> str:
149
+ """Read one line without echoing it, falling back until a strategy works."""
150
+ emit = write if write is not None else _default_write
151
+ if visible:
152
+ return _read_line()
153
+ for strategy in _STRATEGIES:
154
+ try:
155
+ return strategy(emit)
156
+ except SecureInputUnavailable:
157
+ continue
158
+ raise EOFError # unreachable: _read_visible never signals unavailable
159
+
160
+
161
+ def describe_terminal() -> list[str]:
162
+ """Report how this terminal handles hidden input (diagnostics only)."""
163
+ lines = [
164
+ f"platform : {sys.platform}",
165
+ f"stdin.isatty(): {sys.stdin.isatty()}",
166
+ ]
167
+ if sys.platform == "win32":
168
+ lines.append(f"console mode : {_probe_windows_console()}")
169
+ lines.append(f"selected : {_select_strategy_name()}")
170
+ return lines
171
+
172
+
173
+ def _probe_windows_console() -> str:
174
+ """Describe whether stdin is a real Windows console."""
175
+ import ctypes
176
+ from ctypes import wintypes
177
+
178
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
179
+ kernel32.GetStdHandle.restype = wintypes.HANDLE
180
+ kernel32.GetStdHandle.argtypes = [wintypes.DWORD]
181
+ kernel32.GetConsoleMode.argtypes = [
182
+ wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD),
183
+ ]
184
+ handle = kernel32.GetStdHandle(_STD_INPUT_HANDLE)
185
+ mode = wintypes.DWORD()
186
+ if kernel32.GetConsoleMode(handle, ctypes.byref(mode)):
187
+ return f"available (mode={hex(mode.value)})"
188
+ return f"UNAVAILABLE (error {ctypes.get_last_error()}) - hidden input not possible"
189
+
190
+
191
+ def _select_strategy_name() -> str:
192
+ """Name the strategy that would be used, without reading input."""
193
+ if not sys.stdin.isatty():
194
+ return _STRATEGY_NAMES["_read_piped"]
195
+ if sys.platform == "win32":
196
+ if "available (" in _probe_windows_console():
197
+ return _STRATEGY_NAMES["_read_windows"]
198
+ return _STRATEGY_NAMES["_read_visible"]
199
+ try:
200
+ import termios
201
+
202
+ termios.tcgetattr(sys.stdin.fileno())
203
+ except Exception:
204
+ return _STRATEGY_NAMES["_read_visible"]
205
+ return _STRATEGY_NAMES["_read_posix"]
206
+
207
+
208
+ def _default_write(text: str) -> None:
209
+ sys.stderr.write(text)
210
+ sys.stderr.flush()
211
+
212
+
213
+ __all__ = ["SecureInputUnavailable", "describe_terminal", "read_secret"]