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,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")
|