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