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,34 @@
1
+ """Clear command — clears conversation history and screen."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ async def clear_command(context=None, **kwargs) -> str:
7
+ """Clear conversation history and the terminal screen."""
8
+ store = context.store if context else kwargs.get("store")
9
+ if store:
10
+ store.set_state(messages=[])
11
+
12
+ if context and hasattr(context, "repl"):
13
+ agent_loop = getattr(context.repl, "_agent_loop", None)
14
+ if agent_loop:
15
+ agent_loop.context_manager.reset()
16
+
17
+ if context and hasattr(context, "console"):
18
+ console = context.console
19
+ if console is None:
20
+ return ""
21
+ # ESC[H — cursor home
22
+ # ESC[2J — erase visible screen
23
+ # ESC[3J — erase scrollback buffer
24
+ console.file.write("\033[H\033[2J\033[3J")
25
+ console.file.flush()
26
+
27
+ # Re-render the welcome banner
28
+ from iac_code.ui.banner import render_welcome_banner
29
+
30
+ state = store.get_state() if store else None
31
+ if state:
32
+ console.print(render_welcome_banner(state.model, state.cwd))
33
+
34
+ return ""
@@ -0,0 +1,43 @@
1
+ """Compact command - compresses conversation context."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from iac_code.i18n import _
6
+
7
+
8
+ async def compact_command(**kwargs) -> str:
9
+ """Compact conversation context by summarizing history."""
10
+ context = kwargs.get("context")
11
+ if context is None:
12
+ return _("Compact command requires a context.")
13
+
14
+ repl = getattr(context, "repl", None)
15
+ if repl is None:
16
+ return _("No active REPL.")
17
+
18
+ agent_loop = getattr(repl, "_agent_loop", None)
19
+ if agent_loop is None:
20
+ return _("No active agent loop.")
21
+
22
+ result = await agent_loop.compact()
23
+ if result.status == "empty":
24
+ return _("Nothing to compact: conversation is empty.")
25
+ if result.status == "too_short":
26
+ return _(
27
+ "Conversation too short to compact: all messages are within the recent {turns}-turn preservation window."
28
+ ).format(turns=result.preserve_recent_turns)
29
+ if result.status == "failed":
30
+ return _("Compaction failed. See logs for details.")
31
+
32
+ usage_after = agent_loop.get_context_usage()
33
+ percent = (1 - result.compacted_tokens / result.original_tokens) * 100 if result.original_tokens > 0 else 0
34
+ return _(
35
+ "Context compacted: {original} → {compacted} tokens "
36
+ "({percent_display} reduction). "
37
+ "Context usage: {usage_display}"
38
+ ).format(
39
+ original=result.original_tokens,
40
+ compacted=result.compacted_tokens,
41
+ percent_display=f"{percent:.0f}%",
42
+ usage_display=f"{usage_after['usage_percent']:.0f}%",
43
+ )
@@ -0,0 +1,45 @@
1
+ """Debug command — toggle debug logging at runtime."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from iac_code.i18n import _
6
+ from iac_code.utils.log import (
7
+ current_log_file,
8
+ disable_debug_at_runtime,
9
+ enable_debug_at_runtime,
10
+ is_debug_enabled,
11
+ )
12
+
13
+
14
+ async def debug_command(**kwargs) -> str:
15
+ """Show or toggle debug logging.
16
+
17
+ Usage: /debug [on|off]
18
+ """
19
+ context = kwargs.get("context")
20
+ if context is None:
21
+ return _("Debug command requires a context.")
22
+
23
+ repl = getattr(context, "repl", None)
24
+ session_id = getattr(repl, "_session_id", None) if repl else None
25
+ if not session_id:
26
+ return _("No active session.")
27
+
28
+ args = kwargs.get("args") or []
29
+ action = args[0].lower() if args else ""
30
+
31
+ if action == "" or action == "status":
32
+ if is_debug_enabled():
33
+ log_path = current_log_file()
34
+ return _("Debug logging is on. Log file: {path}").format(path=log_path)
35
+ return _("Debug logging is off.")
36
+
37
+ if action == "on":
38
+ log_path = enable_debug_at_runtime(session_id)
39
+ return _("Debug logging enabled. Log file: {path}").format(path=log_path)
40
+
41
+ if action == "off":
42
+ disable_debug_at_runtime()
43
+ return _("Debug logging disabled.")
44
+
45
+ return _("Usage: /debug [on|off]")
@@ -0,0 +1,116 @@
1
+ """Effort command — show or change the thinking/reasoning effort level."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from typing import TYPE_CHECKING
7
+
8
+ from iac_code.commands.auth import _BACK, PROVIDERS, LLMProvider, _select, save_active_provider_config
9
+ from iac_code.config import get_active_provider_key, get_provider_config
10
+ from iac_code.i18n import _
11
+
12
+ if TYPE_CHECKING:
13
+ from iac_code.ui.dialogs.model_picker import EffortLevel
14
+ from iac_code.ui.repl import CommandContext
15
+
16
+
17
+ def _load_picker_module():
18
+ """Lazy import to avoid a circular import through iac_code.ui.__init__."""
19
+ import importlib
20
+
21
+ return importlib.import_module("iac_code.ui.dialogs.model_picker")
22
+
23
+
24
+ def _active_provider() -> LLMProvider | None:
25
+ key = get_active_provider_key()
26
+ if not key:
27
+ return None
28
+ for p in PROVIDERS:
29
+ if str(p["key_name"]) == key:
30
+ return p
31
+ return None
32
+
33
+
34
+ def _load_current_effort(key_name: str, fallback: "EffortLevel") -> "EffortLevel":
35
+ picker = _load_picker_module()
36
+ level_by_value = {lvl.value: lvl for lvl in picker.EffortLevel}
37
+ saved = get_provider_config(key_name).get("effort")
38
+ if isinstance(saved, str) and saved in level_by_value:
39
+ return level_by_value[saved]
40
+ return fallback
41
+
42
+
43
+ async def effort_command(
44
+ context: "CommandContext | None" = None,
45
+ args: list[str] | None = None,
46
+ **kwargs,
47
+ ) -> str | None:
48
+ """Show or change the thinking effort level for the active model."""
49
+ store = context.store if context else kwargs.get("store")
50
+ args = args or []
51
+
52
+ provider = _active_provider()
53
+ if not provider:
54
+ return _("No configured providers. Run /auth first.")
55
+
56
+ current_model = store.get_state().model if store else ""
57
+ if not current_model:
58
+ return _("No model selected. Run /model first.")
59
+
60
+ from iac_code.providers.thinking import get_thinking_spec
61
+
62
+ picker = _load_picker_module()
63
+ provider_key = str(provider["key_name"])
64
+ spec = get_thinking_spec(provider_key, current_model)
65
+ if not spec.supports_effort:
66
+ return _("Model {model} does not support effort.").format(model=current_model)
67
+
68
+ allowed = list(spec.allowed_efforts)
69
+ level_by_value = {lvl.value: lvl for lvl in picker.EffortLevel}
70
+
71
+ assert spec.default_effort is not None # guarded by supports_effort above
72
+ current = _load_current_effort(provider_key, spec.default_effort)
73
+
74
+ # Non-interactive: /effort <level>
75
+ if args:
76
+ token = args[0].strip().lower()
77
+ target = level_by_value.get(token)
78
+ if target is None or target not in allowed:
79
+ labels = ", ".join(lvl.value for lvl in allowed)
80
+ return _("Invalid effort. Allowed: {labels}").format(labels=labels)
81
+ return _apply_effort(provider, current_model, target, store)
82
+
83
+ # Interactive: show picker
84
+ if not context or not context.console:
85
+ return _("Current effort: {effort}").format(effort=current.value)
86
+
87
+ options = [f"{picker.EFFORT_SYMBOLS[lvl]} {lvl.value}" for lvl in allowed]
88
+ default_idx = allowed.index(current) if current in allowed else 0
89
+
90
+ sys.stdout.write("\033[?1049h")
91
+ sys.stdout.flush()
92
+ try:
93
+ idx = _select(
94
+ _("Select effort for {model}").format(model=current_model),
95
+ options,
96
+ default_index=default_idx,
97
+ )
98
+ finally:
99
+ sys.stdout.write("\033[?1049l")
100
+ sys.stdout.flush()
101
+
102
+ if idx is None or idx is _BACK:
103
+ return _("Kept effort as {effort}").format(effort=current.value)
104
+
105
+ selected = allowed[idx]
106
+ if selected == current:
107
+ return _("Kept effort as {effort}").format(effort=current.value)
108
+
109
+ return _apply_effort(provider, current_model, selected, store)
110
+
111
+
112
+ def _apply_effort(provider: LLMProvider, model: str, effort: "EffortLevel", store) -> str:
113
+ save_active_provider_config(provider, model, effort=effort.value)
114
+ if store is not None:
115
+ store.set_state(effort_level=effort)
116
+ return _("Effort switched to: {effort}").format(effort=effort.value)
@@ -0,0 +1,10 @@
1
+ """Exit command"""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ async def exit_command(**kwargs) -> str:
7
+ """Exit application by raising ExitREPLError."""
8
+ from iac_code.ui.repl import ExitREPLError
9
+
10
+ raise ExitREPLError()
@@ -0,0 +1,49 @@
1
+ """Help command"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from rich.text import Text
8
+
9
+ from iac_code.i18n import _
10
+
11
+ if TYPE_CHECKING:
12
+ from iac_code.commands.registry import CommandRegistry
13
+ from iac_code.ui.repl import CommandContext
14
+
15
+
16
+ async def help_command(
17
+ registry: "CommandRegistry",
18
+ context: "CommandContext",
19
+ **kwargs,
20
+ ) -> str | None:
21
+ """Show help information inline."""
22
+ text = Text()
23
+
24
+ text.append("iac-code", style="bold cyan")
25
+ text.append(" - ")
26
+ text.append(_("AI-powered infrastructure orchestration tool"), style="dim")
27
+ text.append("\n\n")
28
+
29
+ text.append(_("Commands:"), style="bold")
30
+ text.append("\n")
31
+ for cmd in registry.get_all():
32
+ text.append(f" /{cmd.name:<12}", style="cyan")
33
+ text.append(f" {cmd.description}\n")
34
+
35
+ text.append("\n")
36
+ text.append(_("Shortcuts:"), style="bold")
37
+ text.append("\n")
38
+ shortcuts = [
39
+ ("Enter", _("Send message")),
40
+ ("Esc+Enter", _("New line")),
41
+ ("/", _("Show command suggestions")),
42
+ ("Ctrl+C", _("Exit")),
43
+ ]
44
+ for key, description in shortcuts:
45
+ text.append(f" {key:<14}", style="cyan")
46
+ text.append(f" {description}\n")
47
+
48
+ context.console.print(text)
49
+ return None
@@ -0,0 +1,130 @@
1
+ """Model command — switch or display current model."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from typing import TYPE_CHECKING
7
+
8
+ from iac_code.commands.auth import (
9
+ _BACK,
10
+ PROVIDERS,
11
+ LLMProvider,
12
+ _classify_base_url,
13
+ get_configured_providers,
14
+ save_active_provider_config,
15
+ select_model_interactive,
16
+ )
17
+ from iac_code.config import _load_yaml, get_active_provider_key, get_settings_path
18
+ from iac_code.i18n import _
19
+ from iac_code.services.telemetry import log_event
20
+ from iac_code.services.telemetry.names import Events
21
+
22
+ if TYPE_CHECKING:
23
+ from iac_code.ui.repl import CommandContext
24
+
25
+
26
+ def _get_active_provider() -> LLMProvider | None:
27
+ """Get the currently active provider config from settings."""
28
+ key_name = get_active_provider_key()
29
+ if not key_name:
30
+ return None
31
+ for p in PROVIDERS:
32
+ if str(p["key_name"]) == key_name:
33
+ return p
34
+ return None
35
+
36
+
37
+ def _get_active_provider_models() -> list[str]:
38
+ """Get model list for the currently active provider."""
39
+ provider = _get_active_provider()
40
+ if provider:
41
+ return list(provider["models"])
42
+ return []
43
+
44
+
45
+ async def model_command(context: "CommandContext | None" = None, args: list[str] | None = None, **kwargs) -> str | None:
46
+ """Switch or display current model."""
47
+ store = context.store if context else kwargs.get("store")
48
+ args = args or []
49
+
50
+ if args:
51
+ new_model = args[0]
52
+ provider = _get_active_provider()
53
+ if provider:
54
+ # Get current custom base URL if any
55
+ settings = _load_yaml(get_settings_path())
56
+ active = settings.get("activeProvider")
57
+ custom_base_url = None
58
+ if isinstance(active, dict):
59
+ custom_base_url = active.get("apiBase")
60
+
61
+ save_active_provider_config(provider, new_model)
62
+
63
+ # Log telemetry event
64
+ log_event(
65
+ Events.AUTH_CONFIGURED,
66
+ {
67
+ "provider": provider["name"],
68
+ "has_custom_base_url": bool(custom_base_url),
69
+ "custom_base_url_host_kind": _classify_base_url(custom_base_url),
70
+ },
71
+ )
72
+
73
+ if store:
74
+ store.set_state(model=new_model)
75
+ return _("Model switched to: {model}").format(model=new_model)
76
+
77
+ if not context or not context.console:
78
+ state = store.get_state() if store else None
79
+ return _("Current model: {model}").format(model=state.model if state else "")
80
+
81
+ if not get_configured_providers():
82
+ return _("No configured providers. Run /auth first.")
83
+
84
+ provider = _get_active_provider()
85
+ if not provider:
86
+ return _("No configured providers. Run /auth first.")
87
+
88
+ current_model = store.get_state().model if store else ""
89
+ models = list(provider["models"])
90
+
91
+ # Use alternate screen for clean UI
92
+ sys.stdout.write("\033[?1049h")
93
+ sys.stdout.flush()
94
+ try:
95
+ selected = select_model_interactive(
96
+ models,
97
+ current_model=current_model,
98
+ provider_display_name=str(provider["display_name"]),
99
+ )
100
+ finally:
101
+ sys.stdout.write("\033[?1049l")
102
+ sys.stdout.flush()
103
+
104
+ if selected is _BACK or selected is None:
105
+ return _("Kept model as {model}").format(model=current_model)
106
+
107
+ new_model = str(selected)
108
+
109
+ # Get current custom base URL if any
110
+ settings = _load_yaml(get_settings_path())
111
+ active = settings.get("activeProvider")
112
+ custom_base_url = None
113
+ if isinstance(active, dict):
114
+ custom_base_url = active.get("apiBase")
115
+
116
+ save_active_provider_config(provider, new_model)
117
+
118
+ # Log telemetry event
119
+ log_event(
120
+ Events.AUTH_CONFIGURED,
121
+ {
122
+ "provider": provider["name"],
123
+ "has_custom_base_url": bool(custom_base_url),
124
+ "custom_base_url_host_kind": _classify_base_url(custom_base_url),
125
+ },
126
+ )
127
+
128
+ if store:
129
+ store.set_state(model=new_model)
130
+ return _("Model switched to: {model}").format(model=new_model)
@@ -0,0 +1,245 @@
1
+ """Command registry — unified registration for local commands and skill-based commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import TYPE_CHECKING, Any, Awaitable, Callable
7
+
8
+ from iac_code.types.skill_source import SkillSource
9
+
10
+ if TYPE_CHECKING:
11
+ from iac_code.skills.skill_definition import SkillDefinition
12
+
13
+
14
+ @dataclass
15
+ class Command:
16
+ """Base class for all commands (both local and skill-based).
17
+
18
+ Common fields shared by all command types.
19
+ """
20
+
21
+ name: str
22
+ description: str
23
+ aliases: list[str] = field(default_factory=list)
24
+ hidden: bool = False
25
+
26
+ @property
27
+ def is_skill(self) -> bool:
28
+ return False
29
+
30
+
31
+ @dataclass
32
+ class LocalCommand(Command):
33
+ """Built-in slash command with a handler function.
34
+
35
+ Examples: /help, /model, /clear, /compact
36
+ """
37
+
38
+ handler: Callable[..., Awaitable[Any]] | None = None
39
+ arg_names: list[str] = field(default_factory=list)
40
+ arg_hint: str | None = None
41
+ """Inline hint shown as ghost text after the command name (e.g. "[on|off]")."""
42
+ progress_label: str | None = None
43
+ """When set, the REPL shows a spinner with this label while the handler runs.
44
+ Use for commands that perform slow async work (e.g. an LLM call)."""
45
+
46
+
47
+ @dataclass
48
+ class PromptCommand(Command):
49
+ """Skill-based command backed by a SkillDefinition.
50
+
51
+ No handler needed — REPL and SkillTool both route through
52
+ process_prompt_command() directly based on is_skill check.
53
+ """
54
+
55
+ skill: SkillDefinition | None = field(default=None, repr=False)
56
+ source: SkillSource = SkillSource.PROJECT
57
+
58
+ @property
59
+ def is_skill(self) -> bool:
60
+ return True
61
+
62
+ @property
63
+ def when_to_use(self) -> str:
64
+ return self.skill.when_to_use if self.skill else ""
65
+
66
+ @property
67
+ def user_invocable(self) -> bool:
68
+ return self.skill.is_user_invocable if self.skill else True
69
+
70
+ @property
71
+ def model_invocable(self) -> bool:
72
+ return True
73
+
74
+ @property
75
+ def content_length(self) -> int:
76
+ return self.skill.content_length if self.skill else 0
77
+
78
+
79
+ # Type alias
80
+ AnyCommand = LocalCommand | PromptCommand
81
+
82
+
83
+ def _subsequence_score(query: str, target: str) -> float | None:
84
+ """Check if query is a subsequence of target, return score (lower is better) or None."""
85
+ query = query.lower()
86
+ target = target.lower()
87
+ qi = 0
88
+ positions: list[int] = []
89
+ for ti, ch in enumerate(target):
90
+ if qi < len(query) and ch == query[qi]:
91
+ positions.append(ti)
92
+ qi += 1
93
+ if qi < len(query):
94
+ return None
95
+ # Score: prefer consecutive matches and matches near the start
96
+ gap_penalty = sum(positions[i] - positions[i - 1] - 1 for i in range(1, len(positions)))
97
+ start_penalty = positions[0] if positions else 0
98
+ return start_penalty + gap_penalty
99
+
100
+
101
+ @dataclass
102
+ class FuzzyMatch:
103
+ """A fuzzy match result with scoring."""
104
+
105
+ command: Command
106
+ name: str # The matched name (could be alias)
107
+ priority: int # 0=exact, 1=prefix, 2=alias_exact, 3=alias_prefix, 4=subsequence, 5=desc_keyword
108
+ score: float # Lower is better within the same priority
109
+
110
+
111
+ class CommandRegistry:
112
+ """Unified registry for both local commands and prompt skills."""
113
+
114
+ def __init__(self) -> None:
115
+ self._commands: dict[str, Command] = {}
116
+ self._skill_usage_counts: dict[str, int] = {}
117
+
118
+ def register(self, command: Command) -> None:
119
+ """Register a command or skill."""
120
+ self._commands[command.name] = command
121
+ for alias in command.aliases:
122
+ self._commands[alias] = command
123
+
124
+ def get(self, name: str) -> Command | None:
125
+ """Get command by name or alias."""
126
+ return self._commands.get(name)
127
+
128
+ def get_all(self) -> list[Command]:
129
+ """Get all unique, non-hidden commands (including skills)."""
130
+ seen = set()
131
+ result = []
132
+ for cmd in self._commands.values():
133
+ if cmd.name not in seen and not cmd.hidden:
134
+ seen.add(cmd.name)
135
+ result.append(cmd)
136
+ return sorted(result, key=lambda c: c.name)
137
+
138
+ # --- Skill-specific queries ---
139
+
140
+ def get_skills(self) -> list[PromptCommand]:
141
+ """Return all prompt-type commands (skills)."""
142
+ return [c for c in self.get_all() if isinstance(c, PromptCommand)]
143
+
144
+ def get_user_invocable_skills(self) -> list[PromptCommand]:
145
+ """Return skills that users can invoke via /skill-name."""
146
+ return [c for c in self.get_skills() if c.user_invocable]
147
+
148
+ def get_model_invocable_skills(self) -> list[PromptCommand]:
149
+ """Return skills that the model can invoke via Skill tool."""
150
+ return [c for c in self.get_skills() if c.model_invocable]
151
+
152
+ def record_skill_usage(self, name: str) -> None:
153
+ """Record a skill usage for frequency-based sorting."""
154
+ self._skill_usage_counts[name] = self._skill_usage_counts.get(name, 0) + 1
155
+
156
+ # --- Existing methods ---
157
+
158
+ def get_completions(self, prefix: str) -> list[str]:
159
+ """Auto-completion: return command names matching prefix"""
160
+ return sorted(name for name in self._commands if name.startswith(prefix) and not self._commands[name].hidden)
161
+
162
+ def fuzzy_search(self, query: str) -> list[FuzzyMatch]:
163
+ """Fuzzy search commands. Returns matches sorted by priority then score.
164
+
165
+ Priority order:
166
+ 0 - Exact name match
167
+ 1 - Name prefix match
168
+ 2 - Exact alias match
169
+ 3 - Alias prefix match
170
+ 4 - Subsequence match on name
171
+ 5 - Description keyword match
172
+ """
173
+ if not query:
174
+ return [FuzzyMatch(command=cmd, name=cmd.name, priority=0, score=0) for cmd in self.get_all()]
175
+
176
+ q = query.lower()
177
+ matches: list[FuzzyMatch] = []
178
+
179
+ for cmd in self.get_all():
180
+ name_lower = cmd.name.lower()
181
+
182
+ # Exact name match
183
+ if name_lower == q:
184
+ matches.append(FuzzyMatch(cmd, cmd.name, priority=0, score=0))
185
+ continue
186
+
187
+ # Name prefix match
188
+ if name_lower.startswith(q):
189
+ matches.append(FuzzyMatch(cmd, cmd.name, priority=1, score=len(cmd.name)))
190
+ continue
191
+
192
+ # Alias matches
193
+ alias_matched = False
194
+ for alias in cmd.aliases:
195
+ alias_lower = alias.lower()
196
+ if alias_lower == q:
197
+ matches.append(FuzzyMatch(cmd, alias, priority=2, score=0))
198
+ alias_matched = True
199
+ break
200
+ if alias_lower.startswith(q):
201
+ matches.append(FuzzyMatch(cmd, alias, priority=3, score=len(alias)))
202
+ alias_matched = True
203
+ break
204
+ if alias_matched:
205
+ continue
206
+
207
+ # Subsequence match on name
208
+ sub_score = _subsequence_score(q, cmd.name)
209
+ if sub_score is not None:
210
+ matches.append(FuzzyMatch(cmd, cmd.name, priority=4, score=sub_score))
211
+ continue
212
+
213
+ # Description keyword match
214
+ desc_lower = cmd.description.lower()
215
+ if q in desc_lower:
216
+ matches.append(FuzzyMatch(cmd, cmd.name, priority=5, score=desc_lower.index(q)))
217
+ continue
218
+
219
+ matches.sort(key=lambda m: (m.priority, m.score))
220
+ return matches
221
+
222
+ def get_best_prefix_match(self, partial: str) -> str | None:
223
+ """Find the best prefix-matching command name for ghost text."""
224
+ if not partial:
225
+ return None
226
+ q = partial.lower()
227
+ for cmd in self.get_all():
228
+ if cmd.name.lower().startswith(q):
229
+ return cmd.name
230
+ for cmd in self.get_all():
231
+ for alias in cmd.aliases:
232
+ if alias.lower().startswith(q):
233
+ return alias
234
+ return None
235
+
236
+ def is_command(self, text: str) -> bool:
237
+ """Check if text is a command"""
238
+ return text.startswith("/")
239
+
240
+ def parse(self, text: str) -> tuple[str, list[str]]:
241
+ """Parse command text, return (command name, argument list)"""
242
+ parts = text.lstrip("/").split()
243
+ name = parts[0] if parts else ""
244
+ args = parts[1:] if len(parts) > 1 else []
245
+ return name, args