vtx-coding-agent 0.1.1__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 (117) hide show
  1. vtx/__init__.py +63 -0
  2. vtx/async_utils.py +40 -0
  3. vtx/builtin_skills/github/SKILL.md +139 -0
  4. vtx/builtin_skills/init/SKILL.md +74 -0
  5. vtx/builtin_skills/review/SKILL.md +73 -0
  6. vtx/builtin_skills/skill-builder/SKILL.md +133 -0
  7. vtx/cli.py +90 -0
  8. vtx/config.py +741 -0
  9. vtx/context/__init__.py +15 -0
  10. vtx/context/_xml.py +8 -0
  11. vtx/context/agent_mds.py +128 -0
  12. vtx/context/git.py +64 -0
  13. vtx/context/loader.py +41 -0
  14. vtx/context/skills.py +423 -0
  15. vtx/core/__init__.py +47 -0
  16. vtx/core/compaction.py +89 -0
  17. vtx/core/errors.py +17 -0
  18. vtx/core/handoff.py +51 -0
  19. vtx/core/scratchpad.py +54 -0
  20. vtx/core/types.py +197 -0
  21. vtx/defaults/__init__.py +0 -0
  22. vtx/defaults/config.yml +53 -0
  23. vtx/diff_display.py +12 -0
  24. vtx/events.py +224 -0
  25. vtx/gh_cli.py +82 -0
  26. vtx/git_branch.py +90 -0
  27. vtx/headless.py +127 -0
  28. vtx/llm/__init__.py +93 -0
  29. vtx/llm/base.py +217 -0
  30. vtx/llm/context_length.py +150 -0
  31. vtx/llm/dynamic_models.py +735 -0
  32. vtx/llm/model_fetcher.py +279 -0
  33. vtx/llm/models.py +78 -0
  34. vtx/llm/oauth/__init__.py +59 -0
  35. vtx/llm/oauth/copilot.py +358 -0
  36. vtx/llm/oauth/dynamic.py +236 -0
  37. vtx/llm/oauth/openai.py +400 -0
  38. vtx/llm/phase_parser.py +270 -0
  39. vtx/llm/provider.yaml +280 -0
  40. vtx/llm/provider_catalog.py +230 -0
  41. vtx/llm/providers/__init__.py +45 -0
  42. vtx/llm/providers/anthropic_sdk.py +256 -0
  43. vtx/llm/providers/mock.py +249 -0
  44. vtx/llm/providers/openai_sdk.py +246 -0
  45. vtx/llm/providers/sanitize.py +14 -0
  46. vtx/llm/sdk/__init__.py +13 -0
  47. vtx/llm/sdk/anthropic.py +382 -0
  48. vtx/llm/sdk/base.py +82 -0
  49. vtx/llm/sdk/openai.py +344 -0
  50. vtx/llm/tool_parser.py +161 -0
  51. vtx/loop.py +272 -0
  52. vtx/notify.py +109 -0
  53. vtx/permissions.py +114 -0
  54. vtx/prompts/__init__.py +45 -0
  55. vtx/prompts/builder.py +86 -0
  56. vtx/prompts/env.py +58 -0
  57. vtx/prompts/identity.py +166 -0
  58. vtx/prompts/tooling.py +36 -0
  59. vtx/py.typed +0 -0
  60. vtx/runtime.py +580 -0
  61. vtx/session.py +868 -0
  62. vtx/sounds/completion.wav +0 -0
  63. vtx/sounds/error.wav +0 -0
  64. vtx/sounds/permission.wav +0 -0
  65. vtx/themes.py +1104 -0
  66. vtx/tools/__init__.py +68 -0
  67. vtx/tools/_read_image.py +106 -0
  68. vtx/tools/_tool_utils.py +90 -0
  69. vtx/tools/base.py +36 -0
  70. vtx/tools/bash.py +371 -0
  71. vtx/tools/edit.py +261 -0
  72. vtx/tools/find.py +132 -0
  73. vtx/tools/read.py +238 -0
  74. vtx/tools/skill.py +278 -0
  75. vtx/tools/web.py +238 -0
  76. vtx/tools/write.py +88 -0
  77. vtx/tools_manager.py +216 -0
  78. vtx/turn.py +789 -0
  79. vtx/ui/__init__.py +0 -0
  80. vtx/ui/agent_runner.py +417 -0
  81. vtx/ui/app.py +665 -0
  82. vtx/ui/app_protocol.py +29 -0
  83. vtx/ui/autocomplete.py +440 -0
  84. vtx/ui/blocks.py +735 -0
  85. vtx/ui/chat.py +613 -0
  86. vtx/ui/clipboard.py +59 -0
  87. vtx/ui/commands/__init__.py +100 -0
  88. vtx/ui/commands/auth.py +306 -0
  89. vtx/ui/commands/base.py +122 -0
  90. vtx/ui/commands/models.py +144 -0
  91. vtx/ui/commands/sessions.py +388 -0
  92. vtx/ui/commands/settings.py +286 -0
  93. vtx/ui/completion_ui.py +313 -0
  94. vtx/ui/export.py +703 -0
  95. vtx/ui/floating_list.py +370 -0
  96. vtx/ui/formatting.py +287 -0
  97. vtx/ui/input.py +760 -0
  98. vtx/ui/latex.py +349 -0
  99. vtx/ui/launch.py +108 -0
  100. vtx/ui/path_complete.py +228 -0
  101. vtx/ui/prompt_history.py +102 -0
  102. vtx/ui/queue_ui.py +141 -0
  103. vtx/ui/selection_mode.py +18 -0
  104. vtx/ui/session_ui.py +235 -0
  105. vtx/ui/startup.py +124 -0
  106. vtx/ui/styles.py +327 -0
  107. vtx/ui/tool_output.py +34 -0
  108. vtx/ui/tree.py +437 -0
  109. vtx/ui/welcome.py +51 -0
  110. vtx/ui/widgets.py +558 -0
  111. vtx/update_check.py +49 -0
  112. vtx/version.py +22 -0
  113. vtx_coding_agent-0.1.1.dist-info/METADATA +259 -0
  114. vtx_coding_agent-0.1.1.dist-info/RECORD +117 -0
  115. vtx_coding_agent-0.1.1.dist-info/WHEEL +4 -0
  116. vtx_coding_agent-0.1.1.dist-info/entry_points.txt +2 -0
  117. vtx_coding_agent-0.1.1.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,15 @@
1
+ from .agent_mds import ContextFile, formatted_agent_mds, load_agent_mds
2
+ from .git import formatted_git_context
3
+ from .loader import Context
4
+ from .skills import Skill, formatted_skills, load_skills
5
+
6
+ __all__ = [
7
+ "Context",
8
+ "ContextFile",
9
+ "Skill",
10
+ "formatted_agent_mds",
11
+ "formatted_git_context",
12
+ "formatted_skills",
13
+ "load_agent_mds",
14
+ "load_skills",
15
+ ]
vtx/context/_xml.py ADDED
@@ -0,0 +1,8 @@
1
+ def escape_xml(text: str) -> str:
2
+ return (
3
+ text.replace("&", "&")
4
+ .replace("<", "&lt;")
5
+ .replace(">", "&gt;")
6
+ .replace('"', "&quot;")
7
+ .replace("'", "&apos;")
8
+ )
@@ -0,0 +1,128 @@
1
+ """
2
+ AGENTS.md discovery and loading.
3
+
4
+ Discovers AGENTS.md (or CLAUDE.md) files from:
5
+ 1. Global config dir (~/.vtx/)
6
+ 2. Ancestor directories from cwd up to git root or home directory (closest last)
7
+ """
8
+
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+
12
+ from .. import get_config_dir
13
+ from ._xml import escape_xml
14
+
15
+ CONTEXT_FILE_CANDIDATES = ["AGENTS.md", "CLAUDE.md"]
16
+
17
+
18
+ @dataclass
19
+ class ContextFile:
20
+ path: str
21
+ content: str
22
+
23
+
24
+ def _find_git_root(start: Path) -> Path | None:
25
+ current = start
26
+ while True:
27
+ if (current / ".git").is_dir():
28
+ return current
29
+ parent = current.parent
30
+ if parent == current:
31
+ return None
32
+ current = parent
33
+
34
+
35
+ def _get_stop_directory(cwd: Path) -> Path:
36
+ git_root = _find_git_root(cwd)
37
+ if git_root:
38
+ return git_root
39
+
40
+ home = Path.home()
41
+ try:
42
+ cwd.relative_to(home)
43
+ return home
44
+ except ValueError:
45
+ return cwd
46
+
47
+
48
+ def _load_context_from_dir(directory: Path) -> ContextFile | None:
49
+ for filename in CONTEXT_FILE_CANDIDATES:
50
+ filepath = directory / filename
51
+ if filepath.is_file():
52
+ try:
53
+ content = filepath.read_text(encoding="utf-8")
54
+ return ContextFile(path=str(filepath), content=content)
55
+ except Exception:
56
+ pass
57
+ return None
58
+
59
+
60
+ def load_agent_mds(cwd: str | None = None) -> list[ContextFile]:
61
+ """
62
+ Load all AGENTS.md files from config dir and ancestor directories.
63
+
64
+ Discovery order:
65
+ 1. Global config dir (~/.vtx/) - loaded first
66
+ 2. Ancestor directories from stop dir down to cwd - closest to cwd loaded last
67
+
68
+ Stop directory is determined by:
69
+ - Git root (if cwd is inside a git repository)
70
+ - Home directory (otherwise)
71
+
72
+ This means project-specific instructions appear after global ones.
73
+ """
74
+ resolved_cwd = Path(cwd) if cwd else Path.cwd()
75
+ resolved_cwd = resolved_cwd.resolve()
76
+
77
+ context_files: list[ContextFile] = []
78
+ seen_paths: set[str] = set()
79
+
80
+ # 1. Load from global agents dir
81
+ agents_dir = get_config_dir()
82
+ if agents_dir.exists():
83
+ global_context = _load_context_from_dir(agents_dir)
84
+ if global_context:
85
+ context_files.append(global_context)
86
+ seen_paths.add(global_context.path)
87
+
88
+ # 2. Determine stop directory (git root or home)
89
+ stop_dir = _get_stop_directory(resolved_cwd)
90
+
91
+ # 3. Collect from ancestors (stop_dir to cwd, so closest is last)
92
+ ancestor_files: list[ContextFile] = []
93
+ current = resolved_cwd
94
+
95
+ while True:
96
+ context_file = _load_context_from_dir(current)
97
+ if context_file and context_file.path not in seen_paths:
98
+ ancestor_files.insert(0, context_file)
99
+ seen_paths.add(context_file.path)
100
+ if current == stop_dir:
101
+ break
102
+ current = current.parent
103
+
104
+ context_files.extend(ancestor_files)
105
+
106
+ return context_files
107
+
108
+
109
+ def formatted_agent_mds(agents_files: list[ContextFile]) -> str:
110
+ if not agents_files:
111
+ return ""
112
+
113
+ lines = [
114
+ "# Project Context",
115
+ "",
116
+ "Project guidelines for coding agents.",
117
+ "",
118
+ "<project_guidelines>",
119
+ ]
120
+
121
+ for ctx in agents_files:
122
+ lines.append(f'<file path="{escape_xml(ctx.path)}">')
123
+ lines.append(escape_xml(ctx.content))
124
+ lines.append("</file>")
125
+
126
+ lines.append("</project_guidelines>")
127
+
128
+ return "\n".join(lines)
vtx/context/git.py ADDED
@@ -0,0 +1,64 @@
1
+ import subprocess
2
+
3
+
4
+ def _run_git_command(cwd: str, args: list[str], timeout: int = 5) -> str:
5
+ try:
6
+ result = subprocess.run(
7
+ ["git", *args], cwd=cwd, check=False, capture_output=True, text=True, timeout=timeout
8
+ )
9
+ except Exception:
10
+ return ""
11
+
12
+ if result.returncode != 0:
13
+ return ""
14
+ return result.stdout.strip()
15
+
16
+
17
+ def formatted_git_context(cwd: str) -> str:
18
+ is_git_repo = _run_git_command(cwd, ["rev-parse", "--git-dir"]) != ""
19
+ if not is_git_repo:
20
+ return ""
21
+
22
+ current_branch = _run_git_command(cwd, ["branch", "--show-current"])
23
+
24
+ main_branch = "main"
25
+ remote_head = _run_git_command(cwd, ["symbolic-ref", "refs/remotes/origin/HEAD"])
26
+ if remote_head.startswith("refs/remotes/origin/"):
27
+ main_branch = remote_head.replace("refs/remotes/origin/", "", 1)
28
+ else:
29
+ remote_branches = _run_git_command(cwd, ["branch", "-r"])
30
+ if "origin/master" in remote_branches:
31
+ main_branch = "master"
32
+
33
+ status = _run_git_command(cwd, ["status", "--porcelain"], timeout=10)
34
+ recent_commits = _run_git_command(cwd, ["log", "--oneline", "-5"], timeout=10)
35
+
36
+ sections: list[str] = []
37
+ if current_branch:
38
+ sections.append(f"Current branch: {current_branch}")
39
+ if main_branch:
40
+ sections.append(f"Main branch (you will usually use this for PRs): {main_branch}")
41
+ if status:
42
+ sections.append(f"Status:\n{status}")
43
+ if recent_commits:
44
+ sections.append(f"Recent commits:\n{recent_commits}")
45
+
46
+ if not sections:
47
+ return ""
48
+
49
+ content = "\n\n".join(sections)
50
+ max_chars = 2000
51
+ if len(content) > max_chars:
52
+ content = (
53
+ content[:max_chars] + "\n\n... (truncated because it exceeds 2k characters. "
54
+ 'If you need more information, run "git status" using bash)'
55
+ )
56
+
57
+ return (
58
+ "# Git Context\n\n"
59
+ "This is the git status at the start of the conversation. Note that this "
60
+ "status is a snapshot in time, and will not update during the conversation.\n\n"
61
+ "<git-status>\n"
62
+ f"{content}\n"
63
+ "</git-status>"
64
+ )
vtx/context/loader.py ADDED
@@ -0,0 +1,41 @@
1
+ """
2
+ Context loader - loads and caches AGENTS.md files and skills.
3
+
4
+ This is loaded once at startup and passed to the agent for system prompt building.
5
+ The UI can also access it to display loaded resources.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+
12
+ from .agent_mds import ContextFile, load_agent_mds
13
+ from .skills import Skill, load_skills
14
+
15
+
16
+ @dataclass
17
+ class Context:
18
+ cwd: str
19
+ agents_files: list[ContextFile] = field(default_factory=list)
20
+ skills: list[Skill] = field(default_factory=list)
21
+ skill_warnings: list[tuple[str, str]] = field(default_factory=list)
22
+
23
+ @classmethod
24
+ def load(cls, cwd: str) -> Context:
25
+ agents_files = load_agent_mds(cwd)
26
+ skills_result = load_skills(cwd)
27
+
28
+ return cls(
29
+ cwd=cwd,
30
+ agents_files=agents_files,
31
+ skills=skills_result.skills,
32
+ skill_warnings=[(w.path, w.message) for w in skills_result.warnings],
33
+ )
34
+
35
+ def reload(self) -> None:
36
+ agents_files = load_agent_mds(self.cwd)
37
+ skills_result = load_skills(self.cwd)
38
+
39
+ self.agents_files = agents_files
40
+ self.skills = skills_result.skills
41
+ self.skill_warnings = [(w.path, w.message) for w in skills_result.warnings]
vtx/context/skills.py ADDED
@@ -0,0 +1,423 @@
1
+ """
2
+ Skills discovery and loading.
3
+
4
+ Skills are directories containing a SKILL.md file with frontmatter.
5
+ They provide specialized instructions that the model can read on-demand.
6
+
7
+ Discovery locations:
8
+ 1. User: ~/.agents/skills/
9
+ 2. Project: <cwd-or-ancestor>/.agents/skills/
10
+ """
11
+
12
+ import os
13
+ import re
14
+ from dataclasses import dataclass
15
+ from importlib import resources
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ from .. import get_agents_dir as get_config_dir
20
+ from ._xml import escape_xml
21
+
22
+ MAX_NAME_LENGTH = 64
23
+ MAX_DESCRIPTION_LENGTH = 1024
24
+ MAX_CMD_INFO_LENGTH = 32
25
+ MAX_CATEGORY_LENGTH = 32
26
+ DEFAULT_SKILL_CATEGORY = "general"
27
+
28
+
29
+ def shorten_path(path: str) -> str:
30
+ home = os.path.expanduser("~")
31
+ if path.startswith(home):
32
+ path = "~" + path[len(home) :]
33
+ return path.replace(os.sep, "/")
34
+
35
+
36
+ def _parse_bool(value: Any) -> bool:
37
+ if isinstance(value, bool):
38
+ return value
39
+ if value is None:
40
+ return False
41
+ if isinstance(value, str):
42
+ return value.strip().lower() in {"1", "true", "yes", "on"}
43
+ return False
44
+
45
+
46
+ @dataclass
47
+ class Skill:
48
+ path: str
49
+ name: str
50
+ description: str
51
+ register_cmd: bool = False
52
+ cmd_info: str = ""
53
+ include_in_prompt: bool = True
54
+ bundled: bool = False
55
+ category: str = DEFAULT_SKILL_CATEGORY
56
+
57
+
58
+ @dataclass
59
+ class SkillWarning:
60
+ path: str
61
+ message: str
62
+
63
+
64
+ @dataclass
65
+ class LoadSkillsResult:
66
+ skills: list[Skill]
67
+ warnings: list[SkillWarning]
68
+
69
+
70
+ def _strip_inline_comment(value: str) -> str:
71
+ quote_char = ""
72
+ escaped = False
73
+ for i, char in enumerate(value):
74
+ if escaped:
75
+ escaped = False
76
+ continue
77
+ if char == "\\" and quote_char:
78
+ escaped = True
79
+ continue
80
+ if char in ('"', "'"):
81
+ if not quote_char:
82
+ quote_char = char
83
+ elif quote_char == char:
84
+ quote_char = ""
85
+ continue
86
+ if char == "#" and not quote_char and (i == 0 or value[i - 1].isspace()):
87
+ return value[:i].rstrip()
88
+ return value
89
+
90
+
91
+ def _parse_frontmatter(content: str) -> dict[str, Any]:
92
+ if not content.startswith("---"):
93
+ return {}
94
+
95
+ end_match = re.search(r"\n---\s*\n", content[3:])
96
+ if not end_match:
97
+ return {}
98
+
99
+ frontmatter_text = content[3 : end_match.start() + 3]
100
+
101
+ result: dict[str, Any] = {}
102
+ for line in frontmatter_text.split("\n"):
103
+ line = line.strip()
104
+ if not line or line.startswith("#"):
105
+ continue
106
+ if ":" in line:
107
+ key, _, value = line.partition(":")
108
+ key = key.strip()
109
+ value = _strip_inline_comment(value.strip())
110
+ if value and value[0] in ('"', "'") and value[-1] == value[0]:
111
+ value = value[1:-1]
112
+ result[key] = value
113
+
114
+ return result
115
+
116
+
117
+ def _validate_skill(
118
+ name: str,
119
+ description: str,
120
+ parent_dir_name: str,
121
+ file_path: str,
122
+ cmd_info: str = "",
123
+ category: str = DEFAULT_SKILL_CATEGORY,
124
+ ) -> list[SkillWarning]:
125
+ warnings: list[SkillWarning] = []
126
+
127
+ if name != parent_dir_name:
128
+ warnings.append(
129
+ SkillWarning(file_path, f'name "{name}" does not match directory "{parent_dir_name}"')
130
+ )
131
+
132
+ if len(name) > MAX_NAME_LENGTH:
133
+ warnings.append(SkillWarning(file_path, f"name exceeds {MAX_NAME_LENGTH} characters"))
134
+
135
+ if not re.match(r"^[a-z0-9-]+$", name):
136
+ warnings.append(SkillWarning(file_path, "name must be lowercase a-z, 0-9, hyphens only"))
137
+
138
+ if name.startswith("-") or name.endswith("-"):
139
+ warnings.append(SkillWarning(file_path, "name must not start or end with hyphen"))
140
+
141
+ if "--" in name:
142
+ warnings.append(SkillWarning(file_path, "name must not contain consecutive hyphens"))
143
+
144
+ if not description or not description.strip():
145
+ warnings.append(SkillWarning(file_path, "description is required"))
146
+
147
+ if len(description) > MAX_DESCRIPTION_LENGTH:
148
+ warnings.append(
149
+ SkillWarning(file_path, f"description exceeds {MAX_DESCRIPTION_LENGTH} characters")
150
+ )
151
+
152
+ if len(cmd_info) > MAX_CMD_INFO_LENGTH:
153
+ warnings.append(
154
+ SkillWarning(file_path, f"cmd_info exceeds {MAX_CMD_INFO_LENGTH} characters")
155
+ )
156
+
157
+ if len(category) > MAX_CATEGORY_LENGTH:
158
+ warnings.append(
159
+ SkillWarning(file_path, f"category exceeds {MAX_CATEGORY_LENGTH} characters")
160
+ )
161
+
162
+ return warnings
163
+
164
+
165
+ def _load_skill_from_dir(skill_dir: Path) -> tuple[Skill | None, list[SkillWarning]]:
166
+ skill_file = skill_dir / "SKILL.md"
167
+ if not skill_file.is_file():
168
+ return None, []
169
+
170
+ warnings: list[SkillWarning] = []
171
+ file_path = str(skill_file)
172
+
173
+ try:
174
+ content = skill_file.read_text(encoding="utf-8")
175
+ frontmatter = _parse_frontmatter(content)
176
+
177
+ parent_dir_name = skill_dir.name
178
+ name = frontmatter.get("name") or parent_dir_name
179
+ description = frontmatter.get("description", "")
180
+ register_cmd_value = str(frontmatter.get("register_cmd", "")).strip().lower()
181
+ cmd_only = register_cmd_value == "only"
182
+ register_cmd = cmd_only or _parse_bool(frontmatter.get("register_cmd"))
183
+ cmd_info = str(frontmatter.get("cmd_info", "")).strip()
184
+ category_raw = str(frontmatter.get("category", "")).strip().lower()
185
+ category = category_raw or DEFAULT_SKILL_CATEGORY
186
+
187
+ warnings = _validate_skill(
188
+ name, description, parent_dir_name, file_path, cmd_info=cmd_info, category=category
189
+ )
190
+
191
+ if not description or not description.strip():
192
+ return None, warnings
193
+
194
+ skill = Skill(
195
+ name=name,
196
+ description=description,
197
+ path=file_path,
198
+ register_cmd=register_cmd,
199
+ cmd_info=cmd_info,
200
+ include_in_prompt=not cmd_only,
201
+ category=category,
202
+ )
203
+ return skill, warnings
204
+
205
+ except Exception as e:
206
+ return None, [SkillWarning(file_path, str(e))]
207
+
208
+
209
+ def _load_skills_from_dir(
210
+ directory: Path, *, legacy_warning: str | None = None
211
+ ) -> LoadSkillsResult:
212
+ skills: list[Skill] = []
213
+ warnings: list[SkillWarning] = []
214
+
215
+ if not directory.exists():
216
+ return LoadSkillsResult(skills=skills, warnings=warnings)
217
+
218
+ if legacy_warning:
219
+ warnings.append(SkillWarning(str(directory), legacy_warning))
220
+
221
+ try:
222
+ for entry in directory.iterdir():
223
+ if entry.name.startswith("."):
224
+ continue
225
+ if not entry.is_dir():
226
+ continue
227
+
228
+ skill, skill_warnings = _load_skill_from_dir(entry)
229
+ warnings.extend(skill_warnings)
230
+ if skill:
231
+ skills.append(skill)
232
+
233
+ except Exception:
234
+ pass
235
+
236
+ return LoadSkillsResult(skills=skills, warnings=warnings)
237
+
238
+
239
+ def _find_git_root(start: Path) -> Path | None:
240
+ current = start
241
+ while True:
242
+ if (current / ".git").is_dir():
243
+ return current
244
+ parent = current.parent
245
+ if parent == current:
246
+ return None
247
+ current = parent
248
+
249
+
250
+ def _project_skill_dirs(cwd: Path) -> list[Path]:
251
+ git_root = _find_git_root(cwd)
252
+ stop_dir = git_root or cwd
253
+ dirs: list[Path] = []
254
+ current = cwd
255
+ while True:
256
+ dirs.append((current / ".agents" / "skills").resolve(strict=False))
257
+ if current == stop_dir:
258
+ break
259
+ current = current.parent
260
+ return dirs
261
+
262
+
263
+ def load_skills(cwd: str | None = None) -> LoadSkillsResult:
264
+ """
265
+ Load skills from ~/.agents and project .agents locations.
266
+
267
+ Discovery:
268
+ 1. <cwd-or-ancestor>/.agents/skills/ - each subdirectory with SKILL.md is a skill
269
+ 2. ~/.agents/skills/ - each subdirectory with SKILL.md is a skill
270
+
271
+ Local skills take precedence over global skills with the same name.
272
+ """
273
+ resolved_cwd = Path(cwd) if cwd else Path.cwd()
274
+ resolved_cwd = resolved_cwd.resolve()
275
+
276
+ skill_map: dict[str, Skill] = {}
277
+ all_warnings: list[SkillWarning] = []
278
+
279
+ def add_skills(result: LoadSkillsResult) -> None:
280
+ all_warnings.extend(result.warnings)
281
+ for skill in result.skills:
282
+ if skill.name in skill_map:
283
+ all_warnings.append(
284
+ SkillWarning(
285
+ skill.path,
286
+ f'name collision: "{skill.name}" already loaded '
287
+ f"from {shorten_path(skill_map[skill.name].path)}",
288
+ )
289
+ )
290
+ else:
291
+ skill_map[skill.name] = skill
292
+
293
+ project_skills_dirs = _project_skill_dirs(resolved_cwd)
294
+ for skills_dir in project_skills_dirs:
295
+ add_skills(_load_skills_from_dir(skills_dir))
296
+
297
+ user_skills_dir = (get_config_dir() / "skills").resolve(strict=False)
298
+ if user_skills_dir not in project_skills_dirs:
299
+ add_skills(_load_skills_from_dir(user_skills_dir))
300
+
301
+ return LoadSkillsResult(skills=list(skill_map.values()), warnings=all_warnings)
302
+
303
+
304
+ def load_builtin_cmd_skills() -> LoadSkillsResult:
305
+ try:
306
+ builtin_resource = resources.files("vtx").joinpath("builtin_skills")
307
+ with resources.as_file(builtin_resource) as builtin_root:
308
+ result = _load_skills_from_dir(builtin_root)
309
+ except Exception:
310
+ return LoadSkillsResult(skills=[], warnings=[])
311
+ return LoadSkillsResult(
312
+ skills=[
313
+ Skill(
314
+ path=skill.path,
315
+ name=skill.name,
316
+ description=skill.description,
317
+ register_cmd=skill.register_cmd,
318
+ cmd_info=skill.cmd_info,
319
+ include_in_prompt=skill.include_in_prompt,
320
+ bundled=True,
321
+ category=skill.category,
322
+ )
323
+ for skill in result.skills
324
+ ],
325
+ warnings=result.warnings,
326
+ )
327
+
328
+
329
+ def strip_frontmatter(content: str) -> str:
330
+ if not content.startswith("---"):
331
+ return content.strip()
332
+ end_match = re.search(r"\n---\s*\n", content[3:])
333
+ if not end_match:
334
+ return content.strip()
335
+ return content[end_match.end() + 3 :].strip()
336
+
337
+
338
+ def render_skill_prompt(skill: Skill, query: str) -> str:
339
+ try:
340
+ content = Path(skill.path).read_text(encoding="utf-8")
341
+ except Exception:
342
+ return _build_fallback_skill_prompt(skill.description, query)
343
+ template = strip_frontmatter(content)
344
+ if "$ARGUMENTS" in template:
345
+ rendered = template.replace("$ARGUMENTS", query).strip()
346
+ else:
347
+ rendered = template.strip()
348
+ if query.strip():
349
+ rendered = f"{rendered}\n\n{query.strip()}"
350
+ skill_dir = str(Path(skill.path).parent)
351
+ return (
352
+ f'<skill name="{escape_xml(skill.name)}" location="{escape_xml(skill.path)}">\n'
353
+ f"References are relative to {skill_dir}.\n"
354
+ f"\n"
355
+ f"{rendered}\n"
356
+ f"</skill>"
357
+ )
358
+
359
+
360
+ def _build_fallback_skill_prompt(description: str, query: str) -> str:
361
+ query = query.strip()
362
+ if not query:
363
+ return description
364
+ return f"{description}\n\n{query}"
365
+
366
+
367
+ def merge_registered_skills(primary: list[Skill], secondary: list[Skill]) -> list[Skill]:
368
+ seen = {skill.name for skill in primary}
369
+ merged = list(primary)
370
+ for skill in secondary:
371
+ if skill.name in seen:
372
+ continue
373
+ merged.append(skill)
374
+ seen.add(skill.name)
375
+ return merged
376
+
377
+
378
+ def formatted_skills(skills: list[Skill]) -> str:
379
+ skills = [skill for skill in skills if skill.include_in_prompt]
380
+ if not skills:
381
+ return ""
382
+
383
+ grouped: dict[str, list[Skill]] = {}
384
+ for skill in skills:
385
+ grouped.setdefault(skill.category or DEFAULT_SKILL_CATEGORY, []).append(skill)
386
+
387
+ index_lines: list[str] = []
388
+ for category in sorted(grouped):
389
+ index_lines.append(f" {category}:")
390
+ for skill in sorted(grouped[category], key=lambda s: s.name):
391
+ if skill.description:
392
+ index_lines.append(f" - {skill.name}: {skill.description}")
393
+ else:
394
+ index_lines.append(f" - {skill.name}")
395
+
396
+ lines = [
397
+ "## Skills (mandatory)",
398
+ "",
399
+ "Before replying, scan the skills below. If a skill matches or is even partially relevant",
400
+ "to your task, you MUST load it with the read tool and follow its instructions. "
401
+ "Err on the side of loading — it is always better to have context you don't need",
402
+ "than to miss critical steps, pitfalls, or established workflows. "
403
+ "Skills contain specialized knowledge — repo conventions, commands, escape hatches, and",
404
+ "proven workflows that outperform general-purpose approaches. Load the skill even if you",
405
+ "think you could handle the task with basic tools. Skills also encode the user's",
406
+ "preferred approach, conventions, and quality standards for tasks like review, planning,",
407
+ "testing, and large refactors — load them even for tasks you already know how to do,",
408
+ "because the skill defines how it should be done here.",
409
+ "",
410
+ "When a skill file references a relative path, resolve it against the skill's directory",
411
+ "(the parent of its SKILL.md) and use that absolute path in tool calls, not a path",
412
+ "relative to the current working directory. If a skill is manually triggered via slash",
413
+ "command, its full content is already included in the user message, so you don't need",
414
+ "to read the skill file again.",
415
+ "",
416
+ "<available_skills>",
417
+ *index_lines,
418
+ "</available_skills>",
419
+ "",
420
+ "Only proceed without loading a skill if genuinely none are relevant to the task.",
421
+ ]
422
+
423
+ return "\n".join(lines)