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,157 @@
1
+ """Prompt rendering pipeline — argument substitution, variable replacement, shell execution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import re
7
+ import shlex
8
+
9
+ from iac_code.skills.skill_definition import SkillContext, SkillDefinition
10
+
11
+ # Shell command matching patterns
12
+ BLOCK_PATTERN = re.compile(r"```!\s*\n?([\s\S]*?)\n?```")
13
+ INLINE_PATTERN = re.compile(r"(?:^|\s)!`([^`]+)`")
14
+
15
+
16
+ async def render_skill_prompt(
17
+ skill: SkillDefinition,
18
+ args: str,
19
+ context: SkillContext,
20
+ ) -> str:
21
+ """Complete rendering pipeline for a skill prompt."""
22
+ content = skill.content
23
+
24
+ # Step 1: Base directory prefix
25
+ if skill.skill_root:
26
+ content = f"Base directory for this skill: {skill.skill_root}\n\n{content}"
27
+
28
+ # Step 2: Argument substitution
29
+ content = substitute_arguments(
30
+ content, args, append_if_no_placeholder=True, argument_names=skill.frontmatter.arguments
31
+ )
32
+
33
+ # Step 3: Built-in variable substitution
34
+ content = content.replace("${SKILL_DIR}", context.skill_dir or "")
35
+ content = content.replace("${SESSION_ID}", context.session_id or "")
36
+
37
+ # Step 4: Shell command execution
38
+ content = await execute_shell_commands(content, cwd=context.cwd)
39
+
40
+ return content
41
+
42
+
43
+ def substitute_arguments(
44
+ content: str,
45
+ args: str,
46
+ *,
47
+ append_if_no_placeholder: bool = True,
48
+ argument_names: list[str] | None = None,
49
+ ) -> str:
50
+ """Substitute argument placeholders in skill content.
51
+
52
+ Supports:
53
+ - Named arguments: $argName
54
+ - Indexed arguments: $ARGUMENTS[0], $ARGUMENTS[1]
55
+ - Short indexed: $0, $1
56
+ - Full arguments: $ARGUMENTS
57
+ """
58
+ if not args:
59
+ return content
60
+
61
+ original = content
62
+ parsed_args = _parse_arguments(args)
63
+ argument_names = argument_names or []
64
+
65
+ # 1. Named argument substitution: $foo, $bar
66
+ for i, name in enumerate(argument_names):
67
+ if i < len(parsed_args):
68
+ content = re.sub(
69
+ rf"\${re.escape(name)}(?![\[\w])",
70
+ parsed_args[i],
71
+ content,
72
+ )
73
+
74
+ # 2. Indexed argument substitution: $ARGUMENTS[0], $ARGUMENTS[1]
75
+ content = re.sub(
76
+ r"\$ARGUMENTS\[(\d+)\]",
77
+ lambda m: parsed_args[int(m.group(1))] if int(m.group(1)) < len(parsed_args) else "",
78
+ content,
79
+ )
80
+
81
+ # 3. Short indexed: $0, $1
82
+ content = re.sub(
83
+ r"\$(\d+)(?!\w)",
84
+ lambda m: parsed_args[int(m.group(1))] if int(m.group(1)) < len(parsed_args) else "",
85
+ content,
86
+ )
87
+
88
+ # 4. Full arguments substitution: $ARGUMENTS
89
+ content = content.replace("$ARGUMENTS", args)
90
+
91
+ # 5. Append if no placeholder was used
92
+ if content == original and append_if_no_placeholder and args:
93
+ content += f"\n\nARGUMENTS: {args}"
94
+
95
+ return content
96
+
97
+
98
+ def _parse_arguments(args: str) -> list[str]:
99
+ """Parse space-separated arguments, respecting quotes."""
100
+ try:
101
+ return shlex.split(args)
102
+ except ValueError:
103
+ return args.split()
104
+
105
+
106
+ async def execute_shell_commands(content: str, *, cwd: str = "") -> str:
107
+ """Execute inline shell commands in skill content and replace with output.
108
+
109
+ Two syntaxes:
110
+ - Inline: !`command` -> replaced with stdout (trimmed)
111
+ - Block: ```!\\ncommand\\n``` -> replaced with stdout
112
+ """
113
+
114
+ async def _replace_block(match: re.Match) -> str:
115
+ cmd = match.group(1).strip()
116
+ return await _run_shell(cmd, cwd=cwd)
117
+
118
+ async def _replace_inline(match: re.Match) -> str:
119
+ cmd = match.group(1).strip()
120
+ output = await _run_shell(cmd, cwd=cwd)
121
+ return output.strip()
122
+
123
+ # Replace block commands
124
+ content = await _replace_async(content, BLOCK_PATTERN, _replace_block)
125
+
126
+ # Replace inline commands
127
+ content = await _replace_async(content, INLINE_PATTERN, _replace_inline)
128
+
129
+ return content
130
+
131
+
132
+ async def _run_shell(cmd: str, *, cwd: str = "", timeout: float = 30.0) -> str:
133
+ """Run a shell command and return its stdout."""
134
+ try:
135
+ proc = await asyncio.create_subprocess_shell(
136
+ cmd,
137
+ stdout=asyncio.subprocess.PIPE,
138
+ stderr=asyncio.subprocess.PIPE,
139
+ cwd=cwd or None,
140
+ )
141
+ stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
142
+ return stdout.decode("utf-8", errors="replace")
143
+ except (asyncio.TimeoutError, OSError) as e:
144
+ return f"[shell error: {e}]"
145
+
146
+
147
+ async def _replace_async(text: str, pattern: re.Pattern, replacer) -> str:
148
+ """Async version of re.sub — awaits the replacer coroutine for each match."""
149
+ result_parts: list[str] = []
150
+ last_end = 0
151
+ for match in pattern.finditer(text):
152
+ result_parts.append(text[last_end : match.start()])
153
+ replacement = await replacer(match)
154
+ result_parts.append(replacement)
155
+ last_end = match.end()
156
+ result_parts.append(text[last_end:])
157
+ return "".join(result_parts)
@@ -0,0 +1,82 @@
1
+ """Core skill definition types."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Protocol
7
+
8
+ from iac_code.skills.frontmatter import SkillFrontmatter
9
+ from iac_code.types.skill_source import SkillSource
10
+
11
+
12
+ class SkillPromptProvider(Protocol):
13
+ """Protocol for skill prompt generation."""
14
+
15
+ async def get_prompt(self, args: str, context: SkillContext) -> str:
16
+ """Generate the final prompt content for this skill."""
17
+ ...
18
+
19
+
20
+ @dataclass
21
+ class SkillContext:
22
+ """Context available during skill prompt generation."""
23
+
24
+ cwd: str
25
+ session_id: str = ""
26
+ skill_dir: str = ""
27
+ skill_root: str = ""
28
+
29
+
30
+ @dataclass
31
+ class SkillDefinition:
32
+ """Complete definition of a skill."""
33
+
34
+ name: str
35
+ description: str
36
+ frontmatter: SkillFrontmatter
37
+ content: str
38
+ source: SkillSource = SkillSource.PROJECT
39
+ file_path: str = ""
40
+ skill_root: str = ""
41
+ content_length: int = 0
42
+
43
+ # Bundled skills can provide a custom prompt generator
44
+ _prompt_provider: SkillPromptProvider | None = field(default=None, repr=False)
45
+
46
+ @property
47
+ def is_user_invocable(self) -> bool:
48
+ return self.frontmatter.user_invocable
49
+
50
+ @property
51
+ def allowed_tools(self) -> list[str]:
52
+ return self.frontmatter.allowed_tools
53
+
54
+ @property
55
+ def model_override(self) -> str:
56
+ return self.frontmatter.model
57
+
58
+ @property
59
+ def effort_override(self) -> str:
60
+ return self.frontmatter.effort
61
+
62
+ @property
63
+ def context_mode(self) -> str:
64
+ """'inline' or 'fork'"""
65
+ return self.frontmatter.context
66
+
67
+ @property
68
+ def agent_type(self) -> str:
69
+ return self.frontmatter.agent
70
+
71
+ @property
72
+ def when_to_use(self) -> str:
73
+ return self.frontmatter.when_to_use
74
+
75
+ async def get_prompt(self, args: str, context: SkillContext) -> str:
76
+ """Generate the final prompt content."""
77
+ if self._prompt_provider is not None:
78
+ return await self._prompt_provider.get_prompt(args, context)
79
+
80
+ from iac_code.skills.renderer import render_skill_prompt
81
+
82
+ return await render_skill_prompt(self, args, context)
@@ -0,0 +1,261 @@
1
+ """SkillTool — registered in ToolRegistry, allows the model to invoke skills."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ from loguru import logger
8
+
9
+ from iac_code.i18n import _
10
+ from iac_code.tools.base import Tool, ToolContext, ToolResult
11
+ from iac_code.types.skill_source import SkillSource
12
+
13
+ if TYPE_CHECKING:
14
+ from iac_code.commands.registry import CommandRegistry
15
+
16
+
17
+ class SkillTool(Tool):
18
+ """Tool that allows the model to invoke skills during conversation.
19
+
20
+ Looks up skills from the unified CommandRegistry (PromptCommand instances).
21
+ Supports two execution modes:
22
+ - inline: Expands skill content into the current conversation context.
23
+ - fork: Runs skill in an isolated sub-agent.
24
+ """
25
+
26
+ def __init__(
27
+ self,
28
+ command_registry: CommandRegistry,
29
+ *,
30
+ session_id: str = "",
31
+ cwd: str = "",
32
+ provider_manager: Any = None,
33
+ tool_registry: Any = None,
34
+ system_prompt: str = "",
35
+ ) -> None:
36
+ self._command_registry = command_registry
37
+ self._session_id = session_id
38
+ self._cwd = cwd
39
+ self._provider_manager = provider_manager
40
+ self._tool_registry = tool_registry
41
+ self._system_prompt = system_prompt
42
+
43
+ @property
44
+ def name(self) -> str:
45
+ return "skill"
46
+
47
+ @property
48
+ def description(self) -> str:
49
+ return "Execute a skill within the current conversation."
50
+
51
+ @property
52
+ def input_schema(self) -> dict[str, Any]:
53
+ return {
54
+ "type": "object",
55
+ "properties": {
56
+ "skill": {
57
+ "type": "string",
58
+ "description": "The skill name to execute.",
59
+ },
60
+ "args": {
61
+ "type": "string",
62
+ "description": "Optional arguments for the skill.",
63
+ },
64
+ },
65
+ "required": ["skill"],
66
+ }
67
+
68
+ async def execute(self, *, tool_input: dict[str, Any], context: ToolContext) -> ToolResult:
69
+ import time
70
+
71
+ from iac_code.services.telemetry import log_event
72
+ from iac_code.services.telemetry.names import Events
73
+ from iac_code.services.telemetry.sanitize import sanitize_skill_name
74
+
75
+ skill_name = self._normalize_name(tool_input["skill"])
76
+ args = tool_input.get("args", "")
77
+
78
+ from iac_code.commands.registry import PromptCommand
79
+
80
+ command = self._command_registry.get(skill_name)
81
+ if not isinstance(command, PromptCommand):
82
+ return ToolResult.error(f"Skill not found: '{skill_name}'")
83
+
84
+ # Record usage
85
+ self._command_registry.record_skill_usage(skill_name)
86
+
87
+ skill = command.skill
88
+ if skill is None:
89
+ return ToolResult.error(f"Skill definition missing for: '{skill_name}'")
90
+
91
+ # Emit skill invoked event
92
+ safe_name = sanitize_skill_name(skill_name)
93
+ log_event(
94
+ Events.SKILL_INVOKED,
95
+ {
96
+ "skill_name": safe_name,
97
+ "invocation_source": "explicit",
98
+ },
99
+ )
100
+ started = time.monotonic()
101
+
102
+ # Execute based on context mode
103
+ if skill.context_mode == "fork":
104
+ return await self._execute_forked(command, args, context, safe_name, started)
105
+ else:
106
+ return await self._execute_inline(command, args, safe_name, started)
107
+
108
+ async def _execute_inline(
109
+ self, command: Any, args: str, safe_name: str | None = None, started: float | None = None
110
+ ) -> ToolResult:
111
+ """Inline mode: expand skill content into current conversation context."""
112
+ import time
113
+
114
+ from iac_code.services.telemetry import log_event
115
+ from iac_code.services.telemetry.names import Events
116
+ from iac_code.skills.processor import process_prompt_command
117
+
118
+ try:
119
+ result = await process_prompt_command(command, args, session_id=self._session_id)
120
+ if safe_name is not None and started is not None:
121
+ log_event(
122
+ Events.SKILL_COMPLETED,
123
+ {
124
+ "skill_name": safe_name,
125
+ "duration_ms": int((time.monotonic() - started) * 1000),
126
+ "outcome": "success",
127
+ },
128
+ )
129
+ return ToolResult(
130
+ content=_("Skill '{name}' loaded (inline).").format(name=result.skill_name),
131
+ is_error=False,
132
+ new_messages=result.new_messages,
133
+ context_modifier=result.context_modifier,
134
+ )
135
+ except Exception as e:
136
+ if safe_name is not None and started is not None:
137
+ log_event(
138
+ Events.SKILL_COMPLETED,
139
+ {
140
+ "skill_name": safe_name,
141
+ "duration_ms": int((time.monotonic() - started) * 1000),
142
+ "outcome": "error",
143
+ },
144
+ )
145
+ logger.exception("Skill inline execution failed: %s", command.name)
146
+ return ToolResult.error(f"Skill execution failed: {e}")
147
+
148
+ async def _execute_forked(
149
+ self,
150
+ command: Any,
151
+ args: str,
152
+ tool_context: ToolContext,
153
+ safe_name: str | None = None,
154
+ started: float | None = None,
155
+ ) -> ToolResult:
156
+ """Fork mode: run skill in an isolated sub-agent."""
157
+ import time
158
+
159
+ from iac_code.agent.agent_tool import run_sub_agent
160
+ from iac_code.services.telemetry import log_event
161
+ from iac_code.services.telemetry.names import Events
162
+ from iac_code.skills.processor import process_prompt_command
163
+
164
+ try:
165
+ result = await process_prompt_command(command, args, session_id=self._session_id)
166
+ skill = command.skill
167
+ result_text, progress = await run_sub_agent(
168
+ prompt=result.prompt_content,
169
+ agent_type=skill.agent_type,
170
+ cwd=tool_context.cwd,
171
+ parent_provider_manager=self._provider_manager,
172
+ parent_tool_registry=self._tool_registry,
173
+ parent_system_prompt=self._system_prompt,
174
+ )
175
+ if safe_name is not None and started is not None:
176
+ log_event(
177
+ Events.SKILL_COMPLETED,
178
+ {
179
+ "skill_name": safe_name,
180
+ "duration_ms": int((time.monotonic() - started) * 1000),
181
+ "outcome": "success",
182
+ },
183
+ )
184
+ return ToolResult.success(
185
+ f"{result_text}\n\n"
186
+ f"[Skill '{skill.name}' completed: "
187
+ f"{progress.tool_use_count} tool calls, "
188
+ f"{progress.token_count} tokens]"
189
+ )
190
+ except Exception as e:
191
+ if safe_name is not None and started is not None:
192
+ log_event(
193
+ Events.SKILL_COMPLETED,
194
+ {
195
+ "skill_name": safe_name,
196
+ "duration_ms": int((time.monotonic() - started) * 1000),
197
+ "outcome": "error",
198
+ },
199
+ )
200
+ logger.exception("Skill forked execution failed: %s", command.name)
201
+ return ToolResult.error(f"Skill forked execution failed: {e}")
202
+
203
+ @staticmethod
204
+ def _normalize_name(name: str) -> str:
205
+ """Normalize skill name: strip leading /, lowercase."""
206
+ return name.lstrip("/").strip().lower()
207
+
208
+ # --- UI rendering ---
209
+
210
+ def render_tool_use_message(self, input: dict, *, verbose: bool = False) -> str | None:
211
+ return input.get("skill", "") or None
212
+
213
+ def user_facing_name(self, input: dict | None = None) -> str:
214
+ return _("Skill")
215
+
216
+ def is_read_only(self, input: dict | None = None) -> bool:
217
+ return True # Skill itself is just prompt injection
218
+
219
+ def is_concurrency_safe(self, tool_input: dict[str, Any]) -> bool:
220
+ return True
221
+
222
+ # --- Permission check ---
223
+
224
+ async def check_permissions(self, input: dict, context: dict | None = None) -> Any:
225
+ """Skill permission check:
226
+
227
+ 1. Bundled skill -> auto-allow
228
+ 2. Skill with only safe properties -> auto-allow
229
+ 3. Others -> ask user
230
+ """
231
+ from iac_code.commands.registry import PromptCommand
232
+ from iac_code.types.permissions import PermissionResult
233
+
234
+ skill_name = self._normalize_name(input.get("skill", ""))
235
+ command = self._command_registry.get(skill_name)
236
+ if not isinstance(command, PromptCommand):
237
+ return PermissionResult(behavior="deny", message=f"Skill not found: {skill_name}")
238
+ skill = command.skill
239
+
240
+ # Bundled skills are fully trusted
241
+ if command.source == SkillSource.BUNDLED:
242
+ return PermissionResult(behavior="allow")
243
+
244
+ # Safe-only skills auto-allow
245
+ if self._has_only_safe_properties(skill):
246
+ return PermissionResult(behavior="allow")
247
+
248
+ # Others ask user
249
+ return PermissionResult(
250
+ behavior="ask",
251
+ message=f"Allow skill '{skill_name}' (source: {skill.source if skill else 'unknown'})?",
252
+ )
253
+
254
+ @staticmethod
255
+ def _has_only_safe_properties(skill: Any) -> bool:
256
+ """Check if a skill only has safe properties (no tools, no shell commands)."""
257
+ if skill.frontmatter.allowed_tools:
258
+ return False
259
+ if "!`" in skill.content or "```!" in skill.content:
260
+ return False
261
+ return True
@@ -0,0 +1,5 @@
1
+ """State management module"""
2
+
3
+ from iac_code.state.app_state import AppState, AppStateStore
4
+
5
+ __all__ = ["AppState", "AppStateStore"]
@@ -0,0 +1,122 @@
1
+ """Application state management."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from collections import OrderedDict
7
+ from dataclasses import dataclass, field
8
+ from typing import Any, Callable
9
+
10
+ from iac_code.types.permissions import PermissionDecision, PermissionMode
11
+
12
+ _PERMISSION_CACHE_MAX_SIZE = 128
13
+
14
+
15
+ def lookup_permission(
16
+ cache: OrderedDict[str, PermissionDecision] | None,
17
+ tool_name: str,
18
+ ) -> PermissionDecision | None:
19
+ """Look up a sticky permission decision and mark it as most-recent.
20
+
21
+ Returns None when the cache is missing or the tool has no recorded
22
+ decision. When found, the entry is moved to the end so LRU eviction
23
+ preserves recency-of-use.
24
+ """
25
+ if cache is None:
26
+ return None
27
+ decision = cache.get(tool_name)
28
+ if decision is None:
29
+ return None
30
+ cache.move_to_end(tool_name)
31
+ return decision
32
+
33
+
34
+ def record_permission(
35
+ cache: OrderedDict[str, PermissionDecision] | None,
36
+ tool_name: str,
37
+ decision: PermissionDecision,
38
+ ) -> None:
39
+ """Record a sticky permission decision, enforcing the LRU size cap."""
40
+ if cache is None:
41
+ return
42
+ cache[tool_name] = decision
43
+ cache.move_to_end(tool_name)
44
+ _evict_lru(cache, _PERMISSION_CACHE_MAX_SIZE)
45
+
46
+
47
+ def _evict_lru(
48
+ cache: OrderedDict[str, PermissionDecision],
49
+ max_size: int,
50
+ ) -> None:
51
+ """Drop oldest entries until ``cache`` fits within ``max_size``."""
52
+ while len(cache) > max_size:
53
+ cache.popitem(last=False)
54
+
55
+
56
+ @dataclass
57
+ class AppState:
58
+ """Global application state."""
59
+
60
+ model: str = ""
61
+ cwd: str = field(default_factory=os.getcwd)
62
+ permission_mode: PermissionMode = PermissionMode.DEFAULT
63
+ messages: list = field(default_factory=list) # list[Message]
64
+ is_busy: bool = False
65
+ always_allow_rules: OrderedDict[str, PermissionDecision] = field(default_factory=OrderedDict)
66
+ spinner_text: str = ""
67
+ context_usage_percent: float = 0.0
68
+ effort_level: Any | None = None # EffortLevel enum or None (avoid circular import)
69
+
70
+
71
+ class AppStateStore:
72
+ """State store.
73
+
74
+ Provides get/set/subscribe interfaces, UI components listen to state changes via subscribe.
75
+ """
76
+
77
+ def __init__(self, initial_state: AppState | None = None) -> None:
78
+ self._state = initial_state or AppState()
79
+ self._listeners: list[Callable[[AppState], None]] = []
80
+
81
+ def get_state(self) -> AppState:
82
+ """Get current state"""
83
+ return self._state
84
+
85
+ def set_state(self, updater: Callable[[AppState], AppState] | None = None, **kwargs) -> None:
86
+ """Update state
87
+
88
+ Two usage patterns:
89
+ 1. store.set_state(lambda s: dataclasses.replace(s, is_busy=True))
90
+ 2. store.set_state(is_busy=True) # Shortcut
91
+ """
92
+ import dataclasses
93
+
94
+ if updater is not None:
95
+ self._state = updater(self._state)
96
+ elif kwargs:
97
+ self._state = dataclasses.replace(self._state, **kwargs)
98
+ self._notify()
99
+
100
+ def subscribe(self, listener: Callable[[AppState], None]) -> Callable[[], None]:
101
+ """Subscribe to state changes, return unsubscribe function"""
102
+ self._listeners.append(listener)
103
+
104
+ def unsubscribe():
105
+ if listener in self._listeners:
106
+ self._listeners.remove(listener)
107
+
108
+ return unsubscribe
109
+
110
+ def _notify(self) -> None:
111
+ """Notify all listeners"""
112
+ for listener in self._listeners:
113
+ listener(self._state)
114
+
115
+
116
+ __all__ = [
117
+ "AppState",
118
+ "AppStateStore",
119
+ "_PERMISSION_CACHE_MAX_SIZE",
120
+ "lookup_permission",
121
+ "record_permission",
122
+ ]
@@ -0,0 +1 @@
1
+ # Task management package.
@@ -0,0 +1,28 @@
1
+ """Notification queue for background agent completion events."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import deque
6
+ from dataclasses import dataclass
7
+
8
+
9
+ @dataclass
10
+ class TaskNotification:
11
+ task_id: str
12
+ message: str
13
+
14
+
15
+ class NotificationQueue:
16
+ def __init__(self, max_pending: int = 100):
17
+ self._queue: deque[TaskNotification] = deque(maxlen=max_pending)
18
+
19
+ def enqueue(self, task_id: str, message: str) -> None:
20
+ self._queue.append(TaskNotification(task_id=task_id, message=message))
21
+
22
+ def dequeue(self) -> TaskNotification | None:
23
+ if self._queue:
24
+ return self._queue.popleft()
25
+ return None
26
+
27
+ def has_pending(self) -> bool:
28
+ return len(self._queue) > 0