chcode 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.
@@ -0,0 +1,565 @@
1
+ """
2
+ Skills 发现和加载器(ps: 自 https://github.com/NanmiCoder/skills-agent-proto 二次开发 )
3
+
4
+ 演示 Skills 三层加载机制的核心实现:
5
+ - Level 1: scan_skills() - 扫描并加载所有 Skills 元数据到 system prompt
6
+ - Level 2: load_skill(skill_name: str) - 根据skill name加载指定 Skill 的详细指令(只返回 instructions - skill.md文档))
7
+ - Level 3: 由 bash tool 执行脚本(见 tools.py),大模型从指令中自己发现脚本
8
+
9
+ 核心设计理念:
10
+ 让大模型成为真正的"智能体",自己阅读指令、发现脚本、决定执行。
11
+ 代码层面不需要特殊处理脚本发现/执行逻辑。
12
+
13
+ Skills 目录结构:
14
+ my-skill/
15
+ ├── SKILL.md # 必需:指令和元数据
16
+ ├── scripts/ # 可选:可执行脚本
17
+ ├── references/ # 可选:参考文档
18
+ └── assets/ # 可选:模板和资源
19
+
20
+ SKILL.md 格式:
21
+ ---
22
+ name: skill-name
23
+ description: 何时使用此 skill 的描述
24
+ ---
25
+ # Skill Title
26
+ 详细指令内容...
27
+ """
28
+
29
+ import re
30
+ import zipfile
31
+ import tarfile
32
+ from pathlib import Path
33
+ from typing import Optional
34
+ from dataclasses import dataclass, field
35
+ import yaml
36
+
37
+
38
+ # 默认 Skills 搜索路径(项目级优先,用户级兜底)
39
+ DEFAULT_SKILL_PATHS = [
40
+ Path.cwd() / ".chat" / "skills", # 项目级 Skills (.chat/skills/) - 优先
41
+ Path.home() / ".chat" / "skills", # 用户级 Skills (~/.chat/skills/) - 兜底
42
+ ]
43
+
44
+
45
+ @dataclass
46
+ class SkillMetadata:
47
+ """
48
+ Skill 元数据(Level 1)
49
+
50
+ 启动时从 YAML frontmatter 解析,用于注入 system prompt。
51
+ 每个 skill 约 100 tokens。
52
+ """
53
+
54
+ name: str # skill 唯一名称
55
+ description: str # 何时使用此 skill 的描述
56
+ skill_path: Path # skill 目录路径
57
+
58
+ def to_prompt_line(self) -> str:
59
+ """生成 system prompt 中的单行描述"""
60
+ return f"- **{self.name}**: {self.description}"
61
+
62
+
63
+ @dataclass
64
+ class SkillContent:
65
+ """
66
+ Skill 完整内容(Level 2)
67
+
68
+ 用户请求匹配时加载,包含 SKILL.md 的完整指令。
69
+ 约 5k tokens。
70
+
71
+ 注意:不收集 scripts 和 additional_docs,让大模型从指令中自己发现。
72
+ 这是 Anthropic Skills 的核心设计理念。
73
+ """
74
+
75
+ metadata: SkillMetadata
76
+ instructions: str # SKILL.md body 内容
77
+
78
+
79
+ class SkillLoader:
80
+ """
81
+ Skills 加载器
82
+
83
+ 核心职责:
84
+ 1. scan_skills(): 发现文件系统中的 Skills,解析元数据
85
+ 2. load_skill(): 按需加载 Skill 详细内容
86
+ 3. build_system_prompt(): 生成包含 Skills 列表的 system prompt
87
+
88
+ 使用示例:
89
+ loader = SkillLoader()
90
+
91
+ # Level 1: 获取 system prompt
92
+ system_prompt = loader.build_system_prompt()
93
+
94
+ # Level 2: 加载具体 skill
95
+ skill = loader.load_skill("news-extractor")
96
+ print(skill.instructions)
97
+ """
98
+
99
+ def __init__(self, skill_paths: list[Path] | None = None):
100
+ """
101
+ 初始化加载器
102
+
103
+ Args:
104
+ skill_paths: 自定义 Skills 搜索路径,默认为:
105
+ - .claude/skills/ (项目级,优先)
106
+ - ~/.claude/skills/ (用户级,兜底)
107
+ """
108
+ self.skill_paths = skill_paths or DEFAULT_SKILL_PATHS
109
+ self._metadata_cache: dict[str, SkillMetadata] = {}
110
+ self._scan_cache: list[SkillMetadata] | None = None
111
+ self._dir_mtimes: dict[str, float] = {}
112
+ self._file_mtimes: dict[str, float] = {}
113
+
114
+ def _is_cache_valid(self) -> bool:
115
+ for base_path in self.skill_paths:
116
+ key = str(base_path)
117
+ try:
118
+ if base_path.exists():
119
+ dir_mtime = base_path.stat().st_mtime
120
+ if key not in self._dir_mtimes:
121
+ return False
122
+ if dir_mtime != self._dir_mtimes[key]:
123
+ return False
124
+ else:
125
+ if key in self._dir_mtimes:
126
+ return False
127
+ except OSError:
128
+ return False
129
+
130
+ for fpath, cached_mtime in self._file_mtimes.items():
131
+ try:
132
+ if (
133
+ not Path(fpath).exists()
134
+ or Path(fpath).stat().st_mtime != cached_mtime
135
+ ):
136
+ return False
137
+ except OSError:
138
+ return False
139
+
140
+ return True
141
+
142
+ def _save_mtimes(self) -> None:
143
+ self._dir_mtimes.clear()
144
+ self._file_mtimes.clear()
145
+ for base_path in self.skill_paths:
146
+ try:
147
+ if base_path.exists():
148
+ self._dir_mtimes[str(base_path)] = base_path.stat().st_mtime
149
+ for skill_dir in base_path.iterdir():
150
+ if skill_dir.is_dir():
151
+ skill_md = skill_dir / "SKILL.md"
152
+ if skill_md.exists():
153
+ self._file_mtimes[str(skill_md)] = (
154
+ skill_md.stat().st_mtime
155
+ )
156
+ except OSError:
157
+ pass
158
+
159
+ def scan_skills(self, *, force: bool = False) -> list[SkillMetadata]:
160
+ """
161
+ Level 1: 扫描所有 Skills 元数据
162
+
163
+ 遍历 skill_paths,查找包含 SKILL.md 的目录,
164
+ 解析 YAML frontmatter 提取 name 和 description。
165
+
166
+ Args:
167
+ force: 强制忽略缓存,重新扫描磁盘
168
+
169
+ Returns:
170
+ 所有发现的 Skills 元数据列表
171
+ """
172
+ if not force and self._scan_cache is not None and self._is_cache_valid():
173
+ return self._scan_cache
174
+
175
+ skills = []
176
+ seen_names = set()
177
+
178
+ for base_path in self.skill_paths:
179
+ if not base_path.exists():
180
+ continue
181
+
182
+ for skill_dir in base_path.iterdir():
183
+ if not skill_dir.is_dir():
184
+ continue
185
+
186
+ skill_md = skill_dir / "SKILL.md"
187
+ if not skill_md.exists():
188
+ continue
189
+
190
+ metadata = self._parse_skill_metadata(skill_md)
191
+ if metadata and metadata.name not in seen_names:
192
+ skills.append(metadata)
193
+ seen_names.add(metadata.name)
194
+ self._metadata_cache[metadata.name] = metadata
195
+
196
+ self._scan_cache = skills
197
+ self._save_mtimes()
198
+ return skills
199
+
200
+ # 解析skill元数据
201
+ def _parse_skill_metadata(self, skill_md_path: Path) -> Optional[SkillMetadata]:
202
+ """
203
+ 解析 SKILL.md 的 YAML frontmatter
204
+
205
+ SKILL.md 格式:
206
+ ---
207
+ name: skill-name
208
+ description: Brief description when to use it
209
+ ---
210
+ # Instructions...
211
+
212
+ Args:
213
+ skill_md_path: SKILL.md 文件路径
214
+
215
+ Returns:
216
+ 解析后的元数据,解析失败返回 None
217
+ """
218
+ try:
219
+ content = skill_md_path.read_text(encoding="utf-8")
220
+ except Exception:
221
+ return None
222
+
223
+ # 使用正则提取 YAML frontmatter
224
+ # 格式: ---\n...yaml...\n---
225
+ frontmatter_match = re.match(r"^---\s*\n(.*?)\n---\s*\n", content, re.DOTALL)
226
+
227
+ if not frontmatter_match:
228
+ return None
229
+
230
+ try:
231
+ # 解析 YAML
232
+ frontmatter = yaml.safe_load(
233
+ frontmatter_match.group(1)
234
+ ) # group:['匹配的完成的内容','第一个组的内容']
235
+ name = frontmatter.get("name", "")
236
+ description = frontmatter.get("description", "")
237
+
238
+ if not name:
239
+ return None
240
+
241
+ return SkillMetadata(
242
+ name=name,
243
+ description=description,
244
+ skill_path=skill_md_path.parent,
245
+ )
246
+ except yaml.YAMLError:
247
+ return None
248
+
249
+ # 从skill字典读取skill完整数据
250
+ def load_skill(self, skill_name: str) -> Optional[SkillContent]:
251
+ """
252
+ Level 2: 加载 Skill 完整内容
253
+
254
+ 读取 SKILL.md 的完整指令,以及其他 .md 文件和脚本列表。
255
+ 这是 load_skill tool 的核心实现。
256
+
257
+ Args:
258
+ skill_name: Skill 名称(如 "news-extractor")
259
+
260
+ Returns:
261
+ Skill 完整内容,未找到返回 None
262
+ """
263
+ # 先检查缓存
264
+ metadata = self._metadata_cache.get(skill_name)
265
+
266
+ # 原始冗余代码
267
+ if not metadata:
268
+ # 尝试重新扫描
269
+ self.scan_skills()
270
+ metadata = self._metadata_cache.get(skill_name)
271
+
272
+ if not metadata:
273
+ return None
274
+
275
+ # 读取 SKILL.md 完整内容
276
+ skill_md = metadata.skill_path / "SKILL.md"
277
+ try:
278
+ content = skill_md.read_text(encoding="utf-8")
279
+ except Exception:
280
+ return None
281
+
282
+ # 提取 body(去除 frontmatter)
283
+ body_match = re.match(r"^---\s*\n.*?\n---\s*\n(.*)$", content, re.DOTALL)
284
+ instructions = body_match.group(1).strip() if body_match else content
285
+
286
+ # 只返回 instructions,让大模型从指令中自己发现脚本和文档
287
+ return SkillContent(
288
+ metadata=metadata,
289
+ instructions=instructions,
290
+ )
291
+
292
+ def build_system_prompt(self, base_prompt: str = "") -> str:
293
+ """
294
+ 构建包含 Skills 列表的 system prompt
295
+
296
+ 这是 Level 1 的核心输出:将所有 Skills 的元数据
297
+ 注入到 system prompt 中。
298
+
299
+ Args:
300
+ base_prompt: 基础 system prompt(可选)
301
+
302
+ Returns:
303
+ 完整的 system prompt
304
+ """
305
+ skills = self.scan_skills()
306
+
307
+ # 构建 Skills 部分
308
+ if skills:
309
+ skills_section = "## Available Skills\n\n"
310
+ skills_section += "You have access to the following specialized skills:\n\n"
311
+ for skill in skills:
312
+ skills_section += skill.to_prompt_line() + "\n"
313
+ skills_section += "\n"
314
+ skills_section += "### How to Use Skills\n\n"
315
+ skills_section += "1. **Discover**: Review the skills list above\n"
316
+ skills_section += (
317
+ "2. **Load**: When a user request matches a skill's description, "
318
+ )
319
+ skills_section += (
320
+ "use `load_skill(skill_name)` to get detailed instructions\n"
321
+ )
322
+ skills_section += (
323
+ "3. **Execute**: Follow the skill's instructions, which may include "
324
+ )
325
+ skills_section += "running scripts via `bash`\n\n"
326
+ skills_section += "**Important**: Only load a skill when it's relevant to the user's request. "
327
+ skills_section += (
328
+ "Script code never enters the context - only their output does.\n"
329
+ )
330
+ else:
331
+ skills_section = "## Skills\n\nNo skills currently available.\n"
332
+
333
+ # 组合完整 prompt
334
+ if base_prompt:
335
+ return f"{base_prompt}\n\n{skills_section}"
336
+ else:
337
+ return f"You are a helpful coding assistant.\n\n{skills_section}"
338
+
339
+
340
+ def scan_all_skills(project_path: Path | None = None) -> list[dict]:
341
+ """扫描所有技能(项目级和全局级)
342
+
343
+ Args:
344
+ project_path: 项目路径,如果提供则同时扫描项目级技能
345
+
346
+ Returns:
347
+ 技能信息列表,每个技能包含 name, type, description, path
348
+ """
349
+ skills = []
350
+ loader = SkillLoader()
351
+
352
+ # 扫描项目级技能
353
+ if project_path:
354
+ project_skills_path = project_path / ".chat" / "skills"
355
+ if project_skills_path.exists():
356
+ project_skills = _scan_skills_in_path(project_skills_path, "项目", loader)
357
+ skills.extend(project_skills)
358
+
359
+ # 扫描全局技能
360
+ global_skills_path = Path.home() / ".chat" / "skills"
361
+ if global_skills_path.exists():
362
+ global_skills = _scan_skills_in_path(global_skills_path, "全局", loader)
363
+ skills.extend(global_skills)
364
+
365
+ return skills
366
+
367
+
368
+ def _scan_skills_in_path(
369
+ skills_path: Path, skill_type: str, loader: SkillLoader
370
+ ) -> list[dict]:
371
+ """扫描指定路径下的技能"""
372
+ skills = []
373
+
374
+ if not skills_path.exists():
375
+ return skills
376
+
377
+ for skill_dir in skills_path.iterdir():
378
+ if not skill_dir.is_dir():
379
+ continue
380
+
381
+ # 检查是否存在 SKILL.md
382
+ skill_md = skill_dir / "SKILL.md"
383
+ if not skill_md.exists():
384
+ continue
385
+
386
+ # 解析技能元数据
387
+ metadata = loader._parse_skill_metadata(skill_md)
388
+ if metadata:
389
+ skills.append(
390
+ {
391
+ "name": metadata.name,
392
+ "type": skill_type,
393
+ "description": metadata.description,
394
+ "path": str(skill_dir),
395
+ }
396
+ )
397
+
398
+ return skills
399
+
400
+
401
+ def _extract_archive(archive_path, target_path):
402
+ """安全解压压缩包,过滤路径穿越攻击"""
403
+ resolved_target = target_path.resolve()
404
+ if archive_path.endswith(".zip"):
405
+ with zipfile.ZipFile(archive_path, "r") as zip_ref:
406
+ for member in zip_ref.namelist():
407
+ if not (target_path / member).resolve().is_relative_to(resolved_target):
408
+ return False
409
+ zip_ref.extractall(target_path)
410
+ elif archive_path.endswith((".tar.gz", ".tgz")):
411
+ with tarfile.open(archive_path, "r:gz") as tar_ref:
412
+ for member in tar_ref.getmembers():
413
+ if (
414
+ not (target_path / member.name)
415
+ .resolve()
416
+ .is_relative_to(resolved_target)
417
+ ):
418
+ return False
419
+ tar_ref.extractall(target_path)
420
+ elif archive_path.endswith(".tar.bz2"):
421
+ with tarfile.open(archive_path, "r:bz2") as tar_ref:
422
+ for member in tar_ref.getmembers():
423
+ if (
424
+ not (target_path / member.name)
425
+ .resolve()
426
+ .is_relative_to(resolved_target)
427
+ ):
428
+ return False
429
+ tar_ref.extractall(target_path)
430
+ else:
431
+ return False
432
+ return True
433
+
434
+
435
+ def validate_skill_package(archive_path: str) -> dict | None:
436
+ """验证技能包是否有效
437
+
438
+ Args:
439
+ archive_path: 压缩包路径
440
+
441
+ Returns:
442
+ 技能信息字典或None(如果无效)
443
+ """
444
+ try:
445
+ import tempfile
446
+ from pathlib import Path
447
+
448
+ loader = SkillLoader()
449
+
450
+ with tempfile.TemporaryDirectory() as temp_dir:
451
+ temp_path = Path(temp_dir)
452
+
453
+ if not _extract_archive(archive_path, temp_path):
454
+ return None
455
+
456
+ # 查找包含SKILL.md的目录
457
+ skill_dir = _find_skill_dir(temp_path)
458
+ if not skill_dir:
459
+ return None
460
+
461
+ skill_md = skill_dir / "SKILL.md"
462
+ if not skill_md.exists():
463
+ return None
464
+
465
+ # 解析元数据
466
+ metadata = loader._parse_skill_metadata(skill_md)
467
+ if not metadata:
468
+ return None
469
+
470
+ return {
471
+ "name": metadata.name,
472
+ "description": metadata.description,
473
+ "source_path": str(skill_dir),
474
+ }
475
+
476
+ except Exception as e:
477
+ print(f"验证技能包失败: {e}")
478
+ return None
479
+
480
+
481
+ def _find_skill_dir(search_path: Path) -> Path | None:
482
+ """在搜索路径中查找包含SKILL.md的目录"""
483
+ # 首先检查根目录
484
+ if (search_path / "SKILL.md").exists():
485
+ return search_path
486
+
487
+ # 检查子目录
488
+ for item in search_path.iterdir():
489
+ if item.is_dir():
490
+ if (item / "SKILL.md").exists():
491
+ return item
492
+ # 递归检查更深层的目录
493
+ result = _find_skill_dir(item)
494
+ if result:
495
+ return result
496
+
497
+ return None
498
+
499
+
500
+ def install_skill(archive_path: str, install_path: Path) -> bool:
501
+ """安装技能到指定位置
502
+
503
+ Args:
504
+ archive_path: 压缩包路径
505
+ install_path: 安装目录路径
506
+
507
+ Returns:
508
+ 是否安装成功
509
+ """
510
+ try:
511
+ import tempfile
512
+ import shutil
513
+ from pathlib import Path
514
+
515
+ # 创建安装目录
516
+ install_path.mkdir(parents=True, exist_ok=True)
517
+
518
+ with tempfile.TemporaryDirectory() as temp_dir:
519
+ temp_path = Path(temp_dir)
520
+
521
+ if not _extract_archive(archive_path, temp_path):
522
+ return False
523
+
524
+ # 查找技能目录
525
+ skill_source_dir = _find_skill_dir(temp_path)
526
+ if not skill_source_dir:
527
+ return False
528
+
529
+ # 获取技能名称
530
+ skill_md = skill_source_dir / "SKILL.md"
531
+ loader = SkillLoader()
532
+ metadata = loader._parse_skill_metadata(skill_md)
533
+ if not metadata:
534
+ return False
535
+
536
+ skill_name = metadata.name
537
+ target_dir = install_path / skill_name
538
+
539
+ # 如果已存在,先删除
540
+ if target_dir.exists():
541
+ shutil.rmtree(target_dir)
542
+
543
+ # 复制到目标位置
544
+ shutil.copytree(skill_source_dir, target_dir)
545
+
546
+ return True
547
+
548
+ except Exception as e:
549
+ print(f"安装技能失败: {e}")
550
+ return False
551
+
552
+
553
+ @dataclass
554
+ class SkillAgentContext:
555
+ """
556
+ Agent 运行时上下文
557
+
558
+ 通过 ToolRuntime[SkillAgentContext] 在 tool 中访问
559
+ """
560
+
561
+ skill_loader: SkillLoader
562
+ model_config: dict
563
+ working_directory: Path
564
+ thread_id: str = ""
565
+ extra: dict = field(default_factory=dict)
@@ -0,0 +1,14 @@
1
+ def get_text_content(content: str | list) -> str:
2
+ """将消息内容转为纯文本,处理多模态消息的列表格式"""
3
+ if isinstance(content, str):
4
+ return content
5
+ if isinstance(content, list):
6
+ text_parts = []
7
+ for part in content:
8
+ if isinstance(part, dict):
9
+ if part.get("type") == "text":
10
+ text_parts.append(part.get("text", ""))
11
+ elif isinstance(part, str):
12
+ text_parts.append(part)
13
+ return "".join(text_parts).strip()
14
+ return str(content)