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