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.
- vtx/__init__.py +63 -0
- vtx/async_utils.py +40 -0
- vtx/builtin_skills/github/SKILL.md +139 -0
- vtx/builtin_skills/init/SKILL.md +74 -0
- vtx/builtin_skills/review/SKILL.md +73 -0
- vtx/builtin_skills/skill-builder/SKILL.md +133 -0
- vtx/cli.py +90 -0
- vtx/config.py +741 -0
- vtx/context/__init__.py +15 -0
- vtx/context/_xml.py +8 -0
- vtx/context/agent_mds.py +128 -0
- vtx/context/git.py +64 -0
- vtx/context/loader.py +41 -0
- vtx/context/skills.py +423 -0
- vtx/core/__init__.py +47 -0
- vtx/core/compaction.py +89 -0
- vtx/core/errors.py +17 -0
- vtx/core/handoff.py +51 -0
- vtx/core/scratchpad.py +54 -0
- vtx/core/types.py +197 -0
- vtx/defaults/__init__.py +0 -0
- vtx/defaults/config.yml +53 -0
- vtx/diff_display.py +12 -0
- vtx/events.py +224 -0
- vtx/gh_cli.py +82 -0
- vtx/git_branch.py +90 -0
- vtx/headless.py +127 -0
- vtx/llm/__init__.py +93 -0
- vtx/llm/base.py +217 -0
- vtx/llm/context_length.py +150 -0
- vtx/llm/dynamic_models.py +735 -0
- vtx/llm/model_fetcher.py +279 -0
- vtx/llm/models.py +78 -0
- vtx/llm/oauth/__init__.py +59 -0
- vtx/llm/oauth/copilot.py +358 -0
- vtx/llm/oauth/dynamic.py +236 -0
- vtx/llm/oauth/openai.py +400 -0
- vtx/llm/phase_parser.py +270 -0
- vtx/llm/provider.yaml +280 -0
- vtx/llm/provider_catalog.py +230 -0
- vtx/llm/providers/__init__.py +45 -0
- vtx/llm/providers/anthropic_sdk.py +256 -0
- vtx/llm/providers/mock.py +249 -0
- vtx/llm/providers/openai_sdk.py +246 -0
- vtx/llm/providers/sanitize.py +14 -0
- vtx/llm/sdk/__init__.py +13 -0
- vtx/llm/sdk/anthropic.py +382 -0
- vtx/llm/sdk/base.py +82 -0
- vtx/llm/sdk/openai.py +344 -0
- vtx/llm/tool_parser.py +161 -0
- vtx/loop.py +272 -0
- vtx/notify.py +109 -0
- vtx/permissions.py +114 -0
- vtx/prompts/__init__.py +45 -0
- vtx/prompts/builder.py +86 -0
- vtx/prompts/env.py +58 -0
- vtx/prompts/identity.py +166 -0
- vtx/prompts/tooling.py +36 -0
- vtx/py.typed +0 -0
- vtx/runtime.py +580 -0
- vtx/session.py +868 -0
- vtx/sounds/completion.wav +0 -0
- vtx/sounds/error.wav +0 -0
- vtx/sounds/permission.wav +0 -0
- vtx/themes.py +1104 -0
- vtx/tools/__init__.py +68 -0
- vtx/tools/_read_image.py +106 -0
- vtx/tools/_tool_utils.py +90 -0
- vtx/tools/base.py +36 -0
- vtx/tools/bash.py +371 -0
- vtx/tools/edit.py +261 -0
- vtx/tools/find.py +132 -0
- vtx/tools/read.py +238 -0
- vtx/tools/skill.py +278 -0
- vtx/tools/web.py +238 -0
- vtx/tools/write.py +88 -0
- vtx/tools_manager.py +216 -0
- vtx/turn.py +789 -0
- vtx/ui/__init__.py +0 -0
- vtx/ui/agent_runner.py +417 -0
- vtx/ui/app.py +665 -0
- vtx/ui/app_protocol.py +29 -0
- vtx/ui/autocomplete.py +440 -0
- vtx/ui/blocks.py +735 -0
- vtx/ui/chat.py +613 -0
- vtx/ui/clipboard.py +59 -0
- vtx/ui/commands/__init__.py +100 -0
- vtx/ui/commands/auth.py +306 -0
- vtx/ui/commands/base.py +122 -0
- vtx/ui/commands/models.py +144 -0
- vtx/ui/commands/sessions.py +388 -0
- vtx/ui/commands/settings.py +286 -0
- vtx/ui/completion_ui.py +313 -0
- vtx/ui/export.py +703 -0
- vtx/ui/floating_list.py +370 -0
- vtx/ui/formatting.py +287 -0
- vtx/ui/input.py +760 -0
- vtx/ui/latex.py +349 -0
- vtx/ui/launch.py +108 -0
- vtx/ui/path_complete.py +228 -0
- vtx/ui/prompt_history.py +102 -0
- vtx/ui/queue_ui.py +141 -0
- vtx/ui/selection_mode.py +18 -0
- vtx/ui/session_ui.py +235 -0
- vtx/ui/startup.py +124 -0
- vtx/ui/styles.py +327 -0
- vtx/ui/tool_output.py +34 -0
- vtx/ui/tree.py +437 -0
- vtx/ui/welcome.py +51 -0
- vtx/ui/widgets.py +558 -0
- vtx/update_check.py +49 -0
- vtx/version.py +22 -0
- vtx_coding_agent-0.1.1.dist-info/METADATA +259 -0
- vtx_coding_agent-0.1.1.dist-info/RECORD +117 -0
- vtx_coding_agent-0.1.1.dist-info/WHEEL +4 -0
- vtx_coding_agent-0.1.1.dist-info/entry_points.txt +2 -0
- vtx_coding_agent-0.1.1.dist-info/licenses/LICENSE +201 -0
vtx/context/__init__.py
ADDED
|
@@ -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
vtx/context/agent_mds.py
ADDED
|
@@ -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)
|