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.
- rlmagent/__init__.py +1 -0
- rlmagent/__main__.py +8 -0
- rlmagent/acp/__init__.py +1 -0
- rlmagent/acp/runtime.py +71 -0
- rlmagent/acp/server.py +598 -0
- rlmagent/acp/updates.py +128 -0
- rlmagent/agentnet.py +237 -0
- rlmagent/agents/__init__.py +1 -0
- rlmagent/agents/approval.py +90 -0
- rlmagent/agents/bridge.py +109 -0
- rlmagent/agents/budget.py +212 -0
- rlmagent/agents/child_handles.py +46 -0
- rlmagent/agents/child_mailbox.py +30 -0
- rlmagent/agents/kernel_api.py +156 -0
- rlmagent/agents/output.py +34 -0
- rlmagent/agents/patch.py +107 -0
- rlmagent/agents/prompts.py +156 -0
- rlmagent/agents/trace.py +290 -0
- rlmagent/agents/tree.py +682 -0
- rlmagent/agents/wasm_api.py +105 -0
- rlmagent/agents/workspace.py +357 -0
- rlmagent/autopilot.py +447 -0
- rlmagent/backend.py +946 -0
- rlmagent/backend_context.py +74 -0
- rlmagent/cli/__init__.py +5 -0
- rlmagent/cli/config_cmd.py +177 -0
- rlmagent/cli/health.py +156 -0
- rlmagent/cli/main.py +1194 -0
- rlmagent/cli/update.py +188 -0
- rlmagent/clipboard.py +63 -0
- rlmagent/config/__init__.py +37 -0
- rlmagent/config/config.py +297 -0
- rlmagent/config/loader.py +119 -0
- rlmagent/config/prompts.py +124 -0
- rlmagent/config/secure_input.py +213 -0
- rlmagent/config/store.py +275 -0
- rlmagent/context/__init__.py +47 -0
- rlmagent/context/budget.py +557 -0
- rlmagent/contract.py +311 -0
- rlmagent/contract_run.py +232 -0
- rlmagent/conversation.py +2547 -0
- rlmagent/cron.py +77 -0
- rlmagent/directives.py +893 -0
- rlmagent/discovery.py +356 -0
- rlmagent/execution_log.py +66 -0
- rlmagent/factory.py +189 -0
- rlmagent/factory_graph.py +413 -0
- rlmagent/harness/__init__.py +63 -0
- rlmagent/harness/contracts/__init__.py +1 -0
- rlmagent/harness/contracts/stream.py +129 -0
- rlmagent/harness/contracts/tooling.py +127 -0
- rlmagent/harness/contracts/transcript/__init__.py +68 -0
- rlmagent/harness/contracts/transcript/_base.py +29 -0
- rlmagent/harness/contracts/transcript/diagnostics.py +52 -0
- rlmagent/harness/contracts/transcript/entries.py +180 -0
- rlmagent/harness/contracts/transcript/segments.py +64 -0
- rlmagent/harness/contracts/values.py +6 -0
- rlmagent/harness/driver.py +397 -0
- rlmagent/harness/engine.py +489 -0
- rlmagent/harness/mending.py +153 -0
- rlmagent/harness/provider/__init__.py +1 -0
- rlmagent/harness/provider/base.py +27 -0
- rlmagent/harness/provider/wire.py +189 -0
- rlmagent/harness/py.typed +0 -0
- rlmagent/harness/session/__init__.py +5 -0
- rlmagent/harness/session/index.py +218 -0
- rlmagent/harness/session/lease.py +66 -0
- rlmagent/harness/session/records.py +118 -0
- rlmagent/harness/session/replay.py +165 -0
- rlmagent/harness/session/store.py +155 -0
- rlmagent/harness/session/summary.py +46 -0
- rlmagent/hints.py +47 -0
- rlmagent/history.py +92 -0
- rlmagent/hooks/__init__.py +1 -0
- rlmagent/hooks/hooks.py +1 -0
- rlmagent/instructions.py +157 -0
- rlmagent/job_runner.py +117 -0
- rlmagent/jobs.py +365 -0
- rlmagent/kept_agents.py +397 -0
- rlmagent/kernel/__init__.py +5 -0
- rlmagent/kernel/session.py +205 -0
- rlmagent/kernel/wasm.py +169 -0
- rlmagent/logs.py +208 -0
- rlmagent/managed_release.py +368 -0
- rlmagent/mcp.py +411 -0
- rlmagent/mcp_oauth.py +213 -0
- rlmagent/model/__init__.py +29 -0
- rlmagent/model/_claude/__init__.py +1 -0
- rlmagent/model/_claude/courier.py +117 -0
- rlmagent/model/_claude/emitter.py +378 -0
- rlmagent/model/_oai/__init__.py +1 -0
- rlmagent/model/_oai/helpers.py +256 -0
- rlmagent/model/_oai/normalize.py +263 -0
- rlmagent/model/_oai/parsers.py +317 -0
- rlmagent/model/_oai/payloads.py +226 -0
- rlmagent/model/_oai/transport.py +127 -0
- rlmagent/model/claude.py +552 -0
- rlmagent/model/copilot.py +142 -0
- rlmagent/model/correlation.py +21 -0
- rlmagent/model/deadline.py +19 -0
- rlmagent/model/discovery.py +181 -0
- rlmagent/model/external/__init__.py +11 -0
- rlmagent/model/external/acp.py +202 -0
- rlmagent/model/external/acp_install.py +109 -0
- rlmagent/model/external/cli.py +248 -0
- rlmagent/model/external/textcalls.py +57 -0
- rlmagent/model/failover.py +129 -0
- rlmagent/model/google.py +199 -0
- rlmagent/model/limits.py +46 -0
- rlmagent/model/oai_compatible.py +414 -0
- rlmagent/model/preferences.py +56 -0
- rlmagent/model/py.typed +0 -0
- rlmagent/model/registered_oauth.py +195 -0
- rlmagent/model/scripted.py +55 -0
- rlmagent/model/settings.py +271 -0
- rlmagent/model/subscription.py +398 -0
- rlmagent/model/token_bounds.py +15 -0
- rlmagent/model/transport/__init__.py +1 -0
- rlmagent/model/transport/backoff.py +87 -0
- rlmagent/model/transport/client.py +87 -0
- rlmagent/model/transport/faults.py +65 -0
- rlmagent/model/transport/responses_socket.py +223 -0
- rlmagent/model/vertex.py +93 -0
- rlmagent/network.py +123 -0
- rlmagent/packages.py +246 -0
- rlmagent/planning.py +84 -0
- rlmagent/playbook/__init__.py +1 -0
- rlmagent/playbook/apply.py +106 -0
- rlmagent/playbook/digest.py +63 -0
- rlmagent/playbook/improver.py +272 -0
- rlmagent/playbook/plan.py +99 -0
- rlmagent/playbook/store.py +67 -0
- rlmagent/plugins.py +327 -0
- rlmagent/policy.py +105 -0
- rlmagent/prompt_refs.py +57 -0
- rlmagent/prompts.py +331 -0
- rlmagent/prune.py +60 -0
- rlmagent/py.typed +0 -0
- rlmagent/reasoning.py +135 -0
- rlmagent/refinement/__init__.py +1 -0
- rlmagent/refinement/controller.py +448 -0
- rlmagent/refinement/engine.py +220 -0
- rlmagent/refinement/evidence.py +131 -0
- rlmagent/refinement/scoped.py +90 -0
- rlmagent/refinement/store.py +193 -0
- rlmagent/refresh.py +202 -0
- rlmagent/routing.py +52 -0
- rlmagent/runtime.py +575 -0
- rlmagent/safety.py +267 -0
- rlmagent/session_html.py +57 -0
- rlmagent/session_import.py +52 -0
- rlmagent/session_share.py +55 -0
- rlmagent/sessions.py +249 -0
- rlmagent/shell_sandbox.py +88 -0
- rlmagent/skillset.py +320 -0
- rlmagent/steering.py +148 -0
- rlmagent/stops.py +62 -0
- rlmagent/subscriptions.py +219 -0
- rlmagent/tasks.py +98 -0
- rlmagent/toolhost.py +310 -0
- rlmagent/tools/__init__.py +56 -0
- rlmagent/tools/_shared.py +210 -0
- rlmagent/tools/files.py +462 -0
- rlmagent/tools/kernel.py +93 -0
- rlmagent/tools/patch_tool.py +68 -0
- rlmagent/tools/search.py +188 -0
- rlmagent/trust.py +383 -0
- rlmagent/tui/__init__.py +1 -0
- rlmagent/tui/adapter.py +506 -0
- rlmagent/tui/app.py +711 -0
- rlmagent/tui/approval_screen.py +57 -0
- rlmagent/tui/choice_screen.py +47 -0
- rlmagent/tui/completions.py +116 -0
- rlmagent/tui/components.py +25 -0
- rlmagent/tui/composer.py +40 -0
- rlmagent/tui/factory_view.py +86 -0
- rlmagent/tui/inspect.py +193 -0
- rlmagent/tui/messages.py +215 -0
- rlmagent/tui/model_screen.py +307 -0
- rlmagent/tui/prompt.py +98 -0
- rlmagent/tui/question_screen.py +52 -0
- rlmagent/tui/replay.py +141 -0
- rlmagent/tui/rlm_agent.tcss +405 -0
- rlmagent/tui/search_box.py +91 -0
- rlmagent/tui/sidebar.py +182 -0
- rlmagent/tui/status.py +49 -0
- rlmagent/tui/steps.py +283 -0
- rlmagent/tui/suggest.py +778 -0
- rlmagent/tui/transcript.py +172 -0
- rlmagent/tui/trust_dialog.py +58 -0
- rlmagent/tui/worker_attach.py +141 -0
- rlmagent/ui/__init__.py +1 -0
- rlmagent/ui/render.py +211 -0
- rlmagent/undo.py +109 -0
- rlmagent/utils/__init__.py +1 -0
- rlmagent/websearch.py +75 -0
- rlmagent/worker.py +1275 -0
- rlmagent/worker_service.py +203 -0
- rlmness_cli-0.2.0.dist-info/METADATA +882 -0
- rlmness_cli-0.2.0.dist-info/RECORD +203 -0
- rlmness_cli-0.2.0.dist-info/WHEEL +4 -0
- rlmness_cli-0.2.0.dist-info/entry_points.txt +2 -0
- 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"]
|