iac-code 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- iac_code/__init__.py +2 -0
- iac_code/acp/__init__.py +97 -0
- iac_code/acp/convert.py +423 -0
- iac_code/acp/http_sse.py +448 -0
- iac_code/acp/mcp.py +54 -0
- iac_code/acp/metrics.py +71 -0
- iac_code/acp/server.py +662 -0
- iac_code/acp/session.py +446 -0
- iac_code/acp/slash_registry.py +125 -0
- iac_code/acp/state.py +99 -0
- iac_code/acp/tools.py +112 -0
- iac_code/acp/types.py +13 -0
- iac_code/acp/version.py +26 -0
- iac_code/agent/__init__.py +19 -0
- iac_code/agent/agent_loop.py +640 -0
- iac_code/agent/agent_tool.py +269 -0
- iac_code/agent/agent_types.py +87 -0
- iac_code/agent/message.py +153 -0
- iac_code/agent/system_prompt.py +313 -0
- iac_code/cli/__init__.py +3 -0
- iac_code/cli/headless.py +114 -0
- iac_code/cli/main.py +246 -0
- iac_code/cli/output_formats.py +125 -0
- iac_code/commands/__init__.py +93 -0
- iac_code/commands/auth.py +1055 -0
- iac_code/commands/clear.py +34 -0
- iac_code/commands/compact.py +43 -0
- iac_code/commands/debug.py +45 -0
- iac_code/commands/effort.py +116 -0
- iac_code/commands/exit.py +10 -0
- iac_code/commands/help.py +49 -0
- iac_code/commands/model.py +130 -0
- iac_code/commands/registry.py +245 -0
- iac_code/commands/resume.py +49 -0
- iac_code/commands/tasks.py +41 -0
- iac_code/config.py +304 -0
- iac_code/i18n/__init__.py +141 -0
- iac_code/i18n/locales/zh/LC_MESSAGES/messages.po +1355 -0
- iac_code/memory/__init__.py +1 -0
- iac_code/memory/memory_manager.py +92 -0
- iac_code/memory/memory_tools.py +88 -0
- iac_code/providers/__init__.py +1 -0
- iac_code/providers/anthropic_provider.py +284 -0
- iac_code/providers/base.py +128 -0
- iac_code/providers/dashscope_provider.py +47 -0
- iac_code/providers/deepseek_provider.py +36 -0
- iac_code/providers/manager.py +399 -0
- iac_code/providers/openai_provider.py +344 -0
- iac_code/providers/retry.py +58 -0
- iac_code/providers/stream_watchdog.py +47 -0
- iac_code/providers/thinking.py +164 -0
- iac_code/services/__init__.py +1 -0
- iac_code/services/agent_factory.py +127 -0
- iac_code/services/cloud_credentials.py +22 -0
- iac_code/services/context_manager.py +221 -0
- iac_code/services/providers/__init__.py +1 -0
- iac_code/services/providers/aliyun.py +232 -0
- iac_code/services/session_index.py +281 -0
- iac_code/services/session_storage.py +245 -0
- iac_code/services/telemetry/__init__.py +66 -0
- iac_code/services/telemetry/attributes.py +84 -0
- iac_code/services/telemetry/client.py +330 -0
- iac_code/services/telemetry/config.py +76 -0
- iac_code/services/telemetry/constants.py +75 -0
- iac_code/services/telemetry/content_serializer.py +124 -0
- iac_code/services/telemetry/events.py +42 -0
- iac_code/services/telemetry/fallback.py +59 -0
- iac_code/services/telemetry/identity.py +73 -0
- iac_code/services/telemetry/metrics.py +62 -0
- iac_code/services/telemetry/names.py +199 -0
- iac_code/services/telemetry/sanitize.py +88 -0
- iac_code/services/telemetry/sink.py +67 -0
- iac_code/services/telemetry/tracing.py +38 -0
- iac_code/services/telemetry/types.py +13 -0
- iac_code/services/token_budget.py +54 -0
- iac_code/services/token_counter.py +76 -0
- iac_code/skills/__init__.py +1 -0
- iac_code/skills/bundled/__init__.py +94 -0
- iac_code/skills/bundled/iac_aliyun/SKILL.md +192 -0
- iac_code/skills/bundled/iac_aliyun/__init__.py +16 -0
- iac_code/skills/bundled/iac_aliyun/references/cloud-products/ecs.md +167 -0
- iac_code/skills/bundled/iac_aliyun/references/cloud-products/oss.md +69 -0
- iac_code/skills/bundled/iac_aliyun/references/cloud-products/rds.md +95 -0
- iac_code/skills/bundled/iac_aliyun/references/cloud-products/redis.md +100 -0
- iac_code/skills/bundled/iac_aliyun/references/cloud-products/slb.md +60 -0
- iac_code/skills/bundled/iac_aliyun/references/cloud-products/vpc.md +54 -0
- iac_code/skills/bundled/iac_aliyun/references/ros-template.md +155 -0
- iac_code/skills/bundled/iac_aliyun/references/template-parameters.md +206 -0
- iac_code/skills/bundled/iac_aliyun/references/terraform-template.md +101 -0
- iac_code/skills/bundled/iac_aliyun/scripts/tf2ros.py +77 -0
- iac_code/skills/bundled/simplify.py +28 -0
- iac_code/skills/discovery.py +136 -0
- iac_code/skills/frontmatter.py +119 -0
- iac_code/skills/listing.py +92 -0
- iac_code/skills/loader.py +42 -0
- iac_code/skills/processor.py +81 -0
- iac_code/skills/renderer.py +157 -0
- iac_code/skills/skill_definition.py +82 -0
- iac_code/skills/skill_tool.py +261 -0
- iac_code/state/__init__.py +5 -0
- iac_code/state/app_state.py +122 -0
- iac_code/tasks/__init__.py +1 -0
- iac_code/tasks/notification_queue.py +28 -0
- iac_code/tasks/task_state.py +66 -0
- iac_code/tasks/task_tools.py +114 -0
- iac_code/tools/__init__.py +8 -0
- iac_code/tools/base.py +226 -0
- iac_code/tools/bash.py +133 -0
- iac_code/tools/cloud/__init__.py +0 -0
- iac_code/tools/cloud/aliyun/__init__.py +0 -0
- iac_code/tools/cloud/aliyun/aliyun_api.py +510 -0
- iac_code/tools/cloud/aliyun/aliyun_doc_search.py +145 -0
- iac_code/tools/cloud/aliyun/endpoints.yml +343 -0
- iac_code/tools/cloud/aliyun/ros_client.py +56 -0
- iac_code/tools/cloud/aliyun/ros_stack.py +633 -0
- iac_code/tools/cloud/aliyun/ros_stack_instances.py +247 -0
- iac_code/tools/cloud/base_api.py +162 -0
- iac_code/tools/cloud/base_stack.py +242 -0
- iac_code/tools/cloud/registry.py +20 -0
- iac_code/tools/cloud/types.py +105 -0
- iac_code/tools/edit_file.py +121 -0
- iac_code/tools/glob.py +103 -0
- iac_code/tools/grep.py +254 -0
- iac_code/tools/list_files.py +104 -0
- iac_code/tools/read_file.py +127 -0
- iac_code/tools/result_storage.py +39 -0
- iac_code/tools/tool_executor.py +165 -0
- iac_code/tools/web_fetch.py +177 -0
- iac_code/tools/write_file.py +88 -0
- iac_code/types/__init__.py +40 -0
- iac_code/types/permissions.py +26 -0
- iac_code/types/skill_source.py +11 -0
- iac_code/types/stream_events.py +227 -0
- iac_code/ui/__init__.py +5 -0
- iac_code/ui/banner.py +110 -0
- iac_code/ui/components/__init__.py +0 -0
- iac_code/ui/components/dialog.py +142 -0
- iac_code/ui/components/divider.py +20 -0
- iac_code/ui/components/fuzzy_picker.py +308 -0
- iac_code/ui/components/progress_bar.py +54 -0
- iac_code/ui/components/search_box.py +165 -0
- iac_code/ui/components/select.py +319 -0
- iac_code/ui/components/status_icon.py +42 -0
- iac_code/ui/components/tabs.py +128 -0
- iac_code/ui/core/__init__.py +0 -0
- iac_code/ui/core/in_place_render.py +129 -0
- iac_code/ui/core/input_history.py +118 -0
- iac_code/ui/core/key_event.py +41 -0
- iac_code/ui/core/prompt_input.py +507 -0
- iac_code/ui/core/raw_input.py +302 -0
- iac_code/ui/core/screen.py +80 -0
- iac_code/ui/dialogs/__init__.py +0 -0
- iac_code/ui/dialogs/global_search.py +178 -0
- iac_code/ui/dialogs/history_search.py +100 -0
- iac_code/ui/dialogs/model_picker.py +280 -0
- iac_code/ui/dialogs/quick_open.py +108 -0
- iac_code/ui/dialogs/resume_picker.py +749 -0
- iac_code/ui/keybindings/__init__.py +0 -0
- iac_code/ui/keybindings/manager.py +124 -0
- iac_code/ui/renderer.py +1535 -0
- iac_code/ui/repl.py +772 -0
- iac_code/ui/spinner.py +112 -0
- iac_code/ui/suggestions/__init__.py +0 -0
- iac_code/ui/suggestions/aggregator.py +171 -0
- iac_code/ui/suggestions/command_provider.py +43 -0
- iac_code/ui/suggestions/directory_provider.py +95 -0
- iac_code/ui/suggestions/file_provider.py +121 -0
- iac_code/ui/suggestions/shell_history_provider.py +108 -0
- iac_code/ui/suggestions/token_extractor.py +77 -0
- iac_code/ui/suggestions/types.py +45 -0
- iac_code/ui/transcript_view.py +199 -0
- iac_code/utils/__init__.py +0 -0
- iac_code/utils/background_housekeeping.py +53 -0
- iac_code/utils/cleanup.py +68 -0
- iac_code/utils/json_utils.py +60 -0
- iac_code/utils/log.py +150 -0
- iac_code/utils/project_paths.py +74 -0
- iac_code/utils/tool_input_parser.py +62 -0
- iac_code-0.1.0.dist-info/LICENSE +201 -0
- iac_code-0.1.0.dist-info/METADATA +64 -0
- iac_code-0.1.0.dist-info/RECORD +184 -0
- iac_code-0.1.0.dist-info/WHEEL +5 -0
- iac_code-0.1.0.dist-info/entry_points.txt +2 -0
- iac_code-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1055 @@
|
|
|
1
|
+
"""Authentication command — interactive provider/key/model setup."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import unicodedata
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from typing import TYPE_CHECKING, TypedDict
|
|
10
|
+
from urllib.parse import urlparse
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from iac_code.services.providers.aliyun import AliyunCredential
|
|
14
|
+
|
|
15
|
+
from iac_code.config import (
|
|
16
|
+
_LEGACY_KEY_NAME_ALIASES,
|
|
17
|
+
_load_yaml,
|
|
18
|
+
_save_yaml,
|
|
19
|
+
get_active_provider_key,
|
|
20
|
+
get_credentials_path,
|
|
21
|
+
get_provider_config,
|
|
22
|
+
get_settings_path,
|
|
23
|
+
)
|
|
24
|
+
from iac_code.i18n import _
|
|
25
|
+
from iac_code.services.telemetry import log_event
|
|
26
|
+
from iac_code.services.telemetry.names import Events
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _display_width(s: str) -> int:
|
|
30
|
+
"""Terminal display width (CJK chars = 2 columns)."""
|
|
31
|
+
w = 0
|
|
32
|
+
for ch in s:
|
|
33
|
+
eaw = unicodedata.east_asian_width(ch)
|
|
34
|
+
w += 2 if eaw in ("W", "F") else 1
|
|
35
|
+
return w
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
if TYPE_CHECKING:
|
|
39
|
+
from iac_code.ui.repl import CommandContext
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class _BackSentinel:
|
|
43
|
+
"""Sentinel used by full-screen flows to request one-step navigation back."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class LLMProvider(TypedDict):
|
|
47
|
+
name: str
|
|
48
|
+
display_name: str
|
|
49
|
+
key_name: str
|
|
50
|
+
api_base: str | None
|
|
51
|
+
models: list[str]
|
|
52
|
+
default_model: str
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _classify_base_url(url: str | None) -> str:
|
|
56
|
+
"""Classify base URL host to one of: 'aliyun', 'openai_compat', 'deepseek', 'other', or ''."""
|
|
57
|
+
if not url:
|
|
58
|
+
return ""
|
|
59
|
+
host = (urlparse(url).hostname or "").lower()
|
|
60
|
+
if "aliyun" in host or "dashscope" in host:
|
|
61
|
+
return "aliyun"
|
|
62
|
+
if "deepseek" in host:
|
|
63
|
+
return "deepseek"
|
|
64
|
+
if "openai" in host:
|
|
65
|
+
return "openai_compat"
|
|
66
|
+
return "other"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# Provider definitions
|
|
70
|
+
PROVIDERS: list[LLMProvider] = [
|
|
71
|
+
{
|
|
72
|
+
"name": "DashScope",
|
|
73
|
+
"display_name": "阿里云百炼",
|
|
74
|
+
"key_name": "dashscope",
|
|
75
|
+
"api_base": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
|
76
|
+
"models": [
|
|
77
|
+
"qwen3.6-max-preview",
|
|
78
|
+
"qwen3.6-plus",
|
|
79
|
+
"qwen3.5-plus",
|
|
80
|
+
"qwen3.5-flash",
|
|
81
|
+
"qwq-plus",
|
|
82
|
+
"kimi-k2.6",
|
|
83
|
+
"deepseek-v4-pro",
|
|
84
|
+
"deepseek-v4-flash",
|
|
85
|
+
"glm-5.1",
|
|
86
|
+
],
|
|
87
|
+
"default_model": "qwen3.6-plus",
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
"name": "DashScope Token Plan",
|
|
91
|
+
"display_name": "阿里云百炼 Token Plan",
|
|
92
|
+
"key_name": "dashscope_token_plan",
|
|
93
|
+
"api_base": "https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1",
|
|
94
|
+
"models": [
|
|
95
|
+
"qwen3.6-plus",
|
|
96
|
+
"deepseek-v3.2",
|
|
97
|
+
"glm-5",
|
|
98
|
+
"MiniMax-M2.5",
|
|
99
|
+
],
|
|
100
|
+
"default_model": "qwen3.6-plus",
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
"name": "OpenAI",
|
|
104
|
+
"display_name": "OpenAI",
|
|
105
|
+
"key_name": "openai",
|
|
106
|
+
"api_base": None,
|
|
107
|
+
"models": [
|
|
108
|
+
"gpt-5.5",
|
|
109
|
+
"gpt-5.4",
|
|
110
|
+
"gpt-5.4-mini",
|
|
111
|
+
"gpt-5.3-codex",
|
|
112
|
+
"gpt-5.2",
|
|
113
|
+
],
|
|
114
|
+
"default_model": "gpt-5.5",
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
"name": "Anthropic",
|
|
118
|
+
"display_name": "Anthropic",
|
|
119
|
+
"key_name": "anthropic",
|
|
120
|
+
"api_base": None,
|
|
121
|
+
"models": [
|
|
122
|
+
"claude-opus-4-7",
|
|
123
|
+
"claude-opus-4-6",
|
|
124
|
+
"claude-sonnet-4-6",
|
|
125
|
+
"claude-sonnet-4-6-1m",
|
|
126
|
+
"claude-haiku-4-5-20251001",
|
|
127
|
+
],
|
|
128
|
+
"default_model": "claude-opus-4-7",
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
"name": "DeepSeek",
|
|
132
|
+
"display_name": "DeepSeek",
|
|
133
|
+
"key_name": "deepseek",
|
|
134
|
+
"api_base": "https://api.deepseek.com/v1",
|
|
135
|
+
"models": [
|
|
136
|
+
"deepseek-v4-pro",
|
|
137
|
+
"deepseek-v4-flash",
|
|
138
|
+
],
|
|
139
|
+
"default_model": "deepseek-v4-pro",
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
"name": "OpenAPI Compatible",
|
|
143
|
+
"display_name": "OpenAPI 兼容",
|
|
144
|
+
"key_name": "openapi_compatible",
|
|
145
|
+
"api_base": None,
|
|
146
|
+
"models": [],
|
|
147
|
+
"default_model": "",
|
|
148
|
+
},
|
|
149
|
+
]
|
|
150
|
+
|
|
151
|
+
# ── ANSI helpers ──────────────────────────────────────────────────────
|
|
152
|
+
_C_SEL = "\033[96m" # bright cyan (selected)
|
|
153
|
+
_C_DIM = "\033[38;2;128;128;128m" # gray (unselected / hints)
|
|
154
|
+
_C_RST = "\033[0m"
|
|
155
|
+
_C_BOLD = "\033[1m"
|
|
156
|
+
|
|
157
|
+
_BACK = _BackSentinel()
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# ── Data helpers ──────────────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def save_llm_key(key_name: str, api_key: str) -> None:
|
|
164
|
+
"""Save API key to ~/.iac-code/.credentials.yml.
|
|
165
|
+
|
|
166
|
+
When ``key_name`` is the canonical replacement of a legacy slot
|
|
167
|
+
(e.g. ``dashscope`` ← ``bailian``), drop the legacy entry so the file
|
|
168
|
+
has a single source of truth.
|
|
169
|
+
"""
|
|
170
|
+
keys_path = get_credentials_path()
|
|
171
|
+
keys = _load_yaml(keys_path)
|
|
172
|
+
keys[key_name] = api_key
|
|
173
|
+
for legacy, canonical in _LEGACY_KEY_NAME_ALIASES.items():
|
|
174
|
+
if canonical == key_name:
|
|
175
|
+
keys.pop(legacy, None)
|
|
176
|
+
_save_yaml(keys_path, keys)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def save_active_provider_config(
|
|
180
|
+
provider: LLMProvider | dict, model: str, effort: str | None = None, api_base: str | None = None
|
|
181
|
+
) -> None:
|
|
182
|
+
"""Persist the provider's per-provider config and mark it active."""
|
|
183
|
+
settings_path = get_settings_path()
|
|
184
|
+
config = _load_yaml(settings_path)
|
|
185
|
+
key_name = str(provider["key_name"])
|
|
186
|
+
|
|
187
|
+
providers = config.get("providers")
|
|
188
|
+
if not isinstance(providers, dict):
|
|
189
|
+
providers = {}
|
|
190
|
+
|
|
191
|
+
existing = providers.get(key_name)
|
|
192
|
+
entry: dict = dict(existing) if isinstance(existing, dict) else {}
|
|
193
|
+
entry["name"] = provider["name"]
|
|
194
|
+
entry["model"] = model
|
|
195
|
+
effective_api_base = api_base if api_base is not None else provider.get("api_base")
|
|
196
|
+
if effective_api_base is not None:
|
|
197
|
+
entry["apiBase"] = effective_api_base
|
|
198
|
+
if effort is not None:
|
|
199
|
+
entry["effort"] = effort
|
|
200
|
+
|
|
201
|
+
providers[key_name] = entry
|
|
202
|
+
for legacy, canonical in _LEGACY_KEY_NAME_ALIASES.items():
|
|
203
|
+
if canonical == key_name:
|
|
204
|
+
providers.pop(legacy, None)
|
|
205
|
+
config["providers"] = providers
|
|
206
|
+
config["activeProvider"] = key_name
|
|
207
|
+
_save_yaml(settings_path, config)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def get_configured_providers() -> list[str]:
|
|
211
|
+
"""Get list of providers with configured API key (slot names normalized)."""
|
|
212
|
+
try:
|
|
213
|
+
keys_path = get_credentials_path()
|
|
214
|
+
keys = _load_yaml(keys_path)
|
|
215
|
+
except Exception:
|
|
216
|
+
return []
|
|
217
|
+
seen: set[str] = set()
|
|
218
|
+
result: list[str] = []
|
|
219
|
+
for raw_key in keys.keys():
|
|
220
|
+
canonical = _LEGACY_KEY_NAME_ALIASES.get(raw_key, raw_key)
|
|
221
|
+
if canonical not in seen:
|
|
222
|
+
seen.add(canonical)
|
|
223
|
+
result.append(canonical)
|
|
224
|
+
return result
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _load_existing_key(key_name: str) -> str | None:
|
|
228
|
+
"""Load an existing API key for a provider, or None.
|
|
229
|
+
|
|
230
|
+
Falls back to legacy slot names in the file (e.g. ``bailian`` for
|
|
231
|
+
``dashscope``) so existing credentials remain visible after the rename.
|
|
232
|
+
"""
|
|
233
|
+
creds = _load_yaml(get_credentials_path())
|
|
234
|
+
value = creds.get(key_name)
|
|
235
|
+
if value:
|
|
236
|
+
return value
|
|
237
|
+
for legacy, canonical in _LEGACY_KEY_NAME_ALIASES.items():
|
|
238
|
+
if canonical == key_name:
|
|
239
|
+
legacy_value = creds.get(legacy)
|
|
240
|
+
if legacy_value:
|
|
241
|
+
return legacy_value
|
|
242
|
+
return None
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _load_existing_api_base(key_name: str) -> str | None:
|
|
246
|
+
"""Load the saved API Base URL for a provider, or None."""
|
|
247
|
+
value = get_provider_config(key_name).get("apiBase")
|
|
248
|
+
return value if isinstance(value, str) and value else None
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _load_existing_model(key_name: str) -> str | None:
|
|
252
|
+
"""Load the last-used model for a provider, or None."""
|
|
253
|
+
value = get_provider_config(key_name).get("model")
|
|
254
|
+
return value if isinstance(value, str) and value else None
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
# ── Terminal UI primitives ────────────────────────────────────────────
|
|
258
|
+
# All operate on the alternate screen via raw stdout writes.
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _write(text: str) -> None:
|
|
262
|
+
sys.stdout.write(text)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _flush() -> None:
|
|
266
|
+
sys.stdout.flush()
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _clear_screen() -> None:
|
|
270
|
+
"""Clear the alternate screen and move cursor to top."""
|
|
271
|
+
_write("\033[H\033[2J")
|
|
272
|
+
_flush()
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _render_title(title: str) -> None:
|
|
276
|
+
_write(f"\n {_C_BOLD}{title}{_C_RST}\n\n")
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _render_options(options: list[str], selected: int, hints: str) -> None:
|
|
280
|
+
"""Render option list + hint line."""
|
|
281
|
+
for i, opt in enumerate(options):
|
|
282
|
+
if i == selected:
|
|
283
|
+
_write(f" {_C_SEL}> {opt}{_C_RST}\n")
|
|
284
|
+
else:
|
|
285
|
+
_write(f" {_C_DIM}{opt}{_C_RST}\n")
|
|
286
|
+
_write(f"\n {_C_DIM}{hints}{_C_RST}\n")
|
|
287
|
+
_flush()
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _select(title: str, options: list[str], default_index: int = 0) -> int | None:
|
|
291
|
+
"""Full-screen selector. Returns index or None (Esc/Ctrl+C)."""
|
|
292
|
+
import select as select_mod
|
|
293
|
+
import termios
|
|
294
|
+
import tty
|
|
295
|
+
|
|
296
|
+
selected = default_index
|
|
297
|
+
total = len(options)
|
|
298
|
+
if total == 0:
|
|
299
|
+
return None
|
|
300
|
+
selected = max(0, min(selected, total - 1))
|
|
301
|
+
|
|
302
|
+
fd = sys.stdin.fileno()
|
|
303
|
+
old = termios.tcgetattr(fd)
|
|
304
|
+
hints = f"↑↓ {_('Navigate')} Enter {_('Confirm')} Esc {_('Back')}"
|
|
305
|
+
|
|
306
|
+
def draw():
|
|
307
|
+
_clear_screen()
|
|
308
|
+
_render_title(title)
|
|
309
|
+
_render_options(options, selected, hints)
|
|
310
|
+
|
|
311
|
+
draw()
|
|
312
|
+
|
|
313
|
+
def _nb(timeout=0.05):
|
|
314
|
+
r, _, _ = select_mod.select([fd], [], [], timeout)
|
|
315
|
+
return os.read(fd, 1).decode("utf-8", errors="ignore") if r else None
|
|
316
|
+
|
|
317
|
+
tty.setraw(fd)
|
|
318
|
+
try:
|
|
319
|
+
while True:
|
|
320
|
+
ch = os.read(fd, 1).decode("utf-8", errors="ignore")
|
|
321
|
+
if ch in ("\r", "\n"):
|
|
322
|
+
return selected
|
|
323
|
+
if ch == "\x1b":
|
|
324
|
+
c2 = _nb()
|
|
325
|
+
if c2 == "[":
|
|
326
|
+
c3 = _nb()
|
|
327
|
+
if c3 == "A":
|
|
328
|
+
selected = (selected - 1) % total
|
|
329
|
+
elif c3 == "B":
|
|
330
|
+
selected = (selected + 1) % total
|
|
331
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old)
|
|
332
|
+
draw()
|
|
333
|
+
tty.setraw(fd)
|
|
334
|
+
else:
|
|
335
|
+
return None
|
|
336
|
+
elif ch == "\x03":
|
|
337
|
+
return None
|
|
338
|
+
except Exception:
|
|
339
|
+
return None
|
|
340
|
+
finally:
|
|
341
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _read_input_events(fd: int) -> list[tuple]:
|
|
345
|
+
"""Read available bytes from fd and parse into input events.
|
|
346
|
+
|
|
347
|
+
Handles batch reads (paste) and bracketed paste escape sequences.
|
|
348
|
+
Returns list of events: ('char', ch), ('backspace',), ('enter',), ('back',), ('cancel',).
|
|
349
|
+
"""
|
|
350
|
+
import select as select_mod
|
|
351
|
+
|
|
352
|
+
data = os.read(fd, 4096)
|
|
353
|
+
if not data:
|
|
354
|
+
return []
|
|
355
|
+
|
|
356
|
+
events: list[tuple] = []
|
|
357
|
+
i = 0
|
|
358
|
+
while i < len(data):
|
|
359
|
+
b = data[i]
|
|
360
|
+
i += 1
|
|
361
|
+
|
|
362
|
+
if b in (13, 10):
|
|
363
|
+
events.append(("enter",))
|
|
364
|
+
break
|
|
365
|
+
elif b == 3:
|
|
366
|
+
events.append(("cancel",))
|
|
367
|
+
break
|
|
368
|
+
elif b == 27: # ESC
|
|
369
|
+
# Check next byte to distinguish ESC key from escape sequence
|
|
370
|
+
if i >= len(data):
|
|
371
|
+
# ESC at end of chunk — wait briefly for more bytes
|
|
372
|
+
r, _, _ = select_mod.select([fd], [], [], 0.05)
|
|
373
|
+
if r:
|
|
374
|
+
data += os.read(fd, 4096)
|
|
375
|
+
|
|
376
|
+
if i < len(data) and data[i] == ord("["):
|
|
377
|
+
i += 1 # skip '['
|
|
378
|
+
# Consume the full CSI sequence (params + intermediate + final byte)
|
|
379
|
+
while i < len(data) and 0x30 <= data[i] <= 0x3F:
|
|
380
|
+
i += 1
|
|
381
|
+
while i < len(data) and 0x20 <= data[i] <= 0x2F:
|
|
382
|
+
i += 1
|
|
383
|
+
if i < len(data) and 0x40 <= data[i] <= 0x7E:
|
|
384
|
+
i += 1
|
|
385
|
+
continue # skip the entire CSI sequence (bracketed paste, arrows, etc.)
|
|
386
|
+
else:
|
|
387
|
+
events.append(("back",))
|
|
388
|
+
break
|
|
389
|
+
elif b in (127, 8):
|
|
390
|
+
events.append(("backspace",))
|
|
391
|
+
elif b >= 0x80:
|
|
392
|
+
# Multi-byte UTF-8
|
|
393
|
+
remaining_count = 1 if b < 0xE0 else (2 if b < 0xF0 else 3)
|
|
394
|
+
end = i + remaining_count
|
|
395
|
+
if end <= len(data):
|
|
396
|
+
try:
|
|
397
|
+
ch = data[i - 1 : end].decode("utf-8")
|
|
398
|
+
events.append(("char", ch))
|
|
399
|
+
except UnicodeDecodeError:
|
|
400
|
+
pass
|
|
401
|
+
i = end
|
|
402
|
+
# else: incomplete UTF-8 at end of chunk, skip
|
|
403
|
+
else:
|
|
404
|
+
ch = chr(b)
|
|
405
|
+
if ch.isprintable():
|
|
406
|
+
events.append(("char", ch))
|
|
407
|
+
|
|
408
|
+
return events
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _input_masked(title: str, prompt: str, existing: str | None = None) -> str | None | _BackSentinel:
|
|
412
|
+
"""Full-screen masked input for API key.
|
|
413
|
+
|
|
414
|
+
Returns str (key), None (Ctrl+C), or _BACK (Esc).
|
|
415
|
+
"""
|
|
416
|
+
import termios
|
|
417
|
+
import tty
|
|
418
|
+
|
|
419
|
+
has_mask = existing is not None
|
|
420
|
+
mask = "*" * len(existing) if existing else ""
|
|
421
|
+
chars: list[str] = []
|
|
422
|
+
|
|
423
|
+
if has_mask:
|
|
424
|
+
hints = f"Enter {_('Keep')} Backspace {_('Re-enter')} Esc {_('Back')}"
|
|
425
|
+
else:
|
|
426
|
+
hints = f"Enter {_('Confirm')} Esc {_('Back')}"
|
|
427
|
+
|
|
428
|
+
def draw():
|
|
429
|
+
_clear_screen()
|
|
430
|
+
_render_title(title)
|
|
431
|
+
display = mask if (has_mask and not chars) else ("*" * len(chars))
|
|
432
|
+
_write(f" {prompt}{display}")
|
|
433
|
+
_write("\033[s") # save cursor position (end of input)
|
|
434
|
+
_write(f"\n\n {_C_DIM}{hints}{_C_RST}")
|
|
435
|
+
_write("\033[u") # restore cursor to end of input
|
|
436
|
+
_flush()
|
|
437
|
+
|
|
438
|
+
draw()
|
|
439
|
+
|
|
440
|
+
fd = sys.stdin.fileno()
|
|
441
|
+
old = termios.tcgetattr(fd)
|
|
442
|
+
tty.setraw(fd)
|
|
443
|
+
try:
|
|
444
|
+
while True:
|
|
445
|
+
events = _read_input_events(fd)
|
|
446
|
+
need_redraw = False
|
|
447
|
+
done = False
|
|
448
|
+
|
|
449
|
+
for event in events:
|
|
450
|
+
if event[0] == "enter":
|
|
451
|
+
done = True
|
|
452
|
+
break
|
|
453
|
+
elif event[0] == "back":
|
|
454
|
+
return _BACK
|
|
455
|
+
elif event[0] == "cancel":
|
|
456
|
+
return None
|
|
457
|
+
elif event[0] == "backspace":
|
|
458
|
+
if has_mask and not chars:
|
|
459
|
+
has_mask = False
|
|
460
|
+
hints = f"Enter {_('Confirm')} Esc {_('Back')}"
|
|
461
|
+
elif chars:
|
|
462
|
+
chars.pop()
|
|
463
|
+
need_redraw = True
|
|
464
|
+
elif event[0] == "char":
|
|
465
|
+
if has_mask:
|
|
466
|
+
has_mask = False
|
|
467
|
+
hints = f"Enter {_('Confirm')} Esc {_('Back')}"
|
|
468
|
+
chars.append(event[1])
|
|
469
|
+
need_redraw = True
|
|
470
|
+
|
|
471
|
+
if done:
|
|
472
|
+
break
|
|
473
|
+
if need_redraw:
|
|
474
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old)
|
|
475
|
+
draw()
|
|
476
|
+
tty.setraw(fd)
|
|
477
|
+
finally:
|
|
478
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old)
|
|
479
|
+
|
|
480
|
+
if not chars and existing:
|
|
481
|
+
return existing
|
|
482
|
+
return "".join(chars) if chars else None
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _input_text(title: str, prompt: str) -> str | None | _BackSentinel:
|
|
486
|
+
"""Full-screen text input. Returns str, None (Ctrl+C), or _BACK (Esc)."""
|
|
487
|
+
import termios
|
|
488
|
+
import tty
|
|
489
|
+
|
|
490
|
+
chars: list[str] = []
|
|
491
|
+
hints = f"Enter {_('Confirm')} Esc {_('Back')}"
|
|
492
|
+
|
|
493
|
+
def draw():
|
|
494
|
+
_clear_screen()
|
|
495
|
+
_render_title(title)
|
|
496
|
+
text = "".join(chars)
|
|
497
|
+
_write(f" {prompt}{text}")
|
|
498
|
+
_write("\033[s") # save cursor position
|
|
499
|
+
_write(f"\n\n {_C_DIM}{hints}{_C_RST}")
|
|
500
|
+
_write("\033[u") # restore cursor
|
|
501
|
+
_flush()
|
|
502
|
+
|
|
503
|
+
draw()
|
|
504
|
+
|
|
505
|
+
fd = sys.stdin.fileno()
|
|
506
|
+
old = termios.tcgetattr(fd)
|
|
507
|
+
tty.setraw(fd)
|
|
508
|
+
try:
|
|
509
|
+
while True:
|
|
510
|
+
events = _read_input_events(fd)
|
|
511
|
+
need_redraw = False
|
|
512
|
+
done = False
|
|
513
|
+
|
|
514
|
+
for event in events:
|
|
515
|
+
if event[0] == "enter":
|
|
516
|
+
done = True
|
|
517
|
+
break
|
|
518
|
+
elif event[0] == "back":
|
|
519
|
+
return _BACK
|
|
520
|
+
elif event[0] == "cancel":
|
|
521
|
+
return None
|
|
522
|
+
elif event[0] == "backspace":
|
|
523
|
+
if chars:
|
|
524
|
+
chars.pop()
|
|
525
|
+
need_redraw = True
|
|
526
|
+
elif event[0] == "char":
|
|
527
|
+
chars.append(event[1])
|
|
528
|
+
need_redraw = True
|
|
529
|
+
|
|
530
|
+
if done:
|
|
531
|
+
break
|
|
532
|
+
if need_redraw:
|
|
533
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old)
|
|
534
|
+
draw()
|
|
535
|
+
tty.setraw(fd)
|
|
536
|
+
finally:
|
|
537
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old)
|
|
538
|
+
|
|
539
|
+
return "".join(chars) if chars else None
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
# ── Public model selection ────────────────────────────────────────────
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def select_model_interactive(
|
|
546
|
+
models: list[str],
|
|
547
|
+
*,
|
|
548
|
+
current_model: str = "",
|
|
549
|
+
provider_display_name: str = "",
|
|
550
|
+
) -> str | None | _BackSentinel:
|
|
551
|
+
"""Interactive model selection with custom model support.
|
|
552
|
+
|
|
553
|
+
Returns model name, None (cancelled), or _BACK (Escape).
|
|
554
|
+
"""
|
|
555
|
+
while True:
|
|
556
|
+
# Build full model list, including current custom model if not already listed
|
|
557
|
+
full_models = list(models)
|
|
558
|
+
if current_model and current_model not in full_models:
|
|
559
|
+
full_models.insert(0, current_model)
|
|
560
|
+
|
|
561
|
+
options = []
|
|
562
|
+
default_index = 0
|
|
563
|
+
for i, m in enumerate(full_models):
|
|
564
|
+
label = m
|
|
565
|
+
if m == current_model:
|
|
566
|
+
label += _(" (current)")
|
|
567
|
+
default_index = i
|
|
568
|
+
options.append(label)
|
|
569
|
+
options.append(_("Custom model..."))
|
|
570
|
+
|
|
571
|
+
title = (
|
|
572
|
+
_("Select model for {provider}").format(provider=provider_display_name)
|
|
573
|
+
if provider_display_name
|
|
574
|
+
else _("Select model")
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
idx = _select(title, options, default_index=default_index)
|
|
578
|
+
if idx is None:
|
|
579
|
+
return _BACK
|
|
580
|
+
|
|
581
|
+
if idx == len(full_models):
|
|
582
|
+
result = _input_text(title, _("Enter custom model name: "))
|
|
583
|
+
if result is _BACK:
|
|
584
|
+
continue
|
|
585
|
+
if result is None or not str(result).strip():
|
|
586
|
+
continue
|
|
587
|
+
return str(result).strip()
|
|
588
|
+
|
|
589
|
+
return full_models[idx]
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
# ── Cloud provider definitions ────────────────────────────────────────
|
|
593
|
+
|
|
594
|
+
CLOUD_PROVIDERS = [
|
|
595
|
+
{"name": "aliyun"},
|
|
596
|
+
]
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
# ── Main auth command ─────────────────────────────────────────────────
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
async def auth_command(context: "CommandContext | None" = None, **kwargs) -> str | None:
|
|
603
|
+
"""Interactive auth flow on alternate screen."""
|
|
604
|
+
console = context.console if context else None
|
|
605
|
+
store = context.store if context else kwargs.get("store")
|
|
606
|
+
|
|
607
|
+
if not console:
|
|
608
|
+
return _("Error: console not available")
|
|
609
|
+
|
|
610
|
+
# Enter alternate screen
|
|
611
|
+
sys.stdout.write("\033[?1049h")
|
|
612
|
+
sys.stdout.flush()
|
|
613
|
+
|
|
614
|
+
try:
|
|
615
|
+
result = _auth_flow(console, store)
|
|
616
|
+
finally:
|
|
617
|
+
# Leave alternate screen — restores main screen cleanly
|
|
618
|
+
sys.stdout.write("\033[?1049l")
|
|
619
|
+
sys.stdout.flush()
|
|
620
|
+
|
|
621
|
+
# Force provider reinitialize so credential/config changes take effect
|
|
622
|
+
# immediately — _on_state_change may skip reinit when only the API key
|
|
623
|
+
# changed but the model and provider config stayed the same.
|
|
624
|
+
if context and hasattr(context, "repl") and context.repl:
|
|
625
|
+
context.repl._reinitialize_provider(context.repl.store.get_state().model)
|
|
626
|
+
|
|
627
|
+
return result
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def _auth_flow(console, store) -> str | None:
|
|
631
|
+
"""Auth flow running inside alternate screen."""
|
|
632
|
+
while True:
|
|
633
|
+
# Step 0: Select category
|
|
634
|
+
categories = [
|
|
635
|
+
_("Configure LLM Provider"),
|
|
636
|
+
_("Configure IaC Cloud Service"),
|
|
637
|
+
]
|
|
638
|
+
cat_idx = _select(_("Select configuration type"), categories)
|
|
639
|
+
if cat_idx is None:
|
|
640
|
+
return _("Auth cancelled")
|
|
641
|
+
|
|
642
|
+
if cat_idx == 0:
|
|
643
|
+
result = _llm_auth_flow(console, store)
|
|
644
|
+
else:
|
|
645
|
+
result = _cloud_auth_flow(console)
|
|
646
|
+
|
|
647
|
+
if isinstance(result, _BackSentinel):
|
|
648
|
+
continue # Go back to category selection
|
|
649
|
+
return result
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def _get_active_key_name() -> str:
|
|
653
|
+
"""Get the key_name of the currently active provider."""
|
|
654
|
+
return get_active_provider_key() or ""
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def _llm_auth_flow(console, store) -> str | None | _BackSentinel:
|
|
658
|
+
"""LLM provider auth flow."""
|
|
659
|
+
active_key_name = _get_active_key_name()
|
|
660
|
+
|
|
661
|
+
while True:
|
|
662
|
+
# Step 1: Select provider
|
|
663
|
+
provider_options = []
|
|
664
|
+
for p in PROVIDERS:
|
|
665
|
+
label = str(p["display_name"])
|
|
666
|
+
if str(p["key_name"]) == active_key_name:
|
|
667
|
+
label += _(" (current)")
|
|
668
|
+
provider_options.append(label)
|
|
669
|
+
|
|
670
|
+
default_idx = 0
|
|
671
|
+
for i, p in enumerate(PROVIDERS):
|
|
672
|
+
if str(p["key_name"]) == active_key_name:
|
|
673
|
+
default_idx = i
|
|
674
|
+
break
|
|
675
|
+
|
|
676
|
+
idx = _select(_("Select provider"), provider_options, default_index=default_idx)
|
|
677
|
+
if idx is None:
|
|
678
|
+
return _BACK
|
|
679
|
+
|
|
680
|
+
provider = PROVIDERS[idx]
|
|
681
|
+
|
|
682
|
+
# Step 2 (OpenAPI Compatible only): API Base URL
|
|
683
|
+
user_api_base = None
|
|
684
|
+
if provider["key_name"] == "openapi_compatible":
|
|
685
|
+
existing_api_base = _load_existing_api_base(str(provider["key_name"]))
|
|
686
|
+
api_base_result = _input_text_with_default(
|
|
687
|
+
_("Configure {provider}").format(provider=provider["display_name"]),
|
|
688
|
+
"API Base URL",
|
|
689
|
+
existing_api_base or "https://",
|
|
690
|
+
)
|
|
691
|
+
if api_base_result is _BACK:
|
|
692
|
+
continue
|
|
693
|
+
if api_base_result is None:
|
|
694
|
+
return _("Auth cancelled")
|
|
695
|
+
user_api_base = str(api_base_result).strip()
|
|
696
|
+
if not user_api_base:
|
|
697
|
+
continue
|
|
698
|
+
|
|
699
|
+
# Step 3: API key
|
|
700
|
+
existing_key = _load_existing_key(str(provider["key_name"]))
|
|
701
|
+
api_key = _input_masked(
|
|
702
|
+
_("Enter API key for {provider}").format(provider=provider["display_name"]),
|
|
703
|
+
"API key: ",
|
|
704
|
+
existing=existing_key,
|
|
705
|
+
)
|
|
706
|
+
if api_key is _BACK:
|
|
707
|
+
continue
|
|
708
|
+
if api_key is None or not str(api_key).strip():
|
|
709
|
+
return _("Auth cancelled")
|
|
710
|
+
|
|
711
|
+
api_key = str(api_key).strip()
|
|
712
|
+
if api_key != existing_key:
|
|
713
|
+
save_llm_key(str(provider["key_name"]), api_key)
|
|
714
|
+
|
|
715
|
+
# Step 4: Select model — recall this provider's own last-used model,
|
|
716
|
+
# so switching providers never leaks another provider's custom model.
|
|
717
|
+
current_model = _load_existing_model(str(provider["key_name"])) or ""
|
|
718
|
+
selected = select_model_interactive(
|
|
719
|
+
list(provider["models"]),
|
|
720
|
+
current_model=current_model,
|
|
721
|
+
provider_display_name=str(provider["display_name"]),
|
|
722
|
+
)
|
|
723
|
+
if selected is _BACK or selected is None:
|
|
724
|
+
continue
|
|
725
|
+
|
|
726
|
+
selected_model = str(selected)
|
|
727
|
+
save_active_provider_config(provider, selected_model, api_base=user_api_base)
|
|
728
|
+
|
|
729
|
+
# Log telemetry event
|
|
730
|
+
log_event(
|
|
731
|
+
Events.AUTH_CONFIGURED,
|
|
732
|
+
{
|
|
733
|
+
"provider": provider["name"],
|
|
734
|
+
"has_custom_base_url": bool(user_api_base),
|
|
735
|
+
"custom_base_url_host_kind": _classify_base_url(user_api_base),
|
|
736
|
+
},
|
|
737
|
+
)
|
|
738
|
+
|
|
739
|
+
if store:
|
|
740
|
+
store.set_state(model=selected_model)
|
|
741
|
+
|
|
742
|
+
return _("{status}: {provider} / {model}").format(
|
|
743
|
+
status=_("Configured"),
|
|
744
|
+
provider=provider["display_name"],
|
|
745
|
+
model=selected_model,
|
|
746
|
+
)
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
def _cloud_provider_display(name: str) -> str:
|
|
750
|
+
"""Get translated display name for a cloud provider."""
|
|
751
|
+
names = {
|
|
752
|
+
"aliyun": _("Alibaba Cloud"),
|
|
753
|
+
}
|
|
754
|
+
return names.get(name, name)
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
def _cloud_auth_flow(console) -> str | None | _BackSentinel:
|
|
758
|
+
"""Cloud provider auth flow."""
|
|
759
|
+
# Select cloud provider
|
|
760
|
+
options = [_cloud_provider_display(p["name"]) for p in CLOUD_PROVIDERS]
|
|
761
|
+
idx = _select(_("Select Cloud Provider"), options)
|
|
762
|
+
if idx is None:
|
|
763
|
+
return _BACK
|
|
764
|
+
|
|
765
|
+
provider = CLOUD_PROVIDERS[idx]
|
|
766
|
+
|
|
767
|
+
if provider["name"] == "aliyun":
|
|
768
|
+
return _aliyun_auth_flow()
|
|
769
|
+
|
|
770
|
+
return _("Auth cancelled")
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
def _aliyun_auth_flow() -> str | None | _BackSentinel:
|
|
774
|
+
"""Aliyun cloud provider auth flow with credential and region sub-menus."""
|
|
775
|
+
while True:
|
|
776
|
+
config_options = [
|
|
777
|
+
_("Credential"),
|
|
778
|
+
_("Region"),
|
|
779
|
+
]
|
|
780
|
+
idx = _select(_("Configure Alibaba Cloud"), config_options)
|
|
781
|
+
if idx is None:
|
|
782
|
+
return _BACK
|
|
783
|
+
|
|
784
|
+
if idx == 0:
|
|
785
|
+
result = _aliyun_credential_flow()
|
|
786
|
+
else:
|
|
787
|
+
result = _aliyun_region_flow()
|
|
788
|
+
|
|
789
|
+
if result is _BACK:
|
|
790
|
+
continue
|
|
791
|
+
return result
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
def _select_with_info(
|
|
795
|
+
title: str,
|
|
796
|
+
options: list[str],
|
|
797
|
+
info_renderer: Callable[[], None] | None = None,
|
|
798
|
+
default_index: int = 0,
|
|
799
|
+
) -> int | None:
|
|
800
|
+
"""Full-screen selector with optional info block between title and options.
|
|
801
|
+
|
|
802
|
+
info_renderer: a callable that writes info lines to stdout (no clear/title).
|
|
803
|
+
Returns index or None (Esc/Ctrl+C).
|
|
804
|
+
"""
|
|
805
|
+
import select as select_mod
|
|
806
|
+
import termios
|
|
807
|
+
import tty
|
|
808
|
+
|
|
809
|
+
selected = default_index
|
|
810
|
+
total = len(options)
|
|
811
|
+
if total == 0:
|
|
812
|
+
return None
|
|
813
|
+
selected = max(0, min(selected, total - 1))
|
|
814
|
+
|
|
815
|
+
fd = sys.stdin.fileno()
|
|
816
|
+
old = termios.tcgetattr(fd)
|
|
817
|
+
hints = f"↑↓ {_('Navigate')} Enter {_('Confirm')} Esc {_('Back')}"
|
|
818
|
+
|
|
819
|
+
def draw():
|
|
820
|
+
_clear_screen()
|
|
821
|
+
_render_title(title)
|
|
822
|
+
if callable(info_renderer):
|
|
823
|
+
info_renderer()
|
|
824
|
+
_render_options(options, selected, hints)
|
|
825
|
+
|
|
826
|
+
draw()
|
|
827
|
+
|
|
828
|
+
def _nb(timeout=0.05):
|
|
829
|
+
r, _, _ = select_mod.select([fd], [], [], timeout)
|
|
830
|
+
return os.read(fd, 1).decode("utf-8", errors="ignore") if r else None
|
|
831
|
+
|
|
832
|
+
tty.setraw(fd)
|
|
833
|
+
try:
|
|
834
|
+
while True:
|
|
835
|
+
ch = os.read(fd, 1).decode("utf-8", errors="ignore")
|
|
836
|
+
if ch in ("\r", "\n"):
|
|
837
|
+
return selected
|
|
838
|
+
if ch == "\x1b":
|
|
839
|
+
c2 = _nb()
|
|
840
|
+
if c2 == "[":
|
|
841
|
+
c3 = _nb()
|
|
842
|
+
if c3 == "A":
|
|
843
|
+
selected = (selected - 1) % total
|
|
844
|
+
elif c3 == "B":
|
|
845
|
+
selected = (selected + 1) % total
|
|
846
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old)
|
|
847
|
+
draw()
|
|
848
|
+
tty.setraw(fd)
|
|
849
|
+
else:
|
|
850
|
+
return None
|
|
851
|
+
elif ch == "\x03":
|
|
852
|
+
return None
|
|
853
|
+
except Exception:
|
|
854
|
+
return None
|
|
855
|
+
finally:
|
|
856
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old)
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
def _render_credential_info(credential: AliyunCredential, source: str) -> None:
|
|
860
|
+
"""Write current credential info lines (called between title and options)."""
|
|
861
|
+
from iac_code.services.providers.aliyun import MODE_DISPLAY_NAMES, MODE_FIELDS, mask_sensitive
|
|
862
|
+
|
|
863
|
+
_write(f" {_C_DIM}{_('Current configuration')} ({source}){_C_RST}\n")
|
|
864
|
+
mode_display = _(MODE_DISPLAY_NAMES.get(credential.mode, credential.mode))
|
|
865
|
+
_write(f" {_C_DIM}{_('Mode')}: {mode_display}{_C_RST}\n")
|
|
866
|
+
|
|
867
|
+
mode_fields = MODE_FIELDS.get(credential.mode, [])
|
|
868
|
+
for field_name, label, sensitive in mode_fields:
|
|
869
|
+
value = getattr(credential, field_name, "")
|
|
870
|
+
if value and sensitive:
|
|
871
|
+
value = mask_sensitive(value)
|
|
872
|
+
display_value = value if value else _("(not set)")
|
|
873
|
+
_write(f" {_C_DIM}{label}: {display_value}{_C_RST}\n")
|
|
874
|
+
|
|
875
|
+
_write(f" {_C_DIM}{_('Region')}: {credential.region_id}{_C_RST}\n")
|
|
876
|
+
_write("\n")
|
|
877
|
+
|
|
878
|
+
|
|
879
|
+
def _aliyun_credential_flow() -> str | None | _BackSentinel:
|
|
880
|
+
"""Configure Aliyun credentials with type selection."""
|
|
881
|
+
from iac_code.services.providers.aliyun import (
|
|
882
|
+
CREDENTIAL_MODES,
|
|
883
|
+
MODE_DISPLAY_NAMES,
|
|
884
|
+
MODE_FIELDS,
|
|
885
|
+
AliyunCredential,
|
|
886
|
+
AliyunCredentials,
|
|
887
|
+
)
|
|
888
|
+
|
|
889
|
+
title = _("Configure Alibaba Cloud credentials")
|
|
890
|
+
|
|
891
|
+
# Load existing credentials from both sources
|
|
892
|
+
iac_code_cred = AliyunCredentials._load_from_iac_code_config()
|
|
893
|
+
cli_cred = AliyunCredentials.load_from_aliyun_cli()
|
|
894
|
+
|
|
895
|
+
# Determine which to display
|
|
896
|
+
existing_cred = iac_code_cred or cli_cred
|
|
897
|
+
source = "iac-code" if iac_code_cred else ("aliyun CLI" if cli_cred else "")
|
|
898
|
+
|
|
899
|
+
while True:
|
|
900
|
+
# Show current config if exists, then let user choose to reconfigure or go back
|
|
901
|
+
if existing_cred and source:
|
|
902
|
+
action_options = [_("Reconfigure credential"), _("Back")]
|
|
903
|
+
info = lambda: _render_credential_info(existing_cred, source) # noqa: E731
|
|
904
|
+
action_idx = _select_with_info(title, action_options, info_renderer=info)
|
|
905
|
+
if action_idx is None or action_idx == 1:
|
|
906
|
+
return _BACK
|
|
907
|
+
# action_idx == 0: continue to reconfigure
|
|
908
|
+
|
|
909
|
+
# Select credential mode
|
|
910
|
+
mode_options = [_(MODE_DISPLAY_NAMES[m]) for m in CREDENTIAL_MODES]
|
|
911
|
+
default_mode_idx = 0
|
|
912
|
+
if existing_cred and existing_cred.mode in CREDENTIAL_MODES:
|
|
913
|
+
default_mode_idx = CREDENTIAL_MODES.index(existing_cred.mode)
|
|
914
|
+
|
|
915
|
+
mode_idx = _select(_("Select credential type"), mode_options, default_index=default_mode_idx)
|
|
916
|
+
if mode_idx is None:
|
|
917
|
+
if existing_cred and source:
|
|
918
|
+
continue # Go back to showing current config
|
|
919
|
+
return _BACK
|
|
920
|
+
|
|
921
|
+
selected_mode = CREDENTIAL_MODES[mode_idx]
|
|
922
|
+
mode_fields = MODE_FIELDS[selected_mode]
|
|
923
|
+
|
|
924
|
+
# Collect field values
|
|
925
|
+
field_values: dict[str, str] = {}
|
|
926
|
+
for field_name, label, sensitive in mode_fields:
|
|
927
|
+
# Pre-fill from existing credential if same mode
|
|
928
|
+
existing_value = None
|
|
929
|
+
if existing_cred and existing_cred.mode == selected_mode:
|
|
930
|
+
existing_value = getattr(existing_cred, field_name, "") or None
|
|
931
|
+
|
|
932
|
+
if sensitive:
|
|
933
|
+
value = _input_masked(title, f"{label}: ", existing=existing_value)
|
|
934
|
+
else:
|
|
935
|
+
if existing_value:
|
|
936
|
+
value = _input_text_with_default(title, label, existing_value)
|
|
937
|
+
else:
|
|
938
|
+
value = _input_text(title, f"{label}: ")
|
|
939
|
+
|
|
940
|
+
if value is _BACK:
|
|
941
|
+
break # Go back to mode selection
|
|
942
|
+
if value is None:
|
|
943
|
+
return _("Auth cancelled")
|
|
944
|
+
|
|
945
|
+
field_values[field_name] = str(value).strip()
|
|
946
|
+
|
|
947
|
+
if len(field_values) != len(mode_fields):
|
|
948
|
+
continue # User pressed back during field input
|
|
949
|
+
|
|
950
|
+
# Validate that required fields are not empty
|
|
951
|
+
if not all(field_values.values()):
|
|
952
|
+
continue
|
|
953
|
+
|
|
954
|
+
# Build credential and save
|
|
955
|
+
cred = AliyunCredential(
|
|
956
|
+
mode=selected_mode,
|
|
957
|
+
access_key_id=field_values.get("access_key_id", ""),
|
|
958
|
+
access_key_secret=field_values.get("access_key_secret", ""),
|
|
959
|
+
region_id=existing_cred.region_id if existing_cred else "cn-hangzhou",
|
|
960
|
+
sts_token=field_values.get("sts_token", ""),
|
|
961
|
+
ram_role_arn=field_values.get("ram_role_arn", ""),
|
|
962
|
+
ram_session_name=field_values.get("ram_session_name", ""),
|
|
963
|
+
)
|
|
964
|
+
AliyunCredentials.save(cred)
|
|
965
|
+
return _("Configured: Alibaba Cloud credentials saved to ~/.iac-code")
|
|
966
|
+
|
|
967
|
+
|
|
968
|
+
def _aliyun_region_flow() -> str | None | _BackSentinel:
|
|
969
|
+
"""Configure Aliyun default region."""
|
|
970
|
+
from iac_code.services.providers.aliyun import AliyunCredential, AliyunCredentials
|
|
971
|
+
|
|
972
|
+
title = _("Configure Alibaba Cloud region")
|
|
973
|
+
|
|
974
|
+
# Load existing credentials
|
|
975
|
+
iac_code_cred = AliyunCredentials._load_from_iac_code_config()
|
|
976
|
+
cli_cred = AliyunCredentials.load_from_aliyun_cli()
|
|
977
|
+
existing_cred = iac_code_cred or cli_cred
|
|
978
|
+
current_region = existing_cred.region_id if existing_cred else "cn-hangzhou"
|
|
979
|
+
|
|
980
|
+
region = _input_text_with_default(title, _("Region"), current_region)
|
|
981
|
+
if region is _BACK:
|
|
982
|
+
return _BACK
|
|
983
|
+
if region is None:
|
|
984
|
+
return _("Auth cancelled")
|
|
985
|
+
|
|
986
|
+
region_str = str(region).strip()
|
|
987
|
+
if not region_str:
|
|
988
|
+
region_str = current_region
|
|
989
|
+
|
|
990
|
+
if existing_cred:
|
|
991
|
+
existing_cred.region_id = region_str
|
|
992
|
+
AliyunCredentials.save(existing_cred)
|
|
993
|
+
else:
|
|
994
|
+
# No existing credential - save just the region with empty AK credential
|
|
995
|
+
cred = AliyunCredential(region_id=region_str)
|
|
996
|
+
AliyunCredentials.save(cred)
|
|
997
|
+
|
|
998
|
+
return _("Configured: Alibaba Cloud region saved to ~/.iac-code")
|
|
999
|
+
|
|
1000
|
+
|
|
1001
|
+
def _input_text_with_default(title: str, label: str, default: str) -> str | None | _BackSentinel:
|
|
1002
|
+
"""Full-screen text input with a default value shown. Returns str, None (Ctrl+C), or _BACK (Esc)."""
|
|
1003
|
+
import termios
|
|
1004
|
+
import tty
|
|
1005
|
+
|
|
1006
|
+
chars: list[str] = list(default)
|
|
1007
|
+
hints = f"Enter {_('Confirm')} Esc {_('Back')}"
|
|
1008
|
+
|
|
1009
|
+
def draw():
|
|
1010
|
+
_clear_screen()
|
|
1011
|
+
_render_title(title)
|
|
1012
|
+
text = "".join(chars)
|
|
1013
|
+
_write(f" {label}: {text}")
|
|
1014
|
+
_write("\033[s") # save cursor position
|
|
1015
|
+
_write(f"\n\n {_C_DIM}{hints}{_C_RST}")
|
|
1016
|
+
_write("\033[u") # restore cursor
|
|
1017
|
+
_flush()
|
|
1018
|
+
|
|
1019
|
+
draw()
|
|
1020
|
+
|
|
1021
|
+
fd = sys.stdin.fileno()
|
|
1022
|
+
old = termios.tcgetattr(fd)
|
|
1023
|
+
tty.setraw(fd)
|
|
1024
|
+
try:
|
|
1025
|
+
while True:
|
|
1026
|
+
events = _read_input_events(fd)
|
|
1027
|
+
need_redraw = False
|
|
1028
|
+
done = False
|
|
1029
|
+
|
|
1030
|
+
for event in events:
|
|
1031
|
+
if event[0] == "enter":
|
|
1032
|
+
done = True
|
|
1033
|
+
break
|
|
1034
|
+
elif event[0] == "back":
|
|
1035
|
+
return _BACK
|
|
1036
|
+
elif event[0] == "cancel":
|
|
1037
|
+
return None
|
|
1038
|
+
elif event[0] == "backspace":
|
|
1039
|
+
if chars:
|
|
1040
|
+
chars.pop()
|
|
1041
|
+
need_redraw = True
|
|
1042
|
+
elif event[0] == "char":
|
|
1043
|
+
chars.append(event[1])
|
|
1044
|
+
need_redraw = True
|
|
1045
|
+
|
|
1046
|
+
if done:
|
|
1047
|
+
break
|
|
1048
|
+
if need_redraw:
|
|
1049
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old)
|
|
1050
|
+
draw()
|
|
1051
|
+
tty.setraw(fd)
|
|
1052
|
+
finally:
|
|
1053
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old)
|
|
1054
|
+
|
|
1055
|
+
return "".join(chars) if chars else None
|