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,49 @@
1
+ """/resume command — pick or jump to a saved session."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from iac_code.i18n import _
8
+
9
+
10
+ async def resume_command(context=None, args: list[str] | None = None, **_kwargs: Any) -> str:
11
+ """Resume a previous session.
12
+
13
+ With an argument, resolves the input as a session id (or unique id
14
+ prefix). Without arguments, opens an interactive picker.
15
+
16
+ Cross-project sessions never hot-swap — they print the
17
+ ``cd ... && iac-code --resume <id>`` command and (best-effort) copy
18
+ it to the clipboard.
19
+ """
20
+ if context is None or not hasattr(context, "repl"):
21
+ return _("Resume is only available in interactive mode.")
22
+ repl = context.repl
23
+ arg_str = " ".join(args or []).strip()
24
+
25
+ index = getattr(repl, "session_index", None)
26
+ if index is None:
27
+ return _("Resume is unavailable: session index not initialised.")
28
+
29
+ if arg_str:
30
+ entry = index.find_by_id_or_prefix(arg_str)
31
+ if entry is None:
32
+ return _("Session not found: {arg}").format(arg=arg_str)
33
+ await repl.swap_or_announce_session(entry)
34
+ return ""
35
+
36
+ from iac_code.ui.dialogs.resume_picker import ResumePicker
37
+
38
+ picker = ResumePicker(
39
+ index=index,
40
+ current_cwd=repl._original_cwd,
41
+ current_session_id=repl.session_id,
42
+ keybinding_manager=getattr(repl, "_keybinding_manager", None),
43
+ renderer=getattr(repl, "renderer", None),
44
+ )
45
+ selected = picker.run()
46
+ if selected is None:
47
+ return _("Resume cancelled")
48
+ await repl.swap_or_announce_session(selected)
49
+ return ""
@@ -0,0 +1,41 @@
1
+ """The /tasks command — view and manage background agents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from iac_code.tasks.task_state import TaskManager
6
+
7
+
8
+ class TasksCommand:
9
+ def __init__(self, task_manager: TaskManager):
10
+ self._manager = task_manager
11
+
12
+ @property
13
+ def name(self) -> str:
14
+ return "tasks"
15
+
16
+ @property
17
+ def description(self) -> str:
18
+ return "List and manage background tasks. Usage: /tasks [stop <id>]"
19
+
20
+ def execute(self, args: str) -> str:
21
+ parts = args.strip().split()
22
+ if parts and parts[0] == "stop" and len(parts) >= 2:
23
+ return self._stop_task(parts[1])
24
+ return self._list_tasks()
25
+
26
+ def _list_tasks(self) -> str:
27
+ tasks = self._manager.list_all()
28
+ if not tasks:
29
+ return "No background tasks."
30
+ lines = []
31
+ for t in tasks:
32
+ icon = {"running": "*", "completed": "+", "failed": "!", "stopped": "-"}.get(t.status.value, "?")
33
+ lines.append(f" [{icon}] {t.id} {t.status.value:<10} [{t.agent_type}] {t.description}")
34
+ return f"Background Tasks ({len(tasks)}):\n" + "\n".join(lines)
35
+
36
+ def _stop_task(self, task_id: str) -> str:
37
+ task = self._manager.get(task_id)
38
+ if not task:
39
+ return f"Task '{task_id}' not found."
40
+ self._manager.stop(task_id)
41
+ return f"Task '{task_id}' stopped."
iac_code/config.py ADDED
@@ -0,0 +1,304 @@
1
+ """Configuration paths for iac-code.
2
+
3
+ Provides unified configuration directory and file paths under
4
+ ``~/.iac-code/``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ import yaml
13
+
14
+ # Default LLM model used when no model is saved in settings
15
+ DEFAULT_MODEL = "qwen3.6-plus"
16
+
17
+ # Configuration directory
18
+ _CONFIG_DIR_NAME = ".iac-code"
19
+
20
+ # Configuration files
21
+ _CREDENTIALS_FILE = ".credentials.yml"
22
+ _SETTINGS_FILE = "settings.yml"
23
+ _CLOUD_CREDENTIALS_FILE = ".cloud-credentials.yml"
24
+ _HISTORY_FILE = ".input_history"
25
+
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # YAML helpers
29
+ # ---------------------------------------------------------------------------
30
+
31
+
32
+ def _load_yaml(path: Path) -> dict[str, Any]:
33
+ """Read a YAML file, returning {} when the file does not exist."""
34
+ if not path.exists():
35
+ return {}
36
+ try:
37
+ data = yaml.safe_load(path.read_text())
38
+ return data if isinstance(data, dict) else {}
39
+ except Exception:
40
+ return {}
41
+
42
+
43
+ def _save_yaml(path: Path, data: dict[str, Any]) -> None:
44
+ """Write *data* to a YAML file, creating parent directories as needed."""
45
+ path.parent.mkdir(parents=True, exist_ok=True)
46
+ path.write_text(yaml.dump(data, default_flow_style=False, allow_unicode=True))
47
+
48
+
49
+ # ---------------------------------------------------------------------------
50
+ # Provider name normalization (env-facing PascalCase ↔ internal key_name)
51
+ # ---------------------------------------------------------------------------
52
+
53
+ # Canonical PascalCase display values for IAC_CODE_PROVIDER.
54
+ # Order is the order shown in error messages.
55
+ _PROVIDER_CANONICAL_NAMES: tuple[str, ...] = (
56
+ "Anthropic",
57
+ "OpenAI",
58
+ "DashScope",
59
+ "DashScopeTokenPlan",
60
+ "DeepSeek",
61
+ "OpenAPICompatible",
62
+ )
63
+
64
+ # Lowercased canonical name -> internal key_name (matches settings.yml `keyName`).
65
+ _PROVIDER_NAME_TO_KEY: dict[str, str] = {
66
+ "anthropic": "anthropic",
67
+ "openai": "openai",
68
+ "dashscope": "dashscope",
69
+ "dashscopetokenplan": "dashscope_token_plan",
70
+ "deepseek": "deepseek",
71
+ "openapicompatible": "openapi_compatible",
72
+ }
73
+
74
+ # key_name -> credentials.yml slot. After the rename, slots match key_name 1:1.
75
+ _KEY_NAME_TO_CRED_SLOT: dict[str, str] = {
76
+ "anthropic": "anthropic",
77
+ "openai": "openai",
78
+ "dashscope": "dashscope",
79
+ "dashscope_token_plan": "dashscope_token_plan",
80
+ "deepseek": "deepseek",
81
+ "openapi_compatible": "openapi_compatible",
82
+ }
83
+
84
+ # Legacy key_name aliases accepted when reading settings.yml (write path always uses
85
+ # the canonical key on the right). Keep DashScope's old "bailian" name readable.
86
+ _LEGACY_KEY_NAME_ALIASES: dict[str, str] = {
87
+ "bailian": "dashscope",
88
+ }
89
+
90
+ # Module-level flag — warn once per process when IAC_CODE_BASE_URL is set
91
+ # but the active provider is not OpenAPICompatible. Reset by tests.
92
+ _warned_base_url_ignored: bool = False
93
+
94
+
95
+ # ---------------------------------------------------------------------------
96
+ # Environment variable overrides
97
+ # ---------------------------------------------------------------------------
98
+
99
+
100
+ def _get_env_overrides() -> dict[str, str | None]:
101
+ """Read IAC_CODE_* env vars and return a normalized override dict.
102
+
103
+ Returns dict with keys: ``provider_key`` (internal key_name or None),
104
+ ``model``, ``api_base``, ``api_key``. Empty/whitespace values are normalized
105
+ to None. Invalid ``IAC_CODE_PROVIDER`` raises ``ValueError`` listing canonical
106
+ names.
107
+ """
108
+ import os
109
+
110
+ def _read(name: str) -> str | None:
111
+ raw = os.environ.get(name, "")
112
+ stripped = raw.strip() if raw else ""
113
+ return stripped or None
114
+
115
+ provider_raw = _read("IAC_CODE_PROVIDER")
116
+ provider_key: str | None = None
117
+ if provider_raw is not None:
118
+ key = _PROVIDER_NAME_TO_KEY.get(provider_raw.lower())
119
+ if key is None:
120
+ valid = ", ".join(_PROVIDER_CANONICAL_NAMES)
121
+ raise ValueError(
122
+ f"Invalid IAC_CODE_PROVIDER value: {provider_raw!r}. Valid values (case-insensitive): {valid}"
123
+ )
124
+ provider_key = key
125
+
126
+ return {
127
+ "provider_key": provider_key,
128
+ "model": _read("IAC_CODE_MODEL"),
129
+ "api_base": _read("IAC_CODE_BASE_URL"),
130
+ "api_key": _read("IAC_CODE_API_KEY"),
131
+ }
132
+
133
+
134
+ # ---------------------------------------------------------------------------
135
+ # Path helpers
136
+ # ---------------------------------------------------------------------------
137
+
138
+
139
+ def get_config_dir() -> Path:
140
+ """Get iac-code config directory (~/.iac-code/).
141
+
142
+ Creates the directory if it doesn't exist.
143
+ """
144
+ config_dir = Path.home() / _CONFIG_DIR_NAME
145
+ config_dir.mkdir(parents=True, exist_ok=True)
146
+ return config_dir
147
+
148
+
149
+ def get_credentials_path() -> Path:
150
+ """Get credentials file path (~/.iac-code/.credentials.yml)."""
151
+ return get_config_dir() / _CREDENTIALS_FILE
152
+
153
+
154
+ def get_settings_path() -> Path:
155
+ """Get settings file path (~/.iac-code/settings.yml)."""
156
+ return get_config_dir() / _SETTINGS_FILE
157
+
158
+
159
+ def get_cloud_credentials_path() -> Path:
160
+ """Get cloud credentials file path (~/.iac-code/.cloud-credentials.yml)."""
161
+ return get_config_dir() / _CLOUD_CREDENTIALS_FILE
162
+
163
+
164
+ def get_history_path() -> Path:
165
+ """Get input history file path (~/.iac-code/.input_history)."""
166
+ return get_config_dir() / _HISTORY_FILE
167
+
168
+
169
+ # ---------------------------------------------------------------------------
170
+ # Config loaders
171
+ # ---------------------------------------------------------------------------
172
+
173
+
174
+ def get_active_provider_key() -> str | None:
175
+ """Return the keyName of the currently active provider, or None.
176
+
177
+ ``IAC_CODE_PROVIDER`` env var takes precedence over settings.yml.
178
+ Legacy keyNames in settings.yml (e.g. ``bailian``) are normalized to the
179
+ canonical name (``dashscope``).
180
+ """
181
+ env_key = _get_env_overrides()["provider_key"]
182
+ if env_key:
183
+ return env_key
184
+ settings = _load_yaml(get_settings_path())
185
+ active = settings.get("activeProvider")
186
+ if isinstance(active, str) and active:
187
+ return _LEGACY_KEY_NAME_ALIASES.get(active, active)
188
+ return None
189
+
190
+
191
+ def get_provider_config(key_name: str) -> dict[str, Any]:
192
+ """Return the persisted per-provider config dict (empty when unset).
193
+
194
+ When ``key_name`` is the active provider, IAC_CODE_MODEL and
195
+ IAC_CODE_BASE_URL env values are overlaid. IAC_CODE_BASE_URL only
196
+ applies when the active provider is ``openapi_compatible``; setting
197
+ it for other providers logs a one-time warning and is ignored.
198
+ """
199
+ global _warned_base_url_ignored
200
+
201
+ settings = _load_yaml(get_settings_path())
202
+ providers = settings.get("providers")
203
+ entry: dict[str, Any] = {}
204
+ if isinstance(providers, dict):
205
+ raw = providers.get(key_name)
206
+ if not isinstance(raw, dict):
207
+ for legacy, canonical in _LEGACY_KEY_NAME_ALIASES.items():
208
+ if canonical == key_name:
209
+ legacy_raw = providers.get(legacy)
210
+ if isinstance(legacy_raw, dict):
211
+ raw = legacy_raw
212
+ break
213
+ if isinstance(raw, dict):
214
+ entry = dict(raw)
215
+
216
+ env = _get_env_overrides()
217
+ active_key = env["provider_key"]
218
+ if active_key is None:
219
+ active = settings.get("activeProvider")
220
+ if isinstance(active, str) and active:
221
+ active_key = _LEGACY_KEY_NAME_ALIASES.get(active, active)
222
+
223
+ if key_name == active_key:
224
+ if env["model"]:
225
+ entry["model"] = env["model"]
226
+ if env["api_base"]:
227
+ if active_key == "openapi_compatible":
228
+ entry["apiBase"] = env["api_base"]
229
+ elif not _warned_base_url_ignored:
230
+ from loguru import logger
231
+
232
+ logger.warning(
233
+ "IAC_CODE_BASE_URL is set but active provider is "
234
+ f"{active_key!r}; the value is ignored. "
235
+ "IAC_CODE_BASE_URL only applies to OpenAPICompatible."
236
+ )
237
+ _warned_base_url_ignored = True
238
+
239
+ return entry
240
+
241
+
242
+ def load_saved_model() -> str | None:
243
+ """Load the active provider's saved model from settings.yml."""
244
+ key = get_active_provider_key()
245
+ if not key:
246
+ return None
247
+ model = get_provider_config(key).get("model")
248
+ return model if isinstance(model, str) and model else None
249
+
250
+
251
+ def load_saved_effort() -> str | None:
252
+ """Load saved effort level from settings.yml."""
253
+ settings = _load_yaml(get_settings_path())
254
+ effort = settings.get("effort")
255
+ return effort if isinstance(effort, str) else None
256
+
257
+
258
+ def load_active_provider_config() -> dict[str, Any] | None:
259
+ """Load the active provider's full config, including its keyName."""
260
+ key = get_active_provider_key()
261
+ if not key:
262
+ return None
263
+ cfg = dict(get_provider_config(key))
264
+ cfg["keyName"] = key
265
+ return cfg
266
+
267
+
268
+ def load_credentials() -> dict[str, str]:
269
+ """Load API credentials from ``.credentials.yml`` with env override applied.
270
+
271
+ Returns a dict with five fixed slots: ``anthropic``, ``openai``,
272
+ ``dashscope``, ``deepseek``, ``openapi_compatible``. The ``dashscope``
273
+ slot also accepts the legacy ``bailian`` key in the YAML file (file's
274
+ ``dashscope`` value takes precedence when both are present).
275
+
276
+ When ``IAC_CODE_API_KEY`` is set and an active provider is determined
277
+ (via env or settings.yml), the env value overrides the active provider's
278
+ slot. With no active provider, the env value is ignored.
279
+ """
280
+ try:
281
+ raw = _load_yaml(get_credentials_path())
282
+ except Exception:
283
+ raw = {}
284
+ if not isinstance(raw, dict):
285
+ raw = {}
286
+
287
+ creds: dict[str, str] = {
288
+ "anthropic": str(raw.get("anthropic", "") or ""),
289
+ "openai": str(raw.get("openai", "") or ""),
290
+ "dashscope": str(raw.get("dashscope", "") or raw.get("bailian", "") or ""),
291
+ "dashscope_token_plan": str(raw.get("dashscope_token_plan", "") or ""),
292
+ "deepseek": str(raw.get("deepseek", "") or ""),
293
+ "openapi_compatible": str(raw.get("openapi_compatible", "") or ""),
294
+ }
295
+
296
+ env = _get_env_overrides()
297
+ if env["api_key"]:
298
+ active_key = env["provider_key"] or get_active_provider_key()
299
+ if active_key:
300
+ slot = _KEY_NAME_TO_CRED_SLOT.get(active_key)
301
+ if slot:
302
+ creds[slot] = env["api_key"]
303
+
304
+ return creds
@@ -0,0 +1,141 @@
1
+ """Internationalization (i18n) module for iac-code.
2
+
3
+ This module provides translation capabilities using Python's standard gettext library.
4
+ """
5
+
6
+ import gettext
7
+ import os
8
+ from pathlib import Path
9
+ from typing import Callable
10
+
11
+ # Supported languages
12
+ SUPPORTED_LANGUAGES = ["en", "zh"]
13
+
14
+ # Default language (English is the source language, no .po file needed)
15
+ DEFAULT_LANGUAGE = "en"
16
+
17
+ # Module-level mutable reference to the actual gettext function
18
+ # Initially set to a pass-through function that returns the original string
19
+
20
+
21
+ def _default_gettext(message: str) -> str:
22
+ """Default pass-through translation function (before setup)."""
23
+ return message
24
+
25
+
26
+ _gettext_func: Callable[[str], str] = _default_gettext
27
+ _current_language: str = DEFAULT_LANGUAGE
28
+
29
+
30
+ def _(message: str) -> str:
31
+ """Translate a message string.
32
+
33
+ Delegates to the current gettext function. This wrapper function
34
+ remains stable after import, while the underlying translation
35
+ function can be updated via setup_i18n().
36
+
37
+ Args:
38
+ message: The message string to translate.
39
+
40
+ Returns:
41
+ The translated message string.
42
+ """
43
+ return _gettext_func(message)
44
+
45
+
46
+ # Typer/Click built-in strings that need translation
47
+ # These are referenced here so pybabel can extract them into messages.pot
48
+ # They are actually used by Typer/Click's internal gettext calls
49
+ _TYPER_CLICK_STRINGS = [
50
+ _("Options"),
51
+ _("Commands"),
52
+ _("Arguments"),
53
+ _("Show this message and exit."),
54
+ _("Install completion for the current shell."),
55
+ _("Show completion for the current shell, to copy it or customize the installation."),
56
+ _("default: {default}"),
57
+ _("required"),
58
+ _("env var: {var}"),
59
+ _("(dynamic)"),
60
+ _("Aborted!"),
61
+ ]
62
+
63
+
64
+ def get_current_language() -> str:
65
+ """Return the currently detected language code (e.g., 'zh', 'en')."""
66
+ return _current_language
67
+
68
+
69
+ def _detect_language() -> str:
70
+ """Detect system language from environment variables.
71
+
72
+ Detection priority:
73
+ 1. LANGUAGE
74
+ 2. LC_ALL
75
+ 3. LC_MESSAGES
76
+ 4. LANG
77
+ 5. Default to 'en'
78
+
79
+ Returns:
80
+ Two-letter language code (e.g., 'zh', 'en')
81
+ """
82
+ env_vars = ["LANGUAGE", "LC_ALL", "LC_MESSAGES", "LANG"]
83
+
84
+ for var in env_vars:
85
+ value = os.environ.get(var)
86
+ if value:
87
+ # Extract the language code (first two characters before any underscore or dot)
88
+ # e.g., 'zh_CN.UTF-8' -> 'zh', 'en_US' -> 'en'
89
+ lang_code = value.split("_")[0].split(".")[0].lower()
90
+ if lang_code in SUPPORTED_LANGUAGES:
91
+ return lang_code
92
+
93
+ return DEFAULT_LANGUAGE
94
+
95
+
96
+ def setup_i18n() -> None:
97
+ """Initialize internationalization.
98
+
99
+ This function sets up gettext with the detected system language.
100
+ It updates the module-level `_` function to use the appropriate translation.
101
+
102
+ The locales directory is expected to be at `locales/` relative to this file.
103
+ English is the default fallback language (source strings are in English).
104
+
105
+ It also binds the `messages` text domain via `gettext.bindtextdomain` /
106
+ `gettext.textdomain`, so that Typer/Click's module-level
107
+ `from gettext import gettext as _` (which captures the function object at
108
+ import time) can still resolve translations at call time. This avoids any
109
+ reliance on import ordering or monkey-patching of the `gettext` module.
110
+ """
111
+ global _gettext_func, _current_language
112
+
113
+ lang = _detect_language()
114
+ _current_language = lang
115
+
116
+ # Get the locales directory path
117
+ locales_dir = Path(__file__).parent / "locales"
118
+
119
+ if lang == DEFAULT_LANGUAGE:
120
+ # For English, use a null translation (strings are already in English)
121
+ translation = gettext.NullTranslations()
122
+ else:
123
+ try:
124
+ translation = gettext.translation(
125
+ "messages",
126
+ localedir=str(locales_dir),
127
+ languages=[lang],
128
+ fallback=True, # Fall back to source strings if translation not found
129
+ )
130
+ except Exception:
131
+ # If any error occurs, fall back to null translation
132
+ translation = gettext.NullTranslations()
133
+
134
+ # Update the mutable reference for our own `_()` calls.
135
+ _gettext_func = translation.gettext
136
+
137
+ # Bind the text domain so that `gettext.gettext(msg)` -> `dgettext('messages', msg)`
138
+ # resolves via this localedir. This is the key mechanism that lets Click's
139
+ # captured `_` reference work without patching or import-order tricks.
140
+ gettext.bindtextdomain("messages", str(locales_dir))
141
+ gettext.textdomain("messages")