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,105 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
from iac_code.i18n import _
|
|
4
|
+
|
|
5
|
+
# Stack/resource status strings for pybabel extraction.
|
|
6
|
+
# Reference: https://help.aliyun.com/zh/ros/developer-reference/api-ros-2019-09-10-getstack
|
|
7
|
+
_STACK_STATUS_STRINGS = [
|
|
8
|
+
_("CREATE_IN_PROGRESS"),
|
|
9
|
+
_("CREATE_FAILED"),
|
|
10
|
+
_("CREATE_COMPLETE"),
|
|
11
|
+
_("UPDATE_IN_PROGRESS"),
|
|
12
|
+
_("UPDATE_FAILED"),
|
|
13
|
+
_("UPDATE_COMPLETE"),
|
|
14
|
+
_("DELETE_IN_PROGRESS"),
|
|
15
|
+
_("DELETE_FAILED"),
|
|
16
|
+
_("DELETE_COMPLETE"),
|
|
17
|
+
_("CREATE_ROLLBACK_IN_PROGRESS"),
|
|
18
|
+
_("CREATE_ROLLBACK_FAILED"),
|
|
19
|
+
_("CREATE_ROLLBACK_COMPLETE"),
|
|
20
|
+
_("ROLLBACK_IN_PROGRESS"),
|
|
21
|
+
_("ROLLBACK_FAILED"),
|
|
22
|
+
_("ROLLBACK_COMPLETE"),
|
|
23
|
+
_("CHECK_IN_PROGRESS"),
|
|
24
|
+
_("CHECK_FAILED"),
|
|
25
|
+
_("CHECK_COMPLETE"),
|
|
26
|
+
_("REVIEW_IN_PROGRESS"),
|
|
27
|
+
_("IMPORT_CREATE_IN_PROGRESS"),
|
|
28
|
+
_("IMPORT_CREATE_FAILED"),
|
|
29
|
+
_("IMPORT_CREATE_COMPLETE"),
|
|
30
|
+
_("IMPORT_CREATE_ROLLBACK_IN_PROGRESS"),
|
|
31
|
+
_("IMPORT_CREATE_ROLLBACK_FAILED"),
|
|
32
|
+
_("IMPORT_CREATE_ROLLBACK_COMPLETE"),
|
|
33
|
+
_("IMPORT_UPDATE_IN_PROGRESS"),
|
|
34
|
+
_("IMPORT_UPDATE_FAILED"),
|
|
35
|
+
_("IMPORT_UPDATE_COMPLETE"),
|
|
36
|
+
_("IMPORT_UPDATE_ROLLBACK_IN_PROGRESS"),
|
|
37
|
+
_("IMPORT_UPDATE_ROLLBACK_FAILED"),
|
|
38
|
+
_("IMPORT_UPDATE_ROLLBACK_COMPLETE"),
|
|
39
|
+
# Resource-level statuses (ListStackResources API)
|
|
40
|
+
_("INIT_COMPLETE"),
|
|
41
|
+
_("IMPORT_IN_PROGRESS"),
|
|
42
|
+
_("IMPORT_COMPLETE"),
|
|
43
|
+
_("IMPORT_FAILED"),
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def translate_status(status: str) -> str:
|
|
48
|
+
"""Translate a stack/resource status code to localized display text."""
|
|
49
|
+
return _(status)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class StackStatus:
|
|
54
|
+
stack_id: str
|
|
55
|
+
stack_name: str
|
|
56
|
+
status: str
|
|
57
|
+
status_reason: str
|
|
58
|
+
progress_percentage: float
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def is_terminal(self) -> bool:
|
|
62
|
+
return self.status.endswith("_COMPLETE") or self.status.endswith("_FAILED")
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def is_success(self) -> bool:
|
|
66
|
+
return self.status.endswith("_COMPLETE")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class ResourceStatus:
|
|
71
|
+
name: str
|
|
72
|
+
resource_type: str
|
|
73
|
+
status: str
|
|
74
|
+
status_reason: str
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def status_icon(self) -> str:
|
|
78
|
+
if self.status.endswith("_COMPLETE"):
|
|
79
|
+
return "\u2705"
|
|
80
|
+
if "IN_PROGRESS" in self.status:
|
|
81
|
+
return "\u23f3"
|
|
82
|
+
if self.status.endswith("_FAILED"):
|
|
83
|
+
return "\u274c"
|
|
84
|
+
if "DELETE" in self.status:
|
|
85
|
+
return "\U0001f5d1\ufe0f"
|
|
86
|
+
return "\u2b1a"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass
|
|
90
|
+
class InstanceStatus:
|
|
91
|
+
account_id: str
|
|
92
|
+
region_id: str
|
|
93
|
+
status: str
|
|
94
|
+
status_reason: str
|
|
95
|
+
elapsed_seconds: int
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def status_icon(self) -> str:
|
|
99
|
+
if self.status in ("SUCCEEDED", "CURRENT"):
|
|
100
|
+
return "\u2705"
|
|
101
|
+
if self.status in ("RUNNING",):
|
|
102
|
+
return "\u23f3"
|
|
103
|
+
if self.status in ("FAILED",):
|
|
104
|
+
return "\u274c"
|
|
105
|
+
return "\u2b1a"
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""EditFile tool - makes targeted edits to files using search and replace."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from iac_code.i18n import _
|
|
9
|
+
from iac_code.tools.base import Tool, ToolContext, ToolResult
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class EditFileTool(Tool):
|
|
13
|
+
@property
|
|
14
|
+
def name(self) -> str:
|
|
15
|
+
return "edit_file"
|
|
16
|
+
|
|
17
|
+
@property
|
|
18
|
+
def description(self) -> str:
|
|
19
|
+
return (
|
|
20
|
+
"Make targeted edits to a file using search and replace. The old_string must "
|
|
21
|
+
"match exactly one location in the file. For creating new files, use WriteFile instead. "
|
|
22
|
+
"Always read the file first to understand its current content before editing."
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def input_schema(self) -> dict[str, Any]:
|
|
27
|
+
return {
|
|
28
|
+
"type": "object",
|
|
29
|
+
"properties": {
|
|
30
|
+
"path": {
|
|
31
|
+
"type": "string",
|
|
32
|
+
"description": "The path to the file to edit.",
|
|
33
|
+
},
|
|
34
|
+
"old_string": {
|
|
35
|
+
"type": "string",
|
|
36
|
+
"description": "The exact string to search for in the file. Must match exactly one location.",
|
|
37
|
+
},
|
|
38
|
+
"new_string": {
|
|
39
|
+
"type": "string",
|
|
40
|
+
"description": "The string to replace old_string with.",
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
"required": ["path", "old_string", "new_string"],
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
def normalize_input(self, tool_input: dict[str, Any]) -> None:
|
|
47
|
+
if "file_path" in tool_input and "path" not in tool_input:
|
|
48
|
+
tool_input["path"] = tool_input.pop("file_path")
|
|
49
|
+
|
|
50
|
+
async def execute(self, *, tool_input: dict[str, Any], context: ToolContext) -> ToolResult:
|
|
51
|
+
path = tool_input["path"]
|
|
52
|
+
old_string = tool_input["old_string"]
|
|
53
|
+
new_string = tool_input["new_string"]
|
|
54
|
+
|
|
55
|
+
if not os.path.isabs(path):
|
|
56
|
+
path = os.path.join(context.cwd, path)
|
|
57
|
+
|
|
58
|
+
if not os.path.exists(path):
|
|
59
|
+
return ToolResult.error(f"File not found: {path}")
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
with open(path, encoding="utf-8") as f:
|
|
63
|
+
content = f.read()
|
|
64
|
+
except Exception as e:
|
|
65
|
+
return ToolResult.error(f"Error reading file: {e}")
|
|
66
|
+
|
|
67
|
+
# Check for exact match
|
|
68
|
+
count = content.count(old_string)
|
|
69
|
+
if count == 0:
|
|
70
|
+
return ToolResult.error(
|
|
71
|
+
f"old_string not found in {path}. Make sure the string matches exactly, "
|
|
72
|
+
"including whitespace and indentation."
|
|
73
|
+
)
|
|
74
|
+
if count > 1:
|
|
75
|
+
return ToolResult.error(
|
|
76
|
+
f"old_string found {count} times in {path}. It must match exactly once. "
|
|
77
|
+
"Include more surrounding context to make the match unique."
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
# Perform the replacement
|
|
81
|
+
new_content = content.replace(old_string, new_string, 1)
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
85
|
+
f.write(new_content)
|
|
86
|
+
except Exception as e:
|
|
87
|
+
return ToolResult.error(f"Error writing file: {e}")
|
|
88
|
+
|
|
89
|
+
return ToolResult.success(f"Successfully edited {path}")
|
|
90
|
+
|
|
91
|
+
# UI rendering methods
|
|
92
|
+
def render_tool_use_message(self, input: dict, *, verbose: bool = False):
|
|
93
|
+
path = input.get("path", "")
|
|
94
|
+
if not path:
|
|
95
|
+
return None
|
|
96
|
+
if verbose and (input.get("old_string") or input.get("new_string")):
|
|
97
|
+
old = input.get("old_string", "")
|
|
98
|
+
preview = old[:60] + "…" if len(old) > 60 else old
|
|
99
|
+
preview = preview.replace("\n", "↵")
|
|
100
|
+
return f"{path} · {preview}"
|
|
101
|
+
return path
|
|
102
|
+
|
|
103
|
+
def render_tool_result_message(self, output: str, *, is_error: bool = False, verbose: bool = False):
|
|
104
|
+
if verbose:
|
|
105
|
+
return output
|
|
106
|
+
# Compact: just the success/error summary line
|
|
107
|
+
first_line = output.split("\n", 1)[0] if output else output
|
|
108
|
+
return first_line
|
|
109
|
+
|
|
110
|
+
def user_facing_name(self, input: dict | None = None) -> str:
|
|
111
|
+
if input and input.get("old_string") == "":
|
|
112
|
+
return _("Create")
|
|
113
|
+
return _("Update")
|
|
114
|
+
|
|
115
|
+
def get_activity_description(self, input: dict | None = None) -> str:
|
|
116
|
+
if input:
|
|
117
|
+
return _("Editing {path}").format(path=input.get("path", ""))
|
|
118
|
+
return _("Editing file...")
|
|
119
|
+
|
|
120
|
+
def is_destructive(self, input: dict | None = None) -> bool:
|
|
121
|
+
return True
|
iac_code/tools/glob.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""GlobTool - fast file pattern matching using glob."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from iac_code.i18n import _
|
|
9
|
+
from iac_code.tools.base import Tool, ToolContext, ToolResult
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class GlobTool(Tool):
|
|
13
|
+
@property
|
|
14
|
+
def name(self) -> str:
|
|
15
|
+
return "glob"
|
|
16
|
+
|
|
17
|
+
@property
|
|
18
|
+
def description(self) -> str:
|
|
19
|
+
return (
|
|
20
|
+
"Fast file pattern matching using glob patterns. Searches for files "
|
|
21
|
+
"matching the given pattern and returns matching file paths sorted by "
|
|
22
|
+
"modification time (newest first). Use ** for recursive matching."
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def input_schema(self) -> dict[str, Any]:
|
|
27
|
+
return {
|
|
28
|
+
"type": "object",
|
|
29
|
+
"properties": {
|
|
30
|
+
"pattern": {
|
|
31
|
+
"type": "string",
|
|
32
|
+
"description": "The glob pattern to match files against, e.g. '**/*.py' or 'src/**/*.ts'.",
|
|
33
|
+
},
|
|
34
|
+
"path": {
|
|
35
|
+
"type": "string",
|
|
36
|
+
"description": "The directory to search in. Defaults to current working directory.",
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
"required": ["pattern"],
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async def execute(self, *, tool_input: dict[str, Any], context: ToolContext) -> ToolResult:
|
|
43
|
+
pattern = tool_input["pattern"]
|
|
44
|
+
path = tool_input.get("path", context.cwd)
|
|
45
|
+
|
|
46
|
+
search_root = Path(path)
|
|
47
|
+
if not search_root.is_absolute():
|
|
48
|
+
search_root = Path(context.cwd) / path
|
|
49
|
+
|
|
50
|
+
if not search_root.exists():
|
|
51
|
+
return ToolResult.error(f"Path not found: {path}")
|
|
52
|
+
|
|
53
|
+
if not search_root.is_dir():
|
|
54
|
+
return ToolResult.error(f"Not a directory: {path}")
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
matches = [p for p in search_root.glob(pattern) if p.is_file()]
|
|
58
|
+
except Exception as e:
|
|
59
|
+
return ToolResult.error(f"Error during glob: {e}")
|
|
60
|
+
|
|
61
|
+
if not matches:
|
|
62
|
+
return ToolResult.success("No files found")
|
|
63
|
+
|
|
64
|
+
# Sort by mtime descending (newest first)
|
|
65
|
+
matches.sort(key=lambda p: p.stat().st_mtime, reverse=True)
|
|
66
|
+
|
|
67
|
+
# Return relative paths
|
|
68
|
+
relative_paths = [str(p.relative_to(search_root)) for p in matches]
|
|
69
|
+
return ToolResult.success("\n".join(relative_paths))
|
|
70
|
+
|
|
71
|
+
# UI rendering methods
|
|
72
|
+
def render_tool_use_message(self, input: dict, *, verbose: bool = False):
|
|
73
|
+
pattern = input.get("pattern", "")
|
|
74
|
+
if not pattern:
|
|
75
|
+
return None
|
|
76
|
+
path = input.get("path", "")
|
|
77
|
+
if path:
|
|
78
|
+
return f'pattern: "{pattern}", path: "{path}"'
|
|
79
|
+
return f'pattern: "{pattern}"'
|
|
80
|
+
|
|
81
|
+
def render_tool_result_message(self, output: str, *, is_error: bool = False, verbose: bool = False):
|
|
82
|
+
if is_error:
|
|
83
|
+
return output
|
|
84
|
+
if output == "No files found":
|
|
85
|
+
return _("Found 0 files")
|
|
86
|
+
lines = output.strip().splitlines()
|
|
87
|
+
count = len(lines)
|
|
88
|
+
summary = _("Found {count} files").format(count=count)
|
|
89
|
+
if verbose:
|
|
90
|
+
return f"{summary}\n" + "\n".join(f" {line}" for line in lines)
|
|
91
|
+
return summary
|
|
92
|
+
|
|
93
|
+
def user_facing_name(self, input: dict | None = None) -> str:
|
|
94
|
+
return _("Search")
|
|
95
|
+
|
|
96
|
+
def get_activity_description(self, input: dict | None = None) -> str:
|
|
97
|
+
if input:
|
|
98
|
+
pattern = input.get("pattern", "")
|
|
99
|
+
return _("Searching {pattern}").format(pattern=pattern)
|
|
100
|
+
return _("Searching files...")
|
|
101
|
+
|
|
102
|
+
def is_read_only(self, input: dict | None = None) -> bool:
|
|
103
|
+
return True
|
iac_code/tools/grep.py
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""GrepTool - content search using ripgrep with Python fallback."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import fnmatch
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import shutil
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from iac_code.i18n import _
|
|
13
|
+
from iac_code.tools.base import Tool, ToolContext, ToolResult
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _is_rg_available() -> bool:
|
|
17
|
+
"""Check whether ripgrep (rg) is available on the system PATH."""
|
|
18
|
+
return shutil.which("rg") is not None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
async def _run_rg(
|
|
22
|
+
pattern: str,
|
|
23
|
+
path: str,
|
|
24
|
+
*,
|
|
25
|
+
glob: str | None = None,
|
|
26
|
+
case_insensitive: bool = False,
|
|
27
|
+
output_mode: str = "files_with_matches",
|
|
28
|
+
max_results: int = 100,
|
|
29
|
+
) -> tuple[int, str, str]:
|
|
30
|
+
"""Run ripgrep and return (returncode, stdout, stderr)."""
|
|
31
|
+
cmd: list[str] = ["rg"]
|
|
32
|
+
|
|
33
|
+
if case_insensitive:
|
|
34
|
+
cmd.append("--ignore-case")
|
|
35
|
+
|
|
36
|
+
if output_mode == "content":
|
|
37
|
+
cmd.extend(["--line-number", "--with-filename"])
|
|
38
|
+
else:
|
|
39
|
+
cmd.append("--files-with-matches")
|
|
40
|
+
|
|
41
|
+
if glob:
|
|
42
|
+
cmd.extend(["--glob", glob])
|
|
43
|
+
|
|
44
|
+
# For content mode, --max-count limits matches per file.
|
|
45
|
+
# We apply a total limit by post-processing the output lines.
|
|
46
|
+
if output_mode == "content":
|
|
47
|
+
cmd.extend(["--max-count", str(max_results)])
|
|
48
|
+
|
|
49
|
+
cmd.append("--")
|
|
50
|
+
cmd.append(pattern)
|
|
51
|
+
cmd.append(path)
|
|
52
|
+
|
|
53
|
+
proc = await asyncio.create_subprocess_exec(
|
|
54
|
+
*cmd,
|
|
55
|
+
stdout=asyncio.subprocess.PIPE,
|
|
56
|
+
stderr=asyncio.subprocess.PIPE,
|
|
57
|
+
)
|
|
58
|
+
stdout_bytes, stderr_bytes = await proc.communicate()
|
|
59
|
+
return (
|
|
60
|
+
proc.returncode or 0,
|
|
61
|
+
stdout_bytes.decode(errors="replace"),
|
|
62
|
+
stderr_bytes.decode(errors="replace"),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _python_grep(
|
|
67
|
+
pattern: str,
|
|
68
|
+
path: str,
|
|
69
|
+
*,
|
|
70
|
+
glob: str | None = None,
|
|
71
|
+
case_insensitive: bool = False,
|
|
72
|
+
output_mode: str = "files_with_matches",
|
|
73
|
+
max_results: int = 100,
|
|
74
|
+
) -> str:
|
|
75
|
+
"""Pure-Python fallback for grep using re + os.walk."""
|
|
76
|
+
flags = re.IGNORECASE if case_insensitive else 0
|
|
77
|
+
try:
|
|
78
|
+
compiled = re.compile(pattern, flags)
|
|
79
|
+
except re.error as e:
|
|
80
|
+
return f"Invalid pattern: {e}"
|
|
81
|
+
|
|
82
|
+
results: list[str] = []
|
|
83
|
+
files_matched = 0
|
|
84
|
+
|
|
85
|
+
for dirpath, _dirnames, filenames in os.walk(path):
|
|
86
|
+
if files_matched >= max_results:
|
|
87
|
+
break
|
|
88
|
+
for filename in sorted(filenames):
|
|
89
|
+
if files_matched >= max_results:
|
|
90
|
+
break
|
|
91
|
+
if glob and not fnmatch.fnmatch(filename, glob):
|
|
92
|
+
continue
|
|
93
|
+
filepath = os.path.join(dirpath, filename)
|
|
94
|
+
try:
|
|
95
|
+
with open(filepath, encoding="utf-8", errors="replace") as fh:
|
|
96
|
+
lines = fh.readlines()
|
|
97
|
+
except (PermissionError, OSError):
|
|
98
|
+
continue
|
|
99
|
+
|
|
100
|
+
matched_lines: list[str] = []
|
|
101
|
+
for lineno, line in enumerate(lines, start=1):
|
|
102
|
+
if compiled.search(line):
|
|
103
|
+
if output_mode == "content":
|
|
104
|
+
matched_lines.append(f"{filepath}:{lineno}:{line.rstrip()}")
|
|
105
|
+
else:
|
|
106
|
+
matched_lines.append(filepath)
|
|
107
|
+
break # files_with_matches — one hit is enough
|
|
108
|
+
|
|
109
|
+
if matched_lines:
|
|
110
|
+
if output_mode == "content":
|
|
111
|
+
results.extend(matched_lines)
|
|
112
|
+
else:
|
|
113
|
+
results.append(filepath)
|
|
114
|
+
files_matched += 1
|
|
115
|
+
|
|
116
|
+
return "\n".join(results)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class GrepTool(Tool):
|
|
120
|
+
@property
|
|
121
|
+
def name(self) -> str:
|
|
122
|
+
return "grep"
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def description(self) -> str:
|
|
126
|
+
return (
|
|
127
|
+
"Search for a regex pattern across file contents. Uses ripgrep (rg) when "
|
|
128
|
+
"available for speed, otherwise falls back to a pure-Python implementation. "
|
|
129
|
+
"Returns matching file paths or matching lines depending on output_mode."
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def input_schema(self) -> dict[str, Any]:
|
|
134
|
+
return {
|
|
135
|
+
"type": "object",
|
|
136
|
+
"properties": {
|
|
137
|
+
"pattern": {
|
|
138
|
+
"type": "string",
|
|
139
|
+
"description": "The regular expression pattern to search for.",
|
|
140
|
+
},
|
|
141
|
+
"path": {
|
|
142
|
+
"type": "string",
|
|
143
|
+
"description": ("The directory to search in. Defaults to current working directory."),
|
|
144
|
+
},
|
|
145
|
+
"glob": {
|
|
146
|
+
"type": "string",
|
|
147
|
+
"description": (
|
|
148
|
+
"Glob pattern to filter files, e.g. '*.py'. "
|
|
149
|
+
"Only files whose names match this pattern will be searched."
|
|
150
|
+
),
|
|
151
|
+
},
|
|
152
|
+
"case_insensitive": {
|
|
153
|
+
"type": "boolean",
|
|
154
|
+
"description": "Perform a case-insensitive search. Defaults to false.",
|
|
155
|
+
},
|
|
156
|
+
"output_mode": {
|
|
157
|
+
"type": "string",
|
|
158
|
+
"enum": ["files_with_matches", "content"],
|
|
159
|
+
"description": (
|
|
160
|
+
"Controls output format. "
|
|
161
|
+
"'files_with_matches' (default) returns only the paths of matching files. "
|
|
162
|
+
"'content' returns each matching line with its file path and line number."
|
|
163
|
+
),
|
|
164
|
+
},
|
|
165
|
+
"max_results": {
|
|
166
|
+
"type": "integer",
|
|
167
|
+
"description": "Maximum number of results to return. Defaults to 100.",
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
"required": ["pattern"],
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async def execute(self, *, tool_input: dict[str, Any], context: ToolContext) -> ToolResult:
|
|
174
|
+
pattern: str = tool_input["pattern"]
|
|
175
|
+
path: str = tool_input.get("path", context.cwd)
|
|
176
|
+
glob: str | None = tool_input.get("glob")
|
|
177
|
+
case_insensitive: bool = bool(tool_input.get("case_insensitive", False))
|
|
178
|
+
output_mode: str = tool_input.get("output_mode", "files_with_matches")
|
|
179
|
+
max_results: int = int(tool_input.get("max_results", 100))
|
|
180
|
+
|
|
181
|
+
# Resolve relative paths against cwd
|
|
182
|
+
if not os.path.isabs(path):
|
|
183
|
+
path = os.path.join(context.cwd, path)
|
|
184
|
+
|
|
185
|
+
if not os.path.exists(path):
|
|
186
|
+
return ToolResult.error(f"Path not found: {path}")
|
|
187
|
+
|
|
188
|
+
if _is_rg_available():
|
|
189
|
+
returncode, stdout, stderr = await _run_rg(
|
|
190
|
+
pattern,
|
|
191
|
+
path,
|
|
192
|
+
glob=glob,
|
|
193
|
+
case_insensitive=case_insensitive,
|
|
194
|
+
output_mode=output_mode,
|
|
195
|
+
max_results=max_results,
|
|
196
|
+
)
|
|
197
|
+
# rg exits with 1 when no matches found (not an error)
|
|
198
|
+
if returncode > 1:
|
|
199
|
+
return ToolResult.error(f"ripgrep error: {stderr.strip()}")
|
|
200
|
+
output = stdout.strip()
|
|
201
|
+
else:
|
|
202
|
+
output = _python_grep(
|
|
203
|
+
pattern,
|
|
204
|
+
path,
|
|
205
|
+
glob=glob,
|
|
206
|
+
case_insensitive=case_insensitive,
|
|
207
|
+
output_mode=output_mode,
|
|
208
|
+
max_results=max_results,
|
|
209
|
+
).strip()
|
|
210
|
+
|
|
211
|
+
if not output:
|
|
212
|
+
return ToolResult.success("No matches")
|
|
213
|
+
|
|
214
|
+
# Enforce max_results on total output lines
|
|
215
|
+
lines = output.splitlines()
|
|
216
|
+
if len(lines) > max_results:
|
|
217
|
+
output = "\n".join(lines[:max_results])
|
|
218
|
+
|
|
219
|
+
return ToolResult.success(output)
|
|
220
|
+
|
|
221
|
+
# UI rendering methods
|
|
222
|
+
def render_tool_use_message(self, input: dict, *, verbose: bool = False):
|
|
223
|
+
pattern = input.get("pattern", "")
|
|
224
|
+
if not pattern:
|
|
225
|
+
return None
|
|
226
|
+
parts = [f'pattern: "{pattern}"']
|
|
227
|
+
path = input.get("path", "")
|
|
228
|
+
if path:
|
|
229
|
+
parts.append(f'path: "{path}"')
|
|
230
|
+
return ", ".join(parts)
|
|
231
|
+
|
|
232
|
+
def render_tool_result_message(self, output: str, *, is_error: bool = False, verbose: bool = False):
|
|
233
|
+
if is_error:
|
|
234
|
+
return output
|
|
235
|
+
if output == "No matches":
|
|
236
|
+
return _("Found 0 files")
|
|
237
|
+
lines = output.strip().splitlines()
|
|
238
|
+
count = len(lines)
|
|
239
|
+
summary = _("Found {count} files").format(count=count)
|
|
240
|
+
if verbose:
|
|
241
|
+
return f"{summary}\n" + "\n".join(f" {line}" for line in lines)
|
|
242
|
+
return summary
|
|
243
|
+
|
|
244
|
+
def user_facing_name(self, input: dict | None = None) -> str:
|
|
245
|
+
return _("Search")
|
|
246
|
+
|
|
247
|
+
def get_activity_description(self, input: dict | None = None) -> str:
|
|
248
|
+
if input:
|
|
249
|
+
pattern = input.get("pattern", "")
|
|
250
|
+
return _("Searching for {pattern}").format(pattern=pattern)
|
|
251
|
+
return _("Searching content...")
|
|
252
|
+
|
|
253
|
+
def is_read_only(self, input: dict | None = None) -> bool:
|
|
254
|
+
return True
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""ListFiles tool - lists directory contents."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from iac_code.i18n import _
|
|
9
|
+
from iac_code.tools.base import Tool, ToolContext, ToolResult
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ListFilesTool(Tool):
|
|
13
|
+
@property
|
|
14
|
+
def name(self) -> str:
|
|
15
|
+
return "list_files"
|
|
16
|
+
|
|
17
|
+
@property
|
|
18
|
+
def description(self) -> str:
|
|
19
|
+
return (
|
|
20
|
+
"List the contents of a directory. Returns file and directory names "
|
|
21
|
+
"with indicators for type (file/directory). Useful for exploring "
|
|
22
|
+
"project structure."
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def input_schema(self) -> dict[str, Any]:
|
|
27
|
+
return {
|
|
28
|
+
"type": "object",
|
|
29
|
+
"properties": {
|
|
30
|
+
"path": {
|
|
31
|
+
"type": "string",
|
|
32
|
+
"description": "The directory path to list. Defaults to current working directory.",
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
"required": [],
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async def execute(self, *, tool_input: dict[str, Any], context: ToolContext) -> ToolResult:
|
|
39
|
+
path = tool_input.get("path", context.cwd)
|
|
40
|
+
|
|
41
|
+
if not os.path.isabs(path):
|
|
42
|
+
path = os.path.join(context.cwd, path)
|
|
43
|
+
|
|
44
|
+
if not os.path.exists(path):
|
|
45
|
+
return ToolResult.error(f"Path not found: {path}")
|
|
46
|
+
|
|
47
|
+
if not os.path.isdir(path):
|
|
48
|
+
return ToolResult.error(f"Not a directory: {path}")
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
entries = sorted(os.listdir(path))
|
|
52
|
+
except PermissionError:
|
|
53
|
+
return ToolResult.error(f"Permission denied: {path}")
|
|
54
|
+
except Exception as e:
|
|
55
|
+
return ToolResult.error(f"Error listing directory: {e}")
|
|
56
|
+
|
|
57
|
+
if not entries:
|
|
58
|
+
return ToolResult.success(f"Directory {path} is empty.")
|
|
59
|
+
|
|
60
|
+
lines = []
|
|
61
|
+
for entry in entries:
|
|
62
|
+
full_path = os.path.join(path, entry)
|
|
63
|
+
if os.path.isdir(full_path):
|
|
64
|
+
lines.append(f" {entry}/")
|
|
65
|
+
else:
|
|
66
|
+
size = os.path.getsize(full_path)
|
|
67
|
+
lines.append(f" {entry} ({_format_size(size)})")
|
|
68
|
+
|
|
69
|
+
result = f"Directory: {path}\n\n" + "\n".join(lines)
|
|
70
|
+
return ToolResult.success(result)
|
|
71
|
+
|
|
72
|
+
# UI rendering methods
|
|
73
|
+
def render_tool_use_message(self, input: dict, *, verbose: bool = False):
|
|
74
|
+
path = input.get("path", ".")
|
|
75
|
+
return path
|
|
76
|
+
|
|
77
|
+
def render_tool_result_message(self, output: str, *, is_error: bool = False, verbose: bool = False):
|
|
78
|
+
if is_error:
|
|
79
|
+
return output
|
|
80
|
+
lines = [line for line in output.strip().splitlines() if line.startswith(" ")]
|
|
81
|
+
summary = _("Found {count} items").format(count=len(lines))
|
|
82
|
+
if verbose:
|
|
83
|
+
return f"{summary}\n" + "\n".join(f" {line.strip()}" for line in lines)
|
|
84
|
+
return summary
|
|
85
|
+
|
|
86
|
+
def user_facing_name(self, input: dict | None = None) -> str:
|
|
87
|
+
return _("List")
|
|
88
|
+
|
|
89
|
+
def get_activity_description(self, input: dict | None = None) -> str:
|
|
90
|
+
if input:
|
|
91
|
+
return _("Listing {path}").format(path=input.get("path", "."))
|
|
92
|
+
return _("Listing files...")
|
|
93
|
+
|
|
94
|
+
def is_read_only(self, input: dict | None = None) -> bool:
|
|
95
|
+
return True
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _format_size(size: int | float) -> str:
|
|
99
|
+
"""Format file size in human readable form."""
|
|
100
|
+
for unit in ["B", "KB", "MB", "GB"]:
|
|
101
|
+
if size < 1024:
|
|
102
|
+
return f"{size:.0f}{unit}" if unit == "B" else f"{size:.1f}{unit}"
|
|
103
|
+
size /= 1024
|
|
104
|
+
return f"{size:.1f}TB"
|