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.
Files changed (91) hide show
  1. mosscode/__init__.py +10 -0
  2. mosscode/__main__.py +3 -0
  3. mosscode/agent.py +486 -0
  4. mosscode/agents/__init__.py +249 -0
  5. mosscode/agents/builtin/explore.md +16 -0
  6. mosscode/agents/builtin/general.md +12 -0
  7. mosscode/agents/builtin/plan.md +16 -0
  8. mosscode/agents/runtime.py +685 -0
  9. mosscode/agents/tasks.py +193 -0
  10. mosscode/appearance.py +184 -0
  11. mosscode/builtin_commands.py +1363 -0
  12. mosscode/cli.py +547 -0
  13. mosscode/commands.py +254 -0
  14. mosscode/compaction.py +259 -0
  15. mosscode/config.py +337 -0
  16. mosscode/context.py +102 -0
  17. mosscode/conversation.py +1046 -0
  18. mosscode/coordinator.py +54 -0
  19. mosscode/doctor.py +303 -0
  20. mosscode/hooks/__init__.py +22 -0
  21. mosscode/hooks/actions.py +151 -0
  22. mosscode/hooks/conditions.py +170 -0
  23. mosscode/hooks/config.py +404 -0
  24. mosscode/hooks/engine.py +401 -0
  25. mosscode/hooks/events.py +48 -0
  26. mosscode/launcher.py +177 -0
  27. mosscode/mcp_client.py +460 -0
  28. mosscode/mcp_config.py +216 -0
  29. mosscode/member_ui.py +289 -0
  30. mosscode/memory.py +291 -0
  31. mosscode/memory_extract.py +245 -0
  32. mosscode/messages.py +216 -0
  33. mosscode/paths.py +31 -0
  34. mosscode/permissions.py +733 -0
  35. mosscode/prompts.py +483 -0
  36. mosscode/providers.py +587 -0
  37. mosscode/scrollback.py +106 -0
  38. mosscode/security.py +282 -0
  39. mosscode/serialization.py +121 -0
  40. mosscode/session.py +563 -0
  41. mosscode/setup.py +248 -0
  42. mosscode/skills/__init__.py +455 -0
  43. mosscode/skills/builtin/commit/SKILL.md +15 -0
  44. mosscode/skills/builtin/review/SKILL.md +15 -0
  45. mosscode/skills/builtin/test/SKILL.md +13 -0
  46. mosscode/skills/builtin/test/scripts/detect.py +96 -0
  47. mosscode/skills/builtin/test/tool.json +9 -0
  48. mosscode/teams/__init__.py +216 -0
  49. mosscode/teams/backends.py +259 -0
  50. mosscode/teams/board.py +230 -0
  51. mosscode/teams/lock.py +173 -0
  52. mosscode/teams/mailbox.py +172 -0
  53. mosscode/teams/member.py +427 -0
  54. mosscode/teams/merge.py +207 -0
  55. mosscode/teams/pane.py +20 -0
  56. mosscode/teams/pane_app.py +167 -0
  57. mosscode/teams/process.py +371 -0
  58. mosscode/teams/runtime.py +1355 -0
  59. mosscode/teams/view.py +182 -0
  60. mosscode/tool_budget.py +86 -0
  61. mosscode/tools/__init__.py +38 -0
  62. mosscode/tools/agent.py +127 -0
  63. mosscode/tools/base.py +384 -0
  64. mosscode/tools/diffing.py +16 -0
  65. mosscode/tools/execution.py +99 -0
  66. mosscode/tools/files.py +287 -0
  67. mosscode/tools/memory.py +65 -0
  68. mosscode/tools/plan.py +30 -0
  69. mosscode/tools/process.py +216 -0
  70. mosscode/tools/search.py +229 -0
  71. mosscode/tools/search_worker.py +243 -0
  72. mosscode/tools/shell.py +121 -0
  73. mosscode/tools/skill.py +118 -0
  74. mosscode/tools/team.py +447 -0
  75. mosscode/tools/windows_job.py +75 -0
  76. mosscode/tui.py +2325 -0
  77. mosscode/usage.py +68 -0
  78. mosscode/windows_inline.py +148 -0
  79. mosscode/worktree/__init__.py +29 -0
  80. mosscode/worktree/changes.py +59 -0
  81. mosscode/worktree/git.py +125 -0
  82. mosscode/worktree/manager.py +462 -0
  83. mosscode/worktree/meta.py +82 -0
  84. mosscode/worktree/names.py +52 -0
  85. mosscode/worktree/setup.py +119 -0
  86. mosscode-0.1.0.dist-info/METADATA +92 -0
  87. mosscode-0.1.0.dist-info/RECORD +91 -0
  88. mosscode-0.1.0.dist-info/WHEEL +5 -0
  89. mosscode-0.1.0.dist-info/entry_points.txt +3 -0
  90. mosscode-0.1.0.dist-info/licenses/LICENSE +21 -0
  91. 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()
@@ -0,0 +1,9 @@
1
+ [
2
+ {
3
+ "name": "detect",
4
+ "description": "检测项目类型,返回建议的测试命令及依据(只读取项目文件,不运行测试)",
5
+ "input_schema": {"type": "object", "properties": {}, "additionalProperties": false},
6
+ "command": ["${PYTHON}", "${SKILL_DIR}/scripts/detect.py"],
7
+ "timeout": 30
8
+ }
9
+ ]