mosscode 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.
- mosscode/__init__.py +10 -0
- mosscode/__main__.py +3 -0
- mosscode/agent.py +486 -0
- mosscode/agents/__init__.py +249 -0
- mosscode/agents/builtin/explore.md +16 -0
- mosscode/agents/builtin/general.md +12 -0
- mosscode/agents/builtin/plan.md +16 -0
- mosscode/agents/runtime.py +685 -0
- mosscode/agents/tasks.py +193 -0
- mosscode/appearance.py +184 -0
- mosscode/builtin_commands.py +1363 -0
- mosscode/cli.py +547 -0
- mosscode/commands.py +254 -0
- mosscode/compaction.py +259 -0
- mosscode/config.py +337 -0
- mosscode/context.py +102 -0
- mosscode/conversation.py +1046 -0
- mosscode/coordinator.py +54 -0
- mosscode/doctor.py +303 -0
- mosscode/hooks/__init__.py +22 -0
- mosscode/hooks/actions.py +151 -0
- mosscode/hooks/conditions.py +170 -0
- mosscode/hooks/config.py +404 -0
- mosscode/hooks/engine.py +401 -0
- mosscode/hooks/events.py +48 -0
- mosscode/launcher.py +177 -0
- mosscode/mcp_client.py +460 -0
- mosscode/mcp_config.py +216 -0
- mosscode/member_ui.py +289 -0
- mosscode/memory.py +291 -0
- mosscode/memory_extract.py +245 -0
- mosscode/messages.py +216 -0
- mosscode/paths.py +31 -0
- mosscode/permissions.py +733 -0
- mosscode/prompts.py +483 -0
- mosscode/providers.py +587 -0
- mosscode/scrollback.py +106 -0
- mosscode/security.py +282 -0
- mosscode/serialization.py +121 -0
- mosscode/session.py +563 -0
- mosscode/setup.py +248 -0
- mosscode/skills/__init__.py +455 -0
- mosscode/skills/builtin/commit/SKILL.md +15 -0
- mosscode/skills/builtin/review/SKILL.md +15 -0
- mosscode/skills/builtin/test/SKILL.md +13 -0
- mosscode/skills/builtin/test/scripts/detect.py +96 -0
- mosscode/skills/builtin/test/tool.json +9 -0
- mosscode/teams/__init__.py +216 -0
- mosscode/teams/backends.py +259 -0
- mosscode/teams/board.py +230 -0
- mosscode/teams/lock.py +173 -0
- mosscode/teams/mailbox.py +172 -0
- mosscode/teams/member.py +427 -0
- mosscode/teams/merge.py +207 -0
- mosscode/teams/pane.py +20 -0
- mosscode/teams/pane_app.py +167 -0
- mosscode/teams/process.py +371 -0
- mosscode/teams/runtime.py +1355 -0
- mosscode/teams/view.py +182 -0
- mosscode/tool_budget.py +86 -0
- mosscode/tools/__init__.py +38 -0
- mosscode/tools/agent.py +127 -0
- mosscode/tools/base.py +384 -0
- mosscode/tools/diffing.py +16 -0
- mosscode/tools/execution.py +99 -0
- mosscode/tools/files.py +287 -0
- mosscode/tools/memory.py +65 -0
- mosscode/tools/plan.py +30 -0
- mosscode/tools/process.py +216 -0
- mosscode/tools/search.py +229 -0
- mosscode/tools/search_worker.py +243 -0
- mosscode/tools/shell.py +121 -0
- mosscode/tools/skill.py +118 -0
- mosscode/tools/team.py +447 -0
- mosscode/tools/windows_job.py +75 -0
- mosscode/tui.py +2325 -0
- mosscode/usage.py +68 -0
- mosscode/windows_inline.py +148 -0
- mosscode/worktree/__init__.py +29 -0
- mosscode/worktree/changes.py +59 -0
- mosscode/worktree/git.py +125 -0
- mosscode/worktree/manager.py +462 -0
- mosscode/worktree/meta.py +82 -0
- mosscode/worktree/names.py +52 -0
- mosscode/worktree/setup.py +119 -0
- mosscode-0.1.0.dist-info/METADATA +92 -0
- mosscode-0.1.0.dist-info/RECORD +91 -0
- mosscode-0.1.0.dist-info/WHEEL +5 -0
- mosscode-0.1.0.dist-info/entry_points.txt +3 -0
- mosscode-0.1.0.dist-info/licenses/LICENSE +21 -0
- mosscode-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
"""Skills: reusable procedures in SKILL.md directories, optionally with tools in tool.json."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from collections.abc import Iterable
|
|
8
|
+
from dataclasses import dataclass, field, replace
|
|
9
|
+
from importlib import resources
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Literal
|
|
12
|
+
|
|
13
|
+
import yaml
|
|
14
|
+
|
|
15
|
+
from ..paths import mosscode_home
|
|
16
|
+
|
|
17
|
+
NAME = re.compile(r"[a-z][a-z0-9-]*")
|
|
18
|
+
TOOL_NAME = re.compile(r"[a-z][a-z0-9_]*")
|
|
19
|
+
NAME_LIMIT = 64
|
|
20
|
+
TOOL_SHORT_LIMIT = 32
|
|
21
|
+
TOOL_NAME_LIMIT = 64 # Provider tool-name limit.
|
|
22
|
+
FILE_LIMIT = 64 * 1024
|
|
23
|
+
DEFAULT_TIMEOUT, MAX_TIMEOUT = 120.0, 600.0
|
|
24
|
+
DESCRIPTION_LIMIT = 250
|
|
25
|
+
CATALOG_LIMIT = 8000
|
|
26
|
+
LOAD_SKILL = "load_skill"
|
|
27
|
+
# Names known before MCP connects. load_skill is a system tool and always allowed.
|
|
28
|
+
BUILTIN_TOOLS = frozenset(
|
|
29
|
+
{"read_file", "write_file", "edit_file", "execute_command", "glob", "grep", "memory", "exit_plan_mode"}
|
|
30
|
+
)
|
|
31
|
+
CLAUDE_TOOLS = {
|
|
32
|
+
"Read": "read_file",
|
|
33
|
+
"Write": "write_file",
|
|
34
|
+
"Edit": "edit_file",
|
|
35
|
+
"Bash": "execute_command",
|
|
36
|
+
"Glob": "glob",
|
|
37
|
+
"Grep": "grep",
|
|
38
|
+
}
|
|
39
|
+
Source = Literal["builtin", "user", "project"]
|
|
40
|
+
SOURCE_LABELS = {"builtin": "内置", "user": "用户", "project": "项目"}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class SkillError(ValueError):
|
|
44
|
+
"""One skill directory is unusable; the message names the file and the field."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class SkillToolDef:
|
|
49
|
+
name: str # Short name, unique within the skill.
|
|
50
|
+
full_name: str # skill__<skill>__<name>
|
|
51
|
+
description: str
|
|
52
|
+
input_schema: dict
|
|
53
|
+
command: tuple[str, ...] # argv before ${SKILL_DIR} / ${PYTHON} substitution
|
|
54
|
+
timeout: float = DEFAULT_TIMEOUT
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class SkillDef:
|
|
59
|
+
name: str
|
|
60
|
+
description: str
|
|
61
|
+
body: str
|
|
62
|
+
path: Path # SKILL.md
|
|
63
|
+
source: Source
|
|
64
|
+
mode: Literal["inline", "fork"] = "inline"
|
|
65
|
+
fork_context: Literal["none", "recent", "full"] = "none"
|
|
66
|
+
allowed_tools: tuple[str, ...] = () # Claude Code names mapped; own tools expanded to full names.
|
|
67
|
+
model: str | None = None
|
|
68
|
+
argument_hint: str = ""
|
|
69
|
+
model_invocable: bool = True
|
|
70
|
+
user_invocable: bool = True
|
|
71
|
+
tools: tuple[SkillToolDef, ...] = ()
|
|
72
|
+
overrides: tuple[Path, ...] = () # Same-named SKILL.md files this one replaced.
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def directory(self) -> Path:
|
|
76
|
+
return self.path.parent
|
|
77
|
+
|
|
78
|
+
def render(self, arguments: str = "", *, fork: bool = False) -> str:
|
|
79
|
+
"""Where it comes from, the suggested tools (not for a fork child) and the body."""
|
|
80
|
+
lines = [f"Skill {self.name}(目录:{self.directory})"]
|
|
81
|
+
if self.tools:
|
|
82
|
+
lines.append("专属工具:" + "、".join(tool.full_name for tool in self.tools))
|
|
83
|
+
if self.allowed_tools and not fork:
|
|
84
|
+
lines.append("本 Skill 建议使用的工具:" + "、".join(self.allowed_tools))
|
|
85
|
+
return "\n".join(lines) + "\n\n" + expand_arguments(self.body, arguments)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def expand_arguments(body: str, arguments: str) -> str:
|
|
89
|
+
arguments = arguments.strip()
|
|
90
|
+
if "$ARGUMENTS" in body:
|
|
91
|
+
return body.replace("$ARGUMENTS", arguments)
|
|
92
|
+
return body + (f"\n\n补充要求:{arguments}" if arguments else "")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def tool_full_name(skill: str, tool: str) -> str:
|
|
96
|
+
return f"skill__{skill.replace('-', '_')}__{tool}"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# Parsing ----------------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _read(path: Path) -> str:
|
|
103
|
+
try:
|
|
104
|
+
data = path.read_bytes()
|
|
105
|
+
except OSError as exc:
|
|
106
|
+
raise SkillError(f"{path}:无法读取({exc.strerror or exc})。") from None
|
|
107
|
+
if len(data) > FILE_LIMIT:
|
|
108
|
+
raise SkillError(f"{path}:文件超过 64 KB。")
|
|
109
|
+
try:
|
|
110
|
+
return data.decode("utf-8-sig")
|
|
111
|
+
except UnicodeDecodeError:
|
|
112
|
+
raise SkillError(f"{path}:不是 UTF-8 编码。") from None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _split(text: str, path: Path) -> tuple[dict, str]:
|
|
116
|
+
lines = text.splitlines()
|
|
117
|
+
if not lines or lines[0].rstrip() != "---":
|
|
118
|
+
raise SkillError(f"{path}:缺少 YAML frontmatter(第一行应为 ---)。")
|
|
119
|
+
end = next((i for i in range(1, len(lines)) if lines[i].rstrip() == "---"), None)
|
|
120
|
+
if end is None:
|
|
121
|
+
raise SkillError(f"{path}:frontmatter 没有结束的 ---。")
|
|
122
|
+
try:
|
|
123
|
+
meta = yaml.safe_load("\n".join(lines[1:end])) or {}
|
|
124
|
+
except yaml.YAMLError as exc:
|
|
125
|
+
raise SkillError(f"{path}:frontmatter 不是合法的 YAML({getattr(exc, 'problem', None) or exc})。") from None
|
|
126
|
+
if not isinstance(meta, dict):
|
|
127
|
+
raise SkillError(f"{path}:frontmatter 必须是键值对。")
|
|
128
|
+
return meta, "\n".join(lines[end + 1 :]).strip()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _field(meta: dict, path: Path, *names: str):
|
|
132
|
+
present = [name for name in names if name in meta]
|
|
133
|
+
if len(present) > 1:
|
|
134
|
+
raise SkillError(f"{path}:{' 和 '.join(present)} 只能写一个。")
|
|
135
|
+
return (present[0], meta[present[0]]) if present else (names[0], None)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _text(meta, path, *names, required=False) -> str:
|
|
139
|
+
key, value = _field(meta, path, *names)
|
|
140
|
+
if value is None:
|
|
141
|
+
if required:
|
|
142
|
+
raise SkillError(f"{path}:缺少 {key}。")
|
|
143
|
+
return ""
|
|
144
|
+
if not isinstance(value, str) or (required and not value.strip()):
|
|
145
|
+
raise SkillError(f"{path}:{key} 必须是非空字符串。")
|
|
146
|
+
return value.strip()
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _flag(meta, path, *names, default: bool) -> bool:
|
|
150
|
+
key, value = _field(meta, path, *names)
|
|
151
|
+
if value is None:
|
|
152
|
+
return default
|
|
153
|
+
if not isinstance(value, bool):
|
|
154
|
+
raise SkillError(f"{path}:{key} 必须是 true 或 false。")
|
|
155
|
+
return value
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _choice(meta, path, key, choices, default):
|
|
159
|
+
value = meta.get(key)
|
|
160
|
+
if value is None:
|
|
161
|
+
return default
|
|
162
|
+
if value not in choices:
|
|
163
|
+
raise SkillError(f"{path}:{key} 只能是 {' / '.join(choices)},当前为 {value!r}。")
|
|
164
|
+
return value
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def split_tool_list(value: str) -> list[str]:
|
|
168
|
+
"""Commas or whitespace separate names; both may appear inside Bash(git commit:*)."""
|
|
169
|
+
items, current, depth = [], "", 0
|
|
170
|
+
for char in value:
|
|
171
|
+
if char == "(":
|
|
172
|
+
depth += 1
|
|
173
|
+
elif char == ")":
|
|
174
|
+
depth = max(depth - 1, 0)
|
|
175
|
+
if depth == 0 and (char == "," or char.isspace()):
|
|
176
|
+
if current:
|
|
177
|
+
items.append(current)
|
|
178
|
+
current = ""
|
|
179
|
+
else:
|
|
180
|
+
current += char
|
|
181
|
+
return items + ([current] if current else [])
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _allowed(meta, path) -> list[str]:
|
|
185
|
+
key, value = _field(meta, path, "allowed_tools", "allowed-tools")
|
|
186
|
+
if value is None:
|
|
187
|
+
return []
|
|
188
|
+
if isinstance(value, str):
|
|
189
|
+
raw = split_tool_list(value)
|
|
190
|
+
elif isinstance(value, list) and all(isinstance(item, str) for item in value):
|
|
191
|
+
raw = [item.strip() for item in value if item.strip()]
|
|
192
|
+
else:
|
|
193
|
+
raise SkillError(f"{path}:{key} 必须是工具名列表或用逗号分隔的字符串。")
|
|
194
|
+
names = []
|
|
195
|
+
for item in raw:
|
|
196
|
+
name = item.split("(", 1)[0].strip() # Bash(git:*) -> Bash: the argument part is ignored.
|
|
197
|
+
names.append(CLAUDE_TOOLS.get(name, name))
|
|
198
|
+
return list(dict.fromkeys(names))
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _tool(item, index: int, skill: str, path: Path) -> SkillToolDef:
|
|
202
|
+
where = f"{path} 第 {index + 1} 项"
|
|
203
|
+
if not isinstance(item, dict):
|
|
204
|
+
raise SkillError(f"{where}:必须是对象。")
|
|
205
|
+
name = item.get("name")
|
|
206
|
+
if not isinstance(name, str) or not TOOL_NAME.fullmatch(name) or len(name) > TOOL_SHORT_LIMIT:
|
|
207
|
+
raise SkillError(f"{where}:name 须以小写字母开头,只含小写字母、数字和 _,最长 {TOOL_SHORT_LIMIT} 个字符。")
|
|
208
|
+
full = tool_full_name(skill, name)
|
|
209
|
+
if len(full) > TOOL_NAME_LIMIT:
|
|
210
|
+
raise SkillError(f"{where}:工具全名 {full} 超过 {TOOL_NAME_LIMIT} 个字符。")
|
|
211
|
+
description = item.get("description")
|
|
212
|
+
if not isinstance(description, str) or not description.strip():
|
|
213
|
+
raise SkillError(f"{where}({name}):description 必须是非空字符串。")
|
|
214
|
+
schema = item.get("input_schema")
|
|
215
|
+
try:
|
|
216
|
+
from ..mcp_client import check_schema
|
|
217
|
+
|
|
218
|
+
check_schema(schema)
|
|
219
|
+
except Exception as exc: # ValueError or jsonschema.SchemaError
|
|
220
|
+
raise SkillError(
|
|
221
|
+
f"{where}({name}):input_schema 不合法({getattr(exc, 'message', None) or exc})。"
|
|
222
|
+
) from None
|
|
223
|
+
command = item.get("command")
|
|
224
|
+
if not isinstance(command, list) or not command or not all(isinstance(part, str) and part for part in command):
|
|
225
|
+
raise SkillError(f"{where}({name}):command 必须是非空字符串组成的非空数组。")
|
|
226
|
+
timeout = item.get("timeout", DEFAULT_TIMEOUT)
|
|
227
|
+
if type(timeout) not in (int, float) or not 0 < timeout <= MAX_TIMEOUT:
|
|
228
|
+
raise SkillError(f"{where}({name}):timeout 必须是 0 到 {MAX_TIMEOUT:g} 之间的秒数。")
|
|
229
|
+
return SkillToolDef(name, full, description.strip(), schema, tuple(command), float(timeout))
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _tools(directory: Path, skill: str) -> tuple[SkillToolDef, ...]:
|
|
233
|
+
path = directory / "tool.json"
|
|
234
|
+
if not path.is_file():
|
|
235
|
+
return ()
|
|
236
|
+
try:
|
|
237
|
+
items = json.loads(_read(path))
|
|
238
|
+
except ValueError as exc:
|
|
239
|
+
raise SkillError(f"{path}:不是合法的 JSON({exc})。") from None
|
|
240
|
+
if not isinstance(items, list):
|
|
241
|
+
raise SkillError(f"{path}:必须是工具对象组成的数组。")
|
|
242
|
+
tools = tuple(_tool(item, index, skill, path) for index, item in enumerate(items))
|
|
243
|
+
names = [tool.name for tool in tools]
|
|
244
|
+
duplicate = next((name for name in names if names.count(name) > 1), None)
|
|
245
|
+
if duplicate:
|
|
246
|
+
raise SkillError(f"{path}:工具名 {duplicate} 重复。")
|
|
247
|
+
return tools
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def parse_skill(directory: Path, source: Source) -> SkillDef:
|
|
251
|
+
"""Read and check one skill directory, its tool whitelist included."""
|
|
252
|
+
path = directory / "SKILL.md"
|
|
253
|
+
if not path.is_file():
|
|
254
|
+
raise SkillError(f"{directory}:没有 SKILL.md。")
|
|
255
|
+
meta, body = _split(_read(path), path)
|
|
256
|
+
name = _text(meta, path, "name", required=True)
|
|
257
|
+
if not NAME.fullmatch(name) or len(name) > NAME_LIMIT:
|
|
258
|
+
raise SkillError(f"{path}:name 须以小写字母开头,只含小写字母、数字和 -,最长 {NAME_LIMIT} 个字符。")
|
|
259
|
+
if name != directory.name:
|
|
260
|
+
raise SkillError(f"{path}:name({name})必须与目录名({directory.name})相同。")
|
|
261
|
+
description = " ".join(_text(meta, path, "description", required=True).split())
|
|
262
|
+
if not body:
|
|
263
|
+
raise SkillError(f"{path}:正文为空。")
|
|
264
|
+
mode = _choice(meta, path, "mode", ("inline", "fork"), None)
|
|
265
|
+
context = _choice(meta, path, "context", ("fork",), None)
|
|
266
|
+
if context and mode == "inline":
|
|
267
|
+
raise SkillError(f"{path}:mode: inline 与 context: fork 矛盾。")
|
|
268
|
+
mode = "fork" if context else mode or "inline"
|
|
269
|
+
model = _text(meta, path, "model") or None
|
|
270
|
+
tools = _tools(directory, name)
|
|
271
|
+
own = {tool.name: tool.full_name for tool in tools}
|
|
272
|
+
allowed = []
|
|
273
|
+
for item in _allowed(meta, path):
|
|
274
|
+
item = own.get(item, item)
|
|
275
|
+
if item == LOAD_SKILL:
|
|
276
|
+
continue # A system tool: never narrowed, so listing it changes nothing.
|
|
277
|
+
if item not in BUILTIN_TOOLS and item not in own.values() and not item.startswith("mcp__"):
|
|
278
|
+
raise SkillError(f"Skill {name}:allowed_tools 中的 {item} 不是已知工具({path})。")
|
|
279
|
+
allowed.append(item)
|
|
280
|
+
return SkillDef(
|
|
281
|
+
name,
|
|
282
|
+
description,
|
|
283
|
+
body,
|
|
284
|
+
path,
|
|
285
|
+
source,
|
|
286
|
+
mode,
|
|
287
|
+
_choice(meta, path, "fork_context", ("none", "recent", "full"), "none"),
|
|
288
|
+
tuple(allowed),
|
|
289
|
+
model,
|
|
290
|
+
_text(meta, path, "argument-hint", "argument_hint"),
|
|
291
|
+
not _flag(meta, path, "disable-model-invocation", "disable_model_invocation", default=False),
|
|
292
|
+
_flag(meta, path, "user-invocable", "user_invocable", default=True),
|
|
293
|
+
tools,
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
# Catalog ----------------------------------------------------------------------------------
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def builtin_dir() -> Path:
|
|
301
|
+
return Path(str(resources.files(__name__).joinpath("builtin")))
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def skill_dirs(root: Path, home: Path | None = None, builtin: Path | None = None) -> list[tuple[Source, Path]]:
|
|
305
|
+
"""Lowest priority first."""
|
|
306
|
+
return [
|
|
307
|
+
("builtin", builtin if builtin is not None else builtin_dir()),
|
|
308
|
+
("user", mosscode_home(home) / "skills"),
|
|
309
|
+
("project", root / ".mosscode" / "skills"),
|
|
310
|
+
]
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
@dataclass
|
|
314
|
+
class SkillCatalog:
|
|
315
|
+
skills: dict[str, SkillDef] = field(default_factory=dict)
|
|
316
|
+
problems: tuple[str, ...] = ()
|
|
317
|
+
_latest: dict[str, SkillDef] = field(default_factory=dict, repr=False)
|
|
318
|
+
_origin: tuple = field(default=(), repr=False) # load() arguments, for reload().
|
|
319
|
+
|
|
320
|
+
@classmethod
|
|
321
|
+
def load(
|
|
322
|
+
cls, root: Path, home: Path | None = None, *, reserved: Iterable[str] = (), builtin: Path | None = None
|
|
323
|
+
) -> SkillCatalog:
|
|
324
|
+
"""Scan the three places; a later place replaces a same-named skill of an earlier one."""
|
|
325
|
+
reserved = set(reserved)
|
|
326
|
+
skills: dict[str, SkillDef] = {}
|
|
327
|
+
problems: list[str] = []
|
|
328
|
+
for source, base in skill_dirs(root, home, builtin):
|
|
329
|
+
try:
|
|
330
|
+
children = sorted(
|
|
331
|
+
child for child in base.iterdir() if child.is_dir() and not child.name.startswith(".")
|
|
332
|
+
)
|
|
333
|
+
except OSError:
|
|
334
|
+
continue # A missing place is not a problem.
|
|
335
|
+
for directory in children:
|
|
336
|
+
if directory.name == "__pycache__":
|
|
337
|
+
continue
|
|
338
|
+
try:
|
|
339
|
+
skill = parse_skill(directory, source)
|
|
340
|
+
except SkillError as exc:
|
|
341
|
+
problems.append(f"Skill 已跳过:{exc}")
|
|
342
|
+
continue
|
|
343
|
+
if skill.name in reserved:
|
|
344
|
+
problems.append(f"Skill {skill.name} 已跳过:与内置命令 /{skill.name} 冲突({skill.path})。")
|
|
345
|
+
continue
|
|
346
|
+
previous = skills.get(skill.name)
|
|
347
|
+
if previous is not None:
|
|
348
|
+
skill = replace(skill, overrides=previous.overrides + (previous.path,))
|
|
349
|
+
skills[skill.name] = skill
|
|
350
|
+
return cls(dict(sorted(skills.items())), tuple(problems), _origin=(root, home, tuple(reserved), builtin))
|
|
351
|
+
|
|
352
|
+
def reload(self) -> SkillCatalog:
|
|
353
|
+
"""Scan the same places again; a catalog that was not loaded from disk stays as it is."""
|
|
354
|
+
if not self._origin:
|
|
355
|
+
return self
|
|
356
|
+
root, home, reserved, builtin = self._origin
|
|
357
|
+
return SkillCatalog.load(root, home, reserved=reserved, builtin=builtin)
|
|
358
|
+
|
|
359
|
+
def get(self, name: str) -> SkillDef | None:
|
|
360
|
+
return self.skills.get(name)
|
|
361
|
+
|
|
362
|
+
def fresh(self, name: str) -> tuple[SkillDef, str | None]:
|
|
363
|
+
"""Read the files again; on failure keep the last version that loaded."""
|
|
364
|
+
cached = self._latest.get(name) or self.skills[name]
|
|
365
|
+
try:
|
|
366
|
+
skill = parse_skill(cached.directory, cached.source)
|
|
367
|
+
except SkillError as exc:
|
|
368
|
+
return cached, f"Skill {name} 重新读取失败,已使用缓存版本:{exc}"
|
|
369
|
+
skill = replace(skill, overrides=cached.overrides)
|
|
370
|
+
self._latest[name] = skill
|
|
371
|
+
return skill, None
|
|
372
|
+
|
|
373
|
+
def catalog_section(self) -> tuple[str, str | None]:
|
|
374
|
+
"""What the model sees before loading; returns the section and a notice when cut."""
|
|
375
|
+
lines, size, left = [], 0, 0
|
|
376
|
+
for skill in self.skills.values():
|
|
377
|
+
if not skill.model_invocable:
|
|
378
|
+
continue
|
|
379
|
+
description = skill.description
|
|
380
|
+
if len(description) > DESCRIPTION_LIMIT:
|
|
381
|
+
description = description[: DESCRIPTION_LIMIT - 1] + "…"
|
|
382
|
+
line = f"- {skill.name}:{description}"
|
|
383
|
+
if size + len(line) + 1 > CATALOG_LIMIT:
|
|
384
|
+
left += 1
|
|
385
|
+
continue
|
|
386
|
+
lines.append(line)
|
|
387
|
+
size += len(line) + 1
|
|
388
|
+
if not lines:
|
|
389
|
+
return "", None
|
|
390
|
+
text = (
|
|
391
|
+
"## Skill\nSkill 是可复用的操作规程。用户的请求与下列某个 Skill 的描述相符时,先调用 load_skill 加载它,"
|
|
392
|
+
"再按加载后的规程执行;不要凭描述猜测规程的内容。用户用 /<名称> 直接调用的 Skill 已附带规程,不需要再加载。\n\n"
|
|
393
|
+
+ "\n".join(lines)
|
|
394
|
+
)
|
|
395
|
+
notice = f"有 {left} 个 Skill 未列入模型可见的目录,仍可用命令调用。" if left else None
|
|
396
|
+
return text, notice
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def missing_tools(skill: SkillDef, available: Iterable[str]) -> list[str]:
|
|
400
|
+
"""Names that are not registered now; the skill's own tools count as available."""
|
|
401
|
+
available = set(available) | {tool.full_name for tool in skill.tools}
|
|
402
|
+
return [name for name in skill.allowed_tools if name not in available]
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
@dataclass(frozen=True)
|
|
406
|
+
class ForkRequest:
|
|
407
|
+
"""An explicit /<name> of a fork skill; prompt is its rendered SOP."""
|
|
408
|
+
|
|
409
|
+
skill: SkillDef
|
|
410
|
+
prompt: str
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
ACTIVE_LIMIT = 40000 # Characters of pinned SOPs in all.
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
@dataclass(frozen=True)
|
|
417
|
+
class ActiveSkill:
|
|
418
|
+
skill: SkillDef
|
|
419
|
+
arguments: str
|
|
420
|
+
text: str # Rendered SOP
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
class ActiveSkills:
|
|
424
|
+
"""Skills the model loaded, in load order; loading one again refreshes it in place."""
|
|
425
|
+
|
|
426
|
+
def __init__(self) -> None:
|
|
427
|
+
self._items: dict[str, ActiveSkill] = {}
|
|
428
|
+
|
|
429
|
+
def __bool__(self) -> bool:
|
|
430
|
+
return bool(self._items)
|
|
431
|
+
|
|
432
|
+
def __contains__(self, name: str) -> bool:
|
|
433
|
+
return name in self._items
|
|
434
|
+
|
|
435
|
+
@property
|
|
436
|
+
def names(self) -> tuple[str, ...]:
|
|
437
|
+
return tuple(self._items)
|
|
438
|
+
|
|
439
|
+
def items(self) -> tuple[ActiveSkill, ...]:
|
|
440
|
+
return tuple(self._items.values())
|
|
441
|
+
|
|
442
|
+
def texts(self) -> tuple[str, ...]:
|
|
443
|
+
return tuple(item.text for item in self._items.values())
|
|
444
|
+
|
|
445
|
+
def size(self, without: str = "") -> int:
|
|
446
|
+
return sum(len(item.text) for name, item in self._items.items() if name != without)
|
|
447
|
+
|
|
448
|
+
def activate(self, skill: SkillDef, arguments: str, text: str) -> None:
|
|
449
|
+
self._items[skill.name] = ActiveSkill(skill, arguments, text)
|
|
450
|
+
|
|
451
|
+
def remove(self, name: str) -> None:
|
|
452
|
+
self._items.pop(name, None)
|
|
453
|
+
|
|
454
|
+
def clear(self) -> None:
|
|
455
|
+
self._items.clear()
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: commit
|
|
3
|
+
description: 把与当前任务相关的改动提交为一次 git commit(不推送)
|
|
4
|
+
allowed_tools: [execute_command, read_file]
|
|
5
|
+
argument-hint: "[对提交说明的要求]"
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
请把当前仓库中与本次任务相关的改动提交为一次 git commit。
|
|
9
|
+
|
|
10
|
+
1. 用 git status 和 git diff HEAD 查看改动;不是 git 仓库或没有可提交的改动时,直接说明并结束。
|
|
11
|
+
2. 用 git log -n 5 --oneline 查看最近的提交说明,沿用仓库的语言和格式。
|
|
12
|
+
3. 只暂存与本次任务相关的文件,用 git add <路径> 逐个列出;不要用 git add -A 或 git add .。看起来含密钥或凭据的文件(如 .env)不要暂存,并提醒用户。
|
|
13
|
+
4. 写一条说明改动目的的提交说明,执行 git commit。不要 --amend,不要 --no-verify,不要推送。
|
|
14
|
+
5. 提交钩子失败时报告原因;修复后重新提交,不改写已有提交。
|
|
15
|
+
6. 完成后用 git status 和 git log -n 1 确认结果并汇报。
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: review
|
|
3
|
+
description: 审查当前仓库中尚未提交的改动(只读),按严重程度列出问题
|
|
4
|
+
mode: fork
|
|
5
|
+
fork_context: none
|
|
6
|
+
allowed_tools: [read_file, glob, grep, execute_command]
|
|
7
|
+
argument-hint: "[范围或侧重点]"
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
请审查当前仓库中尚未提交的改动。
|
|
11
|
+
|
|
12
|
+
1. 先用 git status 和 git diff HEAD 查看改动范围;不是 git 仓库或没有改动时,直接说明并结束。
|
|
13
|
+
2. 阅读改动涉及的上下文代码,找出正确性问题、边界条件、错误处理、安全隐患,以及与周边代码约定不一致的地方。
|
|
14
|
+
3. 只审查,不修改文件,不运行会改变仓库或环境状态的命令。
|
|
15
|
+
4. 按严重程度从高到低列出问题,每条写出 文件:行号、问题、触发场景和修改建议;没有发现问题时明确说明。
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: test
|
|
3
|
+
description: 检测项目类型并运行测试,失败时分析原因
|
|
4
|
+
allowed_tools: [detect, execute_command, read_file, glob, grep]
|
|
5
|
+
argument-hint: "[测试范围]"
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
请运行本项目的测试。
|
|
9
|
+
|
|
10
|
+
1. 先调用 skill__test__detect 获取建议的测试命令;结果为空时,查看 README、AGENTS.md 等项目说明找测试方式,仍找不到就说明并结束。
|
|
11
|
+
2. 有补充要求时(如只测某个文件或用例)按要求缩小范围。
|
|
12
|
+
3. 运行测试命令。
|
|
13
|
+
4. 全部通过时汇报结果;有失败时阅读失败用例和相关代码,说明原因和修改建议。用户没有要求修复时不修改文件。
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Suggest test commands for the project in the working directory; prints a JSON array.
|
|
2
|
+
|
|
3
|
+
Each item is {"kind", "command", "evidence"}. Only reads files; never runs anything. Standard library only.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import configparser
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
import tomllib
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _text(path: Path) -> str:
|
|
14
|
+
try:
|
|
15
|
+
return path.read_text(encoding="utf-8", errors="replace")
|
|
16
|
+
except OSError:
|
|
17
|
+
return ""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _python(root: Path) -> dict | None:
|
|
21
|
+
evidence = []
|
|
22
|
+
try:
|
|
23
|
+
pyproject = tomllib.loads(_text(root / "pyproject.toml")) if (root / "pyproject.toml").is_file() else {}
|
|
24
|
+
except tomllib.TOMLDecodeError:
|
|
25
|
+
pyproject = {}
|
|
26
|
+
if "pytest" in pyproject.get("tool", {}):
|
|
27
|
+
evidence.append("pyproject.toml 含 [tool.pytest]")
|
|
28
|
+
if (root / "pytest.ini").is_file():
|
|
29
|
+
evidence.append("pytest.ini")
|
|
30
|
+
for name, section in (("tox.ini", "pytest"), ("setup.cfg", "tool:pytest")):
|
|
31
|
+
if (root / name).is_file():
|
|
32
|
+
parser = configparser.ConfigParser(interpolation=None)
|
|
33
|
+
try:
|
|
34
|
+
parser.read_string(_text(root / name))
|
|
35
|
+
except configparser.Error:
|
|
36
|
+
continue
|
|
37
|
+
if parser.has_section(section):
|
|
38
|
+
evidence.append(f"{name} 含 [{section}]")
|
|
39
|
+
if (root / "tests").is_dir():
|
|
40
|
+
evidence.append("tests/ 目录")
|
|
41
|
+
if not evidence:
|
|
42
|
+
return None
|
|
43
|
+
python = "python"
|
|
44
|
+
for candidate in (".venv/Scripts/python.exe", ".venv/bin/python"):
|
|
45
|
+
if (root / candidate).is_file():
|
|
46
|
+
python = candidate.removesuffix(".exe")
|
|
47
|
+
evidence.append(f"{candidate} 存在")
|
|
48
|
+
break
|
|
49
|
+
return {"kind": "python", "command": f"{python} -m pytest", "evidence": ";".join(evidence)}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _node(root: Path) -> dict | None:
|
|
53
|
+
try:
|
|
54
|
+
package = json.loads(_text(root / "package.json")) if (root / "package.json").is_file() else {}
|
|
55
|
+
except ValueError:
|
|
56
|
+
return None
|
|
57
|
+
script = package.get("scripts", {}).get("test") if isinstance(package, dict) else None
|
|
58
|
+
if not isinstance(script, str) or not script.strip() or "no test specified" in script:
|
|
59
|
+
return None # npm init's placeholder fails on purpose; it is not a test suite.
|
|
60
|
+
for lock, tool in (("pnpm-lock.yaml", "pnpm"), ("yarn.lock", "yarn")):
|
|
61
|
+
if (root / lock).is_file():
|
|
62
|
+
return {"kind": "node", "command": f"{tool} test", "evidence": f"package.json 的 scripts.test;{lock}"}
|
|
63
|
+
return {"kind": "node", "command": "npm test", "evidence": "package.json 的 scripts.test"}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _jvm(root: Path) -> dict | None:
|
|
67
|
+
if (root / "pom.xml").is_file():
|
|
68
|
+
return {"kind": "maven", "command": "mvn test", "evidence": "pom.xml"}
|
|
69
|
+
for name in ("build.gradle", "build.gradle.kts"):
|
|
70
|
+
if (root / name).is_file():
|
|
71
|
+
wrapper = (root / "gradlew").is_file() or (root / "gradlew.bat").is_file()
|
|
72
|
+
return {
|
|
73
|
+
"kind": "gradle",
|
|
74
|
+
"command": "./gradlew test" if wrapper else "gradle test",
|
|
75
|
+
"evidence": name + (";gradlew" if wrapper else ""),
|
|
76
|
+
}
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def detect(root: Path) -> list[dict]:
|
|
81
|
+
found = [_python(root), _node(root)]
|
|
82
|
+
if (root / "go.mod").is_file():
|
|
83
|
+
found.append({"kind": "go", "command": "go test ./...", "evidence": "go.mod"})
|
|
84
|
+
if (root / "Cargo.toml").is_file():
|
|
85
|
+
found.append({"kind": "rust", "command": "cargo test", "evidence": "Cargo.toml"})
|
|
86
|
+
found.append(_jvm(root))
|
|
87
|
+
return [item for item in found if item is not None]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def main() -> None:
|
|
91
|
+
sys.stdin.read() # The arguments object; this tool takes none.
|
|
92
|
+
print(json.dumps(detect(Path.cwd()), ensure_ascii=False))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
if __name__ == "__main__":
|
|
96
|
+
main()
|