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