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.
Files changed (184) hide show
  1. iac_code/__init__.py +2 -0
  2. iac_code/acp/__init__.py +97 -0
  3. iac_code/acp/convert.py +423 -0
  4. iac_code/acp/http_sse.py +448 -0
  5. iac_code/acp/mcp.py +54 -0
  6. iac_code/acp/metrics.py +71 -0
  7. iac_code/acp/server.py +662 -0
  8. iac_code/acp/session.py +446 -0
  9. iac_code/acp/slash_registry.py +125 -0
  10. iac_code/acp/state.py +99 -0
  11. iac_code/acp/tools.py +112 -0
  12. iac_code/acp/types.py +13 -0
  13. iac_code/acp/version.py +26 -0
  14. iac_code/agent/__init__.py +19 -0
  15. iac_code/agent/agent_loop.py +640 -0
  16. iac_code/agent/agent_tool.py +269 -0
  17. iac_code/agent/agent_types.py +87 -0
  18. iac_code/agent/message.py +153 -0
  19. iac_code/agent/system_prompt.py +313 -0
  20. iac_code/cli/__init__.py +3 -0
  21. iac_code/cli/headless.py +114 -0
  22. iac_code/cli/main.py +246 -0
  23. iac_code/cli/output_formats.py +125 -0
  24. iac_code/commands/__init__.py +93 -0
  25. iac_code/commands/auth.py +1055 -0
  26. iac_code/commands/clear.py +34 -0
  27. iac_code/commands/compact.py +43 -0
  28. iac_code/commands/debug.py +45 -0
  29. iac_code/commands/effort.py +116 -0
  30. iac_code/commands/exit.py +10 -0
  31. iac_code/commands/help.py +49 -0
  32. iac_code/commands/model.py +130 -0
  33. iac_code/commands/registry.py +245 -0
  34. iac_code/commands/resume.py +49 -0
  35. iac_code/commands/tasks.py +41 -0
  36. iac_code/config.py +304 -0
  37. iac_code/i18n/__init__.py +141 -0
  38. iac_code/i18n/locales/zh/LC_MESSAGES/messages.po +1355 -0
  39. iac_code/memory/__init__.py +1 -0
  40. iac_code/memory/memory_manager.py +92 -0
  41. iac_code/memory/memory_tools.py +88 -0
  42. iac_code/providers/__init__.py +1 -0
  43. iac_code/providers/anthropic_provider.py +284 -0
  44. iac_code/providers/base.py +128 -0
  45. iac_code/providers/dashscope_provider.py +47 -0
  46. iac_code/providers/deepseek_provider.py +36 -0
  47. iac_code/providers/manager.py +399 -0
  48. iac_code/providers/openai_provider.py +344 -0
  49. iac_code/providers/retry.py +58 -0
  50. iac_code/providers/stream_watchdog.py +47 -0
  51. iac_code/providers/thinking.py +164 -0
  52. iac_code/services/__init__.py +1 -0
  53. iac_code/services/agent_factory.py +127 -0
  54. iac_code/services/cloud_credentials.py +22 -0
  55. iac_code/services/context_manager.py +221 -0
  56. iac_code/services/providers/__init__.py +1 -0
  57. iac_code/services/providers/aliyun.py +232 -0
  58. iac_code/services/session_index.py +281 -0
  59. iac_code/services/session_storage.py +245 -0
  60. iac_code/services/telemetry/__init__.py +66 -0
  61. iac_code/services/telemetry/attributes.py +84 -0
  62. iac_code/services/telemetry/client.py +330 -0
  63. iac_code/services/telemetry/config.py +76 -0
  64. iac_code/services/telemetry/constants.py +75 -0
  65. iac_code/services/telemetry/content_serializer.py +124 -0
  66. iac_code/services/telemetry/events.py +42 -0
  67. iac_code/services/telemetry/fallback.py +59 -0
  68. iac_code/services/telemetry/identity.py +73 -0
  69. iac_code/services/telemetry/metrics.py +62 -0
  70. iac_code/services/telemetry/names.py +199 -0
  71. iac_code/services/telemetry/sanitize.py +88 -0
  72. iac_code/services/telemetry/sink.py +67 -0
  73. iac_code/services/telemetry/tracing.py +38 -0
  74. iac_code/services/telemetry/types.py +13 -0
  75. iac_code/services/token_budget.py +54 -0
  76. iac_code/services/token_counter.py +76 -0
  77. iac_code/skills/__init__.py +1 -0
  78. iac_code/skills/bundled/__init__.py +94 -0
  79. iac_code/skills/bundled/iac_aliyun/SKILL.md +192 -0
  80. iac_code/skills/bundled/iac_aliyun/__init__.py +16 -0
  81. iac_code/skills/bundled/iac_aliyun/references/cloud-products/ecs.md +167 -0
  82. iac_code/skills/bundled/iac_aliyun/references/cloud-products/oss.md +69 -0
  83. iac_code/skills/bundled/iac_aliyun/references/cloud-products/rds.md +95 -0
  84. iac_code/skills/bundled/iac_aliyun/references/cloud-products/redis.md +100 -0
  85. iac_code/skills/bundled/iac_aliyun/references/cloud-products/slb.md +60 -0
  86. iac_code/skills/bundled/iac_aliyun/references/cloud-products/vpc.md +54 -0
  87. iac_code/skills/bundled/iac_aliyun/references/ros-template.md +155 -0
  88. iac_code/skills/bundled/iac_aliyun/references/template-parameters.md +206 -0
  89. iac_code/skills/bundled/iac_aliyun/references/terraform-template.md +101 -0
  90. iac_code/skills/bundled/iac_aliyun/scripts/tf2ros.py +77 -0
  91. iac_code/skills/bundled/simplify.py +28 -0
  92. iac_code/skills/discovery.py +136 -0
  93. iac_code/skills/frontmatter.py +119 -0
  94. iac_code/skills/listing.py +92 -0
  95. iac_code/skills/loader.py +42 -0
  96. iac_code/skills/processor.py +81 -0
  97. iac_code/skills/renderer.py +157 -0
  98. iac_code/skills/skill_definition.py +82 -0
  99. iac_code/skills/skill_tool.py +261 -0
  100. iac_code/state/__init__.py +5 -0
  101. iac_code/state/app_state.py +122 -0
  102. iac_code/tasks/__init__.py +1 -0
  103. iac_code/tasks/notification_queue.py +28 -0
  104. iac_code/tasks/task_state.py +66 -0
  105. iac_code/tasks/task_tools.py +114 -0
  106. iac_code/tools/__init__.py +8 -0
  107. iac_code/tools/base.py +226 -0
  108. iac_code/tools/bash.py +133 -0
  109. iac_code/tools/cloud/__init__.py +0 -0
  110. iac_code/tools/cloud/aliyun/__init__.py +0 -0
  111. iac_code/tools/cloud/aliyun/aliyun_api.py +510 -0
  112. iac_code/tools/cloud/aliyun/aliyun_doc_search.py +145 -0
  113. iac_code/tools/cloud/aliyun/endpoints.yml +343 -0
  114. iac_code/tools/cloud/aliyun/ros_client.py +56 -0
  115. iac_code/tools/cloud/aliyun/ros_stack.py +633 -0
  116. iac_code/tools/cloud/aliyun/ros_stack_instances.py +247 -0
  117. iac_code/tools/cloud/base_api.py +162 -0
  118. iac_code/tools/cloud/base_stack.py +242 -0
  119. iac_code/tools/cloud/registry.py +20 -0
  120. iac_code/tools/cloud/types.py +105 -0
  121. iac_code/tools/edit_file.py +121 -0
  122. iac_code/tools/glob.py +103 -0
  123. iac_code/tools/grep.py +254 -0
  124. iac_code/tools/list_files.py +104 -0
  125. iac_code/tools/read_file.py +127 -0
  126. iac_code/tools/result_storage.py +39 -0
  127. iac_code/tools/tool_executor.py +165 -0
  128. iac_code/tools/web_fetch.py +177 -0
  129. iac_code/tools/write_file.py +88 -0
  130. iac_code/types/__init__.py +40 -0
  131. iac_code/types/permissions.py +26 -0
  132. iac_code/types/skill_source.py +11 -0
  133. iac_code/types/stream_events.py +227 -0
  134. iac_code/ui/__init__.py +5 -0
  135. iac_code/ui/banner.py +110 -0
  136. iac_code/ui/components/__init__.py +0 -0
  137. iac_code/ui/components/dialog.py +142 -0
  138. iac_code/ui/components/divider.py +20 -0
  139. iac_code/ui/components/fuzzy_picker.py +308 -0
  140. iac_code/ui/components/progress_bar.py +54 -0
  141. iac_code/ui/components/search_box.py +165 -0
  142. iac_code/ui/components/select.py +319 -0
  143. iac_code/ui/components/status_icon.py +42 -0
  144. iac_code/ui/components/tabs.py +128 -0
  145. iac_code/ui/core/__init__.py +0 -0
  146. iac_code/ui/core/in_place_render.py +129 -0
  147. iac_code/ui/core/input_history.py +118 -0
  148. iac_code/ui/core/key_event.py +41 -0
  149. iac_code/ui/core/prompt_input.py +507 -0
  150. iac_code/ui/core/raw_input.py +302 -0
  151. iac_code/ui/core/screen.py +80 -0
  152. iac_code/ui/dialogs/__init__.py +0 -0
  153. iac_code/ui/dialogs/global_search.py +178 -0
  154. iac_code/ui/dialogs/history_search.py +100 -0
  155. iac_code/ui/dialogs/model_picker.py +280 -0
  156. iac_code/ui/dialogs/quick_open.py +108 -0
  157. iac_code/ui/dialogs/resume_picker.py +749 -0
  158. iac_code/ui/keybindings/__init__.py +0 -0
  159. iac_code/ui/keybindings/manager.py +124 -0
  160. iac_code/ui/renderer.py +1535 -0
  161. iac_code/ui/repl.py +772 -0
  162. iac_code/ui/spinner.py +112 -0
  163. iac_code/ui/suggestions/__init__.py +0 -0
  164. iac_code/ui/suggestions/aggregator.py +171 -0
  165. iac_code/ui/suggestions/command_provider.py +43 -0
  166. iac_code/ui/suggestions/directory_provider.py +95 -0
  167. iac_code/ui/suggestions/file_provider.py +121 -0
  168. iac_code/ui/suggestions/shell_history_provider.py +108 -0
  169. iac_code/ui/suggestions/token_extractor.py +77 -0
  170. iac_code/ui/suggestions/types.py +45 -0
  171. iac_code/ui/transcript_view.py +199 -0
  172. iac_code/utils/__init__.py +0 -0
  173. iac_code/utils/background_housekeeping.py +53 -0
  174. iac_code/utils/cleanup.py +68 -0
  175. iac_code/utils/json_utils.py +60 -0
  176. iac_code/utils/log.py +150 -0
  177. iac_code/utils/project_paths.py +74 -0
  178. iac_code/utils/tool_input_parser.py +62 -0
  179. iac_code-0.1.0.dist-info/LICENSE +201 -0
  180. iac_code-0.1.0.dist-info/METADATA +64 -0
  181. iac_code-0.1.0.dist-info/RECORD +184 -0
  182. iac_code-0.1.0.dist-info/WHEEL +5 -0
  183. iac_code-0.1.0.dist-info/entry_points.txt +2 -0
  184. 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