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,101 @@
1
+ # Terraform 模板最佳实践
2
+
3
+ ## 文件组织
4
+
5
+ | 规模 | 组织方式 |
6
+ |------|----------|
7
+ | < 10 资源 | 单 `main.tf` |
8
+ | 10-20 资源 | `provider.tf` + `variables.tf` + `main.tf` + `outputs.tf` |
9
+ | > 20 资源 | 按资源类型拆分:`vpc.tf`、`ecs.tf`、`rds.tf` 等 |
10
+
11
+ ## 变量管理
12
+
13
+ ### 使用 validation 约束输入
14
+
15
+ ```hcl
16
+ variable "env" {
17
+ type = string
18
+ description = "环境标识"
19
+ validation {
20
+ condition = contains(["dev", "staging", "prod"], var.env)
21
+ error_message = "env 必须为 dev、staging 或 prod"
22
+ }
23
+ }
24
+ ```
25
+
26
+ ### 使用 locals 减少重复
27
+
28
+ ```hcl
29
+ locals {
30
+ name_prefix = "${var.env}-${var.project}"
31
+ common_tags = {
32
+ Environment = var.env
33
+ ManagedBy = "terraform"
34
+ }
35
+ }
36
+ ```
37
+
38
+ ## Data Source
39
+
40
+ 使用 data source 查询动态信息,避免硬编码:
41
+
42
+ ```hcl
43
+ data "alicloud_zones" "available" {
44
+ available_resource_creation = "VSwitch"
45
+ }
46
+
47
+ data "alicloud_images" "ubuntu" {
48
+ name_regex = "^ubuntu_22"
49
+ owners = "system"
50
+ most_recent = true
51
+ }
52
+ ```
53
+
54
+ ## 命名约定
55
+
56
+ - 资源名:`{env}-{product}-{role}`,如 `prod-ecs-web`
57
+ - 变量名:蛇形命名,如 `instance_type`
58
+ - 资源标识符:用途而非类型,如 `alicloud_instance.web` 而非 `alicloud_instance.instance1`
59
+
60
+ ## 执行公共命令
61
+
62
+ `alicloud_ecs_commands` data source 查出 command_id,再用 `alicloud_ecs_invocation` 执行
63
+
64
+ ```hcl
65
+ # 公共命令:先查 command_id
66
+ data "alicloud_ecs_commands" "install_openclaw" {
67
+ name = "ACS-ECS-InstallOpenClaw-for-linux.sh"
68
+ command_provider = "AlibabaCloud"
69
+ }
70
+
71
+ # 再执行
72
+ resource "alicloud_ecs_invocation" "install_openclaw" {
73
+ command_id = data.alicloud_ecs_commands.install_openclaw.commands.0.id
74
+ instance_id = [alicloud_instance.web.id]
75
+ }
76
+ ```
77
+
78
+ ## 与 ROS 集成
79
+
80
+ ROS 支持通过 Terraform 类型模板部署。流程:
81
+
82
+ 1. 生成 Terraform 文件时,按 [template-parameters.md](template-parameters.md) 的「Terraform 模板的参数规范」:
83
+ - 变量 description 中写入 AssociationProperty、Label 等(JSON 格式)
84
+ - 在 tf 目录下创建 `.metadata` 文件定义 ParameterGroups
85
+ 2. 运行 `python ../scripts/tf2ros.py <terraform_dir> <output.yml>` 生成 ROS 模板文件
86
+ - 递归打包目录下所有文件(`.tf`、`.metadata`、`scripts/*.sh`、子目录任意文本文件等)到 Workspace
87
+ - 自动跳过 `.terraform/`、`.git/`、`__pycache__/` 目录及 `*.tfstate*` 文件
88
+ 3. 读取模板文件内容,调用 ros_stack(CreateStack) 部署
89
+
90
+ ## 文件函数的 path 参数限制(重要)
91
+
92
+ `file`、`fileexists`、`fileset`、`filebase64` 的 path 参数受 ROS 严格校验,生成模板时必须遵守:
93
+
94
+ - 必须是字面量字符串,不能引用变量
95
+ - 第一个分词必须是 `${path.module}`、`${path.root}`、`${path.cwd}` 或 `${terraform.workspace}` 之一
96
+ - 后续分词只允许字母、数字与 `-_.`,不允许为空、`.` 或 `..`(即不能用 `../` 跳出目录)
97
+ - 引用的文件必须存在于 Workspace 中(即在传给 tf2ros.py 的目录里)
98
+
99
+ ## 其他模板结构限制
100
+
101
+ Terraform 类型模板的其他结构性约束(provider 白名单、不支持的语法、变量类型等)以官方文档为准:<https://help.aliyun.com/zh/ros/user-guide/structure-of-terraform-templates>。ValidateTemplate 报错若指向模板结构问题,按报错信息对照该文档定位修复点。
@@ -0,0 +1,77 @@
1
+ #!/usr/bin/env python3
2
+ """Convert a Terraform directory to a ROS Terraform-type template file."""
3
+
4
+ import json
5
+ import os
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ import yaml
10
+
11
+
12
+ class _BlockStr(str):
13
+ """String subclass that forces YAML block scalar (|) style."""
14
+
15
+
16
+ def _block_str_representer(dumper: yaml.Dumper, data: _BlockStr) -> yaml.ScalarNode:
17
+ return dumper.represent_scalar("tag:yaml.org,2002:str", data, style="|")
18
+
19
+
20
+ yaml.add_representer(_BlockStr, _block_str_representer)
21
+
22
+
23
+ # Directories that must never be bundled into Workspace.
24
+ _SKIP_DIRS = {".terraform", ".git", "__pycache__"}
25
+ # File suffixes that must never be bundled into Workspace.
26
+ _SKIP_SUFFIXES = (".tfstate", ".tfstate.backup")
27
+
28
+
29
+ def convert(tf_dir: str, output_path: str) -> None:
30
+ tf_path = Path(tf_dir)
31
+ if not tf_path.is_dir():
32
+ print(f"Error: {tf_dir} is not a directory", file=sys.stderr)
33
+ sys.exit(1)
34
+
35
+ rel_paths: list[Path] = []
36
+ for root, dirs, files in os.walk(tf_path):
37
+ dirs[:] = [d for d in dirs if d not in _SKIP_DIRS]
38
+ for fname in files:
39
+ if fname.endswith(_SKIP_SUFFIXES):
40
+ continue
41
+ rel_paths.append(Path(root, fname).relative_to(tf_path))
42
+
43
+ workspace: dict[str, _BlockStr] = {}
44
+ for rel in sorted(rel_paths):
45
+ full = tf_path / rel
46
+ try:
47
+ text = full.read_text()
48
+ except UnicodeDecodeError:
49
+ print(f"Warning: skipping non-text file {rel.as_posix()}", file=sys.stderr)
50
+ continue
51
+ workspace[rel.as_posix()] = _BlockStr(text)
52
+
53
+ if not any(k.endswith(".tf") for k in workspace):
54
+ print(f"Error: no .tf files found in {tf_dir}", file=sys.stderr)
55
+ sys.exit(1)
56
+
57
+ template: dict = {
58
+ "ROSTemplateFormatVersion": "2015-09-01",
59
+ "Transform": "Aliyun::OpenTofu-v1.8",
60
+ "Workspace": workspace,
61
+ }
62
+
63
+ out = Path(output_path)
64
+ if out.suffix in (".yml", ".yaml"):
65
+ out.write_text(yaml.dump(template, allow_unicode=True, default_flow_style=False, sort_keys=False))
66
+ else:
67
+ out.write_text(json.dumps(template, ensure_ascii=False, indent=2))
68
+
69
+ print(f"Template written to {out}")
70
+
71
+
72
+ if __name__ == "__main__":
73
+ if len(sys.argv) != 3:
74
+ print(f"Usage: {sys.argv[0]} <terraform_dir> <output_file>", file=sys.stderr)
75
+ print(" output_file: .yml/.yaml for YAML, .json for JSON", file=sys.stderr)
76
+ sys.exit(1)
77
+ convert(sys.argv[1], sys.argv[2])
@@ -0,0 +1,28 @@
1
+ """Built-in simplify skill — review changed code for reuse, quality, and efficiency."""
2
+
3
+ from iac_code.i18n import _
4
+ from iac_code.skills.bundled import register_bundled_skill
5
+
6
+ SIMPLIFY_PROMPT = """\
7
+ Review the recently changed code for:
8
+
9
+ 1. **Reuse** — Are there existing functions, utilities, or patterns in the codebase that \
10
+ could replace newly added code? Search broadly.
11
+ 2. **Quality** — Are there bugs, edge cases, or logic errors?
12
+ 3. **Efficiency** — Can the code be simplified without losing clarity?
13
+
14
+ For each issue found:
15
+ - Explain the problem
16
+ - Show the fix (edit the file directly)
17
+
18
+ If no issues are found, say so briefly.
19
+ """
20
+
21
+
22
+ def register_simplify_skill() -> None:
23
+ register_bundled_skill(
24
+ name="simplify",
25
+ description=_("Review changed code for reuse, quality, and efficiency, then fix issues found."),
26
+ prompt=SIMPLIFY_PROMPT,
27
+ user_invocable=True,
28
+ )
@@ -0,0 +1,136 @@
1
+ """Skill discovery — scan filesystem sources and convert to PromptCommands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import fnmatch
6
+ from pathlib import Path
7
+
8
+ from iac_code.commands.registry import PromptCommand
9
+ from iac_code.skills.loader import load_skill_from_path
10
+ from iac_code.skills.skill_definition import SkillDefinition
11
+ from iac_code.types.skill_source import SkillSource
12
+
13
+
14
+ def discover_all_skills(cwd: str) -> list[SkillDefinition]:
15
+ """Discover all available skills from all sources.
16
+
17
+ Load order (later entries override earlier with same name):
18
+ 1. Bundled skills
19
+ 2. User global skills (~/.iac-code/skills/)
20
+ 3. Project local skills — skills/ (lower priority)
21
+ 4. Project local skills — .iac-code/skills/ (higher priority, overrides same name)
22
+ """
23
+ from iac_code.skills.bundled import get_bundled_skills
24
+
25
+ skills: dict[str, SkillDefinition] = {}
26
+
27
+ # 1. Bundled skills
28
+ for skill in get_bundled_skills():
29
+ skills[skill.name] = skill
30
+
31
+ # 2. User global skills
32
+ user_skills_dir = Path.home() / ".iac-code" / "skills"
33
+ for skill in _scan_skills_dir(user_skills_dir):
34
+ skill.source = SkillSource.USER
35
+ skills[skill.name] = skill
36
+
37
+ # 3. Project local skills (two locations, .iac-code/skills/ has higher priority)
38
+ for project_dir in _find_project_skills_dirs(cwd):
39
+ for skill in _scan_skills_dir(project_dir):
40
+ skill.source = SkillSource.PROJECT
41
+ skills[skill.name] = skill
42
+
43
+ return list(skills.values())
44
+
45
+
46
+ def _find_project_skills_dirs(cwd: str) -> list[Path]:
47
+ """Find project skills directories, searching up from cwd.
48
+
49
+ Returns directories in priority order (low -> high):
50
+ - skills/ (root-level, lower priority)
51
+ - .iac-code/skills/ (config-level, higher priority)
52
+ """
53
+ result: list[Path] = []
54
+ current = Path(cwd).resolve()
55
+ while True:
56
+ # Lower priority: skills/
57
+ bare = current / "skills"
58
+ if bare.is_dir():
59
+ result.append(bare)
60
+ # Higher priority: .iac-code/skills/
61
+ dotdir = current / ".iac-code" / "skills"
62
+ if dotdir.is_dir():
63
+ result.append(dotdir)
64
+ parent = current.parent
65
+ if parent == current:
66
+ break
67
+ current = parent
68
+ return result
69
+
70
+
71
+ def _scan_skills_dir(skills_dir: Path) -> list[SkillDefinition]:
72
+ """Scan a skills directory for skill files."""
73
+ if not skills_dir.is_dir():
74
+ return []
75
+
76
+ skills: list[SkillDefinition] = []
77
+ seen_real_paths: set[str] = set()
78
+
79
+ for entry in skills_dir.iterdir():
80
+ real_path = str(entry.resolve())
81
+ if real_path in seen_real_paths:
82
+ continue
83
+ seen_real_paths.add(real_path)
84
+
85
+ if entry.is_dir():
86
+ # Directory format: skill-name/SKILL.md
87
+ skill_file = entry / "SKILL.md"
88
+ if skill_file.is_file():
89
+ skill = load_skill_from_path(skill_file, skill_name=entry.name)
90
+ if skill:
91
+ skill.skill_root = str(entry)
92
+ skills.append(skill)
93
+ elif entry.suffix == ".md" and entry.name != "SKILL.md":
94
+ # Single file format: skill-name.md
95
+ skill = load_skill_from_path(entry, skill_name=entry.stem)
96
+ if skill:
97
+ skills.append(skill)
98
+
99
+ return skills
100
+
101
+
102
+ def skill_to_command(skill: SkillDefinition) -> PromptCommand:
103
+ """Convert a SkillDefinition to a PromptCommand."""
104
+ return PromptCommand(
105
+ name=skill.name,
106
+ description=skill.description,
107
+ skill=skill,
108
+ source=skill.source,
109
+ )
110
+
111
+
112
+ class DynamicSkillTracker:
113
+ """Tracks file access and dynamically activates path-matched skills."""
114
+
115
+ def __init__(self) -> None:
116
+ self._accessed_paths: set[str] = set()
117
+ self._activated_skills: dict[str, SkillDefinition] = {}
118
+
119
+ def on_file_accessed(self, file_path: str, all_skills: list[SkillDefinition]) -> None:
120
+ """Called when a file is accessed by a tool. Checks path-matched skills."""
121
+ self._accessed_paths.add(file_path)
122
+ for skill in all_skills:
123
+ if skill.name in self._activated_skills:
124
+ continue
125
+ if skill.frontmatter.paths and self._matches_any_pattern(file_path, skill.frontmatter.paths):
126
+ self._activated_skills[skill.name] = skill
127
+
128
+ def get_activated_skills(self) -> list[SkillDefinition]:
129
+ return list(self._activated_skills.values())
130
+
131
+ @staticmethod
132
+ def _matches_any_pattern(file_path: str, patterns: list[str]) -> bool:
133
+ for pattern in patterns:
134
+ if fnmatch.fnmatch(file_path, pattern):
135
+ return True
136
+ return False
@@ -0,0 +1,119 @@
1
+ """YAML Frontmatter parsing for skill markdown files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass, field
7
+ from typing import Any
8
+
9
+ import yaml
10
+
11
+ FRONTMATTER_REGEX = re.compile(r"^---\s*\n([\s\S]*?)---\s*\n?")
12
+
13
+ # YAML special characters that need auto-quoting
14
+ YAML_SPECIAL_CHARS = set("{}[]*&#!|>%@")
15
+
16
+
17
+ @dataclass
18
+ class SkillFrontmatter:
19
+ """Parsed skill frontmatter metadata."""
20
+
21
+ name: str = ""
22
+ description: str = ""
23
+ descriptions: dict[str, str] = field(default_factory=dict)
24
+ allowed_tools: list[str] = field(default_factory=list)
25
+ when_to_use: str = ""
26
+ argument_hint: str = ""
27
+ arguments: list[str] = field(default_factory=list)
28
+ user_invocable: bool = True
29
+ model: str = "inherit"
30
+ effort: str = ""
31
+ context: str = "inline" # "inline" | "fork"
32
+ agent: str = "general-purpose"
33
+ paths: list[str] = field(default_factory=list)
34
+
35
+
36
+ def parse_frontmatter(markdown: str) -> tuple[SkillFrontmatter, str]:
37
+ """Parse YAML frontmatter from a skill markdown file.
38
+
39
+ Returns:
40
+ (frontmatter, content) - parsed metadata and remaining markdown body.
41
+ """
42
+ match = FRONTMATTER_REGEX.match(markdown)
43
+ if not match:
44
+ return SkillFrontmatter(), markdown
45
+
46
+ frontmatter_text = match.group(1)
47
+ content = markdown[match.end() :]
48
+
49
+ # First parse attempt
50
+ data = _parse_yaml_safe(frontmatter_text)
51
+
52
+ # If failed, auto-quote problematic values and retry
53
+ if data is None:
54
+ quoted_text = _quote_problematic_values(frontmatter_text)
55
+ data = _parse_yaml_safe(quoted_text)
56
+
57
+ if data is None:
58
+ return SkillFrontmatter(), content
59
+
60
+ return _data_to_frontmatter(data), content
61
+
62
+
63
+ def _parse_yaml_safe(text: str) -> dict[str, Any] | None:
64
+ """Safely parse YAML text, returning None on failure."""
65
+ try:
66
+ result = yaml.safe_load(text)
67
+ return result if isinstance(result, dict) else None
68
+ except yaml.YAMLError:
69
+ return None
70
+
71
+
72
+ def _quote_problematic_values(text: str) -> str:
73
+ """Auto-quote values containing special YAML characters (e.g., glob patterns)."""
74
+ lines = []
75
+ for line in text.split("\n"):
76
+ if ":" in line and not line.strip().startswith("-"):
77
+ key, _, value = line.partition(":")
78
+ value = value.strip()
79
+ if (
80
+ value
81
+ and any(c in value for c in YAML_SPECIAL_CHARS)
82
+ and not (value.startswith('"') or value.startswith("'"))
83
+ ):
84
+ line = f'{key}: "{value}"'
85
+ lines.append(line)
86
+ return "\n".join(lines)
87
+
88
+
89
+ def _data_to_frontmatter(data: dict[str, Any]) -> SkillFrontmatter:
90
+ """Convert raw YAML dict to SkillFrontmatter dataclass."""
91
+ from iac_code.i18n import get_current_language
92
+
93
+ fm = SkillFrontmatter()
94
+ fm.name = data.get("name", "")
95
+ fm.description = data.get("description", "")
96
+
97
+ # Support localized descriptions via "descriptions" dict
98
+ descriptions = data.get("descriptions")
99
+ if isinstance(descriptions, dict):
100
+ fm.descriptions = {str(k): str(v) for k, v in descriptions.items()}
101
+ lang = get_current_language()
102
+ if lang in fm.descriptions:
103
+ fm.description = fm.descriptions[lang]
104
+
105
+ # allowed_tools: supports str or list[str]
106
+ tools = data.get("allowed_tools", [])
107
+ fm.allowed_tools = [tools] if isinstance(tools, str) else list(tools or [])
108
+
109
+ fm.when_to_use = data.get("when_to_use", "")
110
+ fm.argument_hint = data.get("argument_hint", "")
111
+ fm.arguments = list(data.get("arguments", []))
112
+ fm.user_invocable = data.get("user_invocable", True)
113
+ fm.model = data.get("model", "inherit")
114
+ fm.effort = data.get("effort", "")
115
+ fm.context = data.get("context", "inline")
116
+ fm.agent = data.get("agent", "general-purpose")
117
+ fm.paths = list(data.get("paths", []))
118
+
119
+ return fm
@@ -0,0 +1,92 @@
1
+ """Skill listing generation for system prompt injection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from iac_code.types.skill_source import SkillSource
8
+
9
+ if TYPE_CHECKING:
10
+ from iac_code.commands.registry import PromptCommand
11
+
12
+ SKILL_BUDGET_CONTEXT_PERCENT = 0.01
13
+ MAX_LISTING_DESC_CHARS = 250
14
+ DEFAULT_CHAR_BUDGET = 8_000 # 200K * 4 chars/token * 1%
15
+
16
+
17
+ def get_char_budget(context_window_tokens: int | None = None) -> int:
18
+ if context_window_tokens:
19
+ return int(context_window_tokens * 4 * SKILL_BUDGET_CONTEXT_PERCENT)
20
+ return DEFAULT_CHAR_BUDGET
21
+
22
+
23
+ def build_skill_listing(
24
+ skills: list[PromptCommand],
25
+ context_window_tokens: int | None = None,
26
+ ) -> str:
27
+ """Build the skill listing string for injection into the system prompt.
28
+
29
+ Truncation strategy:
30
+ 1. Try full description + when_to_use for all skills
31
+ 2. If over budget: bundled skills keep full description; others truncated proportionally
32
+ 3. Extreme case: other skills show name only
33
+ """
34
+ if not skills:
35
+ return ""
36
+
37
+ budget = get_char_budget(context_window_tokens)
38
+
39
+ # Separate bundled and other skills
40
+ bundled = [s for s in skills if s.source == SkillSource.BUNDLED]
41
+ others = [s for s in skills if s.source != SkillSource.BUNDLED]
42
+
43
+ # Try full descriptions
44
+ lines = _format_full(bundled + others)
45
+ total = sum(len(line) for line in lines)
46
+
47
+ if total <= budget:
48
+ return _assemble(lines)
49
+
50
+ # Over budget: bundled keep full, others get proportional budget
51
+ bundled_lines = _format_full(bundled)
52
+ bundled_cost = sum(len(line) for line in bundled_lines)
53
+ remaining_budget = budget - bundled_cost
54
+
55
+ if remaining_budget <= 0:
56
+ other_lines = [f"- {s.name}" for s in others]
57
+ else:
58
+ per_skill_budget = remaining_budget // max(len(others), 1)
59
+ other_lines = _format_truncated(others, per_skill_budget)
60
+
61
+ return _assemble(bundled_lines + other_lines)
62
+
63
+
64
+ def _format_full(skills: list[PromptCommand]) -> list[str]:
65
+ lines = []
66
+ for s in skills:
67
+ desc = s.description
68
+ if s.when_to_use:
69
+ desc += f"\n{s.when_to_use}"
70
+ if len(desc) > MAX_LISTING_DESC_CHARS:
71
+ desc = desc[: MAX_LISTING_DESC_CHARS - 3] + "..."
72
+ lines.append(f"- {s.name}: {desc}")
73
+ return lines
74
+
75
+
76
+ def _format_truncated(skills: list[PromptCommand], per_skill_budget: int) -> list[str]:
77
+ lines = []
78
+ for s in skills:
79
+ desc = s.description
80
+ max_desc = per_skill_budget - len(s.name) - 4 # "- name: "
81
+ if max_desc <= 0:
82
+ lines.append(f"- {s.name}")
83
+ elif len(desc) > max_desc:
84
+ lines.append(f"- {s.name}: {desc[: max_desc - 3]}...")
85
+ else:
86
+ lines.append(f"- {s.name}: {desc}")
87
+ return lines
88
+
89
+
90
+ def _assemble(lines: list[str]) -> str:
91
+ header = "The following skills are available for use with the Skill tool:\n"
92
+ return header + "\n".join(lines)
@@ -0,0 +1,42 @@
1
+ """Load SkillDefinition from disk."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from loguru import logger
8
+
9
+ from iac_code.skills.frontmatter import parse_frontmatter
10
+ from iac_code.skills.skill_definition import SkillDefinition
11
+
12
+
13
+ def load_skill_from_path(file_path: Path, skill_name: str) -> SkillDefinition | None:
14
+ """Load a skill from a markdown file.
15
+
16
+ Args:
17
+ file_path: Path to the .md file.
18
+ skill_name: Default name (from directory or filename).
19
+
20
+ Returns:
21
+ SkillDefinition or None if the file cannot be read.
22
+ """
23
+ try:
24
+ text = file_path.read_text(encoding="utf-8")
25
+ except (OSError, UnicodeDecodeError) as e:
26
+ logger.warning("Failed to read skill file %s: %s", file_path, e)
27
+ return None
28
+
29
+ frontmatter, content = parse_frontmatter(text)
30
+
31
+ # Use frontmatter name if provided, otherwise use the filesystem-derived name
32
+ name = frontmatter.name or skill_name
33
+ description = frontmatter.description or ""
34
+
35
+ return SkillDefinition(
36
+ name=name,
37
+ description=description,
38
+ frontmatter=frontmatter,
39
+ content=content,
40
+ file_path=str(file_path),
41
+ content_length=len(content),
42
+ )
@@ -0,0 +1,81 @@
1
+ """Unified skill processing — shared by slash commands and SkillTool."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass
7
+ from typing import Any, Callable
8
+
9
+ from iac_code.skills.skill_definition import SkillContext
10
+
11
+
12
+ @dataclass
13
+ class ProcessedSkillResult:
14
+ """Unified result of processing a skill (from either slash command or SkillTool).
15
+
16
+ Both execution paths produce this same structure, ensuring identical behavior.
17
+ """
18
+
19
+ prompt_content: str
20
+ skill_name: str
21
+ new_messages: list[dict[str, Any]]
22
+ context_modifier: Callable[[dict], dict] | None = None
23
+ is_fork: bool = False
24
+
25
+
26
+ async def process_prompt_command(
27
+ command: Any,
28
+ args: str,
29
+ *,
30
+ session_id: str = "",
31
+ ) -> ProcessedSkillResult:
32
+ """Unified skill processing — called by BOTH slash commands AND SkillTool.
33
+
34
+ 1. Render skill prompt (argument substitution + variable replacement + shell execution)
35
+ 2. Wrap as <skill-name> tagged message
36
+ 3. Build context_modifier (allowed_tools / model / effort)
37
+ """
38
+ skill = command.skill
39
+ if skill is None:
40
+ raise ValueError(f"Command '{command.name}' is not a skill")
41
+
42
+ skill_context = SkillContext(
43
+ cwd=os.getcwd(),
44
+ session_id=session_id,
45
+ skill_dir=skill.skill_root or "",
46
+ skill_root=skill.skill_root or "",
47
+ )
48
+
49
+ # Step 1: Render prompt
50
+ prompt_content = await skill.get_prompt(args, skill_context)
51
+
52
+ # Step 2: Wrap as tagged message
53
+ tagged_content = f"<skill-name>{command.name}</skill-name>\n\n{prompt_content}"
54
+ new_messages = [{"role": "user", "content": tagged_content}]
55
+
56
+ # Step 3: Build context_modifier
57
+ allowed_tools = skill.allowed_tools
58
+ model_override = skill.model_override
59
+ effort_override = skill.effort_override
60
+
61
+ context_modifier = None
62
+ if allowed_tools or (model_override and model_override != "inherit") or effort_override:
63
+
64
+ def context_modifier(ctx: dict) -> dict:
65
+ modified = {**ctx}
66
+ if allowed_tools:
67
+ existing = modified.get("allowed_tool_rules", [])
68
+ modified["allowed_tool_rules"] = existing + allowed_tools
69
+ if model_override and model_override != "inherit":
70
+ modified["model_override"] = model_override
71
+ if effort_override:
72
+ modified["effort_override"] = effort_override
73
+ return modified
74
+
75
+ return ProcessedSkillResult(
76
+ prompt_content=prompt_content,
77
+ skill_name=command.name,
78
+ new_messages=new_messages,
79
+ context_modifier=context_modifier,
80
+ is_fork=(skill.context_mode == "fork"),
81
+ )