Files
Auto-Finance/scripts/install_skill.py
T
wandering 821822a408 docs: 全面体检并更新文档,工具参考改为从运行时 schema 生成
先体检再动手:44 个 Markdown,相对链接 0 处失效,逐份分辨「现状文档」与「历史记录」。
历史记录(error-experience/*、架构分析、已过时的全景图)保留原貌只加指路,不重写。

- 新增 scripts/gen_mcp_docs.py:起 stdio 子进程 list_tools 后整段重生成 docs/MCP.md 的「工具参考」
  (291 行 / 25 个工具,占这份文档四分之三,手工维护必漂)。--check 漂移退出码 1,--diff 打印差异;
  GROUPS 是刻意闸门——加了工具没登记就报错,防止「有工具没文档」。接进 make mcp-docs / docs-check
  与 tasks.py 同名任务
- 生成时抓到两处真实漂移:invoice_type 线上是 string/null 且默认 null,文档手写成 null/string 无默认;
  case_list 无参数在旧文档里写成一句散文。现已与线上一字不差
- 过期说法清理:src/README 的「browser_* 与 submit_* 尚未实现」(browser 早已联机验通、submit 定案不做);
  tests/README 的「当前 107 例」(现 148)与缺 test_mcp_browser.py 一行、「Playwright 填报不进单测」
  (现在选择器分支/状态判定/错误路径有单测,只有真实页面需要联机);.agents/README 的「Cursor Agent」;
  plans/README 里还写着「提交加确认 token」;scripts/data/README 表格有一行跑到正文后面
- 工程实践指南里把已删除的 CLI 入口当例子:mypy src/main.py、make run、python src/main.py 全部改为现状
  (mypy src / 唯一入口 python -m src.mcp.server),并补上「任务必须透传退出码」这条本轮踩到的坑
- README:「还没做完」重写为「边界与未完事项」——把刻意不做(提交、submit_* 整类、删除、撤回)与
  真的未完(科目/项目/人员固定取第 N 行,待业务口径)分开写;开发命令补 mcp-docs / docs-check / skill-install
- 操作指南:7.3 经济科目与项目从「要改就改索引」改成「已知限制,等口径,填报后务必人工核对」;
  相关命令补齐新目标;MCP.md 标准流程补上填报与「到此为止,提交由人」两步,并改掉与新纪律矛盾的
  「把截图报给用户」表述
- AGENTS:命令表加两行;测试段给出改工具后的固定顺序(mcp-smoke → mcp-docs → 重连宿主);提交纪律
  补「make check 之外还有 docs-check / skill-check 两道专项门禁」;截图产物改标注为排查证据
- install_skill.py:拿不到主目录时退出码改 4,别和「漂移」的 1 混成一类
- 已核对:submit_execute / src/main.py 在文档里的残留都是禁止性表述或「已删除」清单项,不是现状描述

验证:ruff / format --check / mypy src / deptry / pytest 148 passed / mcp-smoke 25 工具 /
docs-check 与 skill-check 均退出码 0。--no-verify 提交并手跑等价检查。
2026-10-20 23:12:46 +08:00

193 lines
7.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""把报销技能同步到宿主的用户级技能目录,让它脱离本仓库也能用。
仓库里的 `.agents/skills/reimbursement/` 是**唯一真源**,本脚本只做单向同步(不反向编辑安装副本,
否则下次同步会被覆盖)。装完要重连宿主才会重新扫描技能目录。
用法:
uv run python scripts/install_skill.py # 安装 / 更新
uv run python scripts/install_skill.py --check # 只比对;有漂移退出码 1
uv run python scripts/install_skill.py --dest D:/x/skills # 指定别的技能目录(会加 /reimbursement)
uv run python scripts/install_skill.py --dest D:/x/skills # 指定别的技能目录(会加 /reimbursement)
"""
from __future__ import annotations
import argparse
import hashlib
import os
import shutil
import sys
from pathlib import Path
SKILL_NAME = "reimbursement"
REPO_ROOT = Path(__file__).resolve().parents[1]
SOURCE = REPO_ROOT / ".agents" / "skills" / SKILL_NAME
def resolve_home() -> Path:
"""主目录:环境变量优先。精简过的进程环境里 Path.home() 会抛 RuntimeError。"""
for key in ("USERPROFILE", "HOME", "HOMEDRIVE_HOME"):
raw = os.environ.get(key, "").strip()
if raw:
if key == "HOMEDRIVE_HOME":
raw = os.path.join(os.environ.get("HOMEDRIVE", "").strip(), os.environ.get("HOME", "").strip())
if raw and Path(raw).is_absolute():
return Path(raw)
try:
return Path.home()
except RuntimeError:
print("无法确定用户主目录:设 DSH_SKILLS_DIR,或用 --dest 指定技能目录", file=sys.stderr)
raise SystemExit(4) from None
def norm(path_str: str) -> str:
"""日志里的路径是 JSON 转义过的双反斜杠,统一成小写正斜杠再比。"""
return "".join(ch for ch in path_str if ch != chr(92)).replace("//", "/").lower().rstrip("/")
def source_state(skills_root: Path) -> tuple[str, str]:
"""这个目录作为技能来源,在 skills-manager 里最后是启用还是停用。
踩过的坑:装进了一个被停用的来源目录(~/.dsh/skills),文件明明在,宿主却永远扫不到,
比没装更难发现。所以装之前先查最后一条形同实质的记录。
"""
log = resolve_home() / ".dsh" / "dsh-skills-manager.log"
if not log.is_file():
return "unknown", ""
want = norm(str(skills_root))
state, detail = "unknown", ""
try:
lines = log.read_text(encoding="utf-8", errors="replace").splitlines()
except OSError:
return "unknown", ""
for line in lines:
if "source-enable" not in line and "source-disable" not in line:
continue
if want and want in norm(line):
state = "disabled" if "source-disable" in line else "enabled"
detail = line[-90:]
return state, detail
def default_dest() -> Path:
"""用户级技能目录:DSH_SKILLS_DIR > ~/.agents/skills(跨工具约定,本机已启用)> ~/.dsh/skills。"""
base = os.environ.get("DSH_SKILLS_DIR", "").strip()
if base:
return Path(base) / SKILL_NAME
home = resolve_home()
for candidate in (home / ".agents" / "skills", home / ".dsh" / "skills"):
if candidate.is_dir():
return candidate / SKILL_NAME
return (home / ".agents" / "skills") / SKILL_NAME
def collect(base: Path) -> list[Path]:
"""技能目录里要同步的文件:SKILL.md 与 references/ 下的 Markdown。"""
files: list[Path] = []
top = base / "SKILL.md"
if top.is_file():
files.append(top)
ref = base / "references"
if ref.is_dir():
files.extend(sorted(p for p in ref.glob("*.md") if p.is_file()))
return files
def digest(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def validate_source() -> str:
"""源目录健康检查:宁可现在报错,也别把一个没有 front matter 的技能装上去。"""
if not SOURCE.is_dir():
return f"源目录不存在:{SOURCE}"
skill_md = SOURCE / "SKILL.md"
if not skill_md.is_file():
return f"缺少 SKILL.md:{skill_md}"
text = skill_md.read_text(encoding="utf-8")
if not text.startswith("---\n"):
return "SKILL.md 缺少 YAML front matter(宿主靠它注册技能)"
front = text.split("---", 2)[1] if text.count("---") >= 2 else ""
if "name:" not in front or "description:" not in front:
return "front matter 里必须有 name 与 description"
return ""
def main() -> int:
parser = argparse.ArgumentParser(description="同步报销技能到用户级技能目录")
parser.add_argument("--dest", default="", help="目标技能目录(默认 ~/.dsh/skills/reimbursement)")
parser.add_argument("--check", action="store_true", help="只比对不写入")
parser.add_argument("--force", action="store_true", help="即使来源被停用也照装")
args = parser.parse_args()
problem = validate_source()
if problem:
print(f"源技能有问题:{problem}", file=sys.stderr)
return 2
dest = Path(args.dest).expanduser() if args.dest else default_dest()
if args.dest and dest.name != SKILL_NAME:
dest = dest / SKILL_NAME
state, detail = source_state(dest.parent)
if state == "disabled":
print(
f"警告:{dest.parent} 这个技能来源在 skills-manager 里是【停用】状态,装进去宿主也扫不到。"
f"(最后一条记录:{detail})改目标目录或用 --dest 指定已启用的来源。",
file=sys.stderr,
)
if not args.force:
return 3
elif state == "enabled":
print(f"技能来源已启用:{dest.parent}")
else:
print(f"(查不到 {dest.parent} 的来源启停记录,按已启用处理)")
new: list[str] = []
changed: list[str] = []
stale: list[str] = []
for src in collect(SOURCE):
rel = src.relative_to(SOURCE)
target = dest / rel
if not target.is_file():
new.append(str(rel))
elif digest(target) != digest(src):
changed.append(str(rel))
src_names = {str(p.relative_to(SOURCE)) for p in collect(SOURCE)}
if dest.is_dir():
for existing in collect(dest):
rel = str(existing.relative_to(dest))
if rel not in src_names:
stale.append(rel)
same = len(collect(SOURCE)) - len(new) - len(changed)
if not new and not changed and not stale:
print(f"已是最新:{dest}({same} 个文件一致)")
return 0
print(f"{'检查' if args.check else '同步'}:{SOURCE} -> {dest}")
for n in new:
print(f" 新增 {n}")
for n in changed:
print(f" 更新 {n}")
for n in stale:
print(f" 多余 {n}(安装目录里有、源里没有,需手工删)")
if args.check:
print("存在漂移:跑 uv run python scripts/install_skill.py 同步", file=sys.stderr)
return 1
for src in collect(SOURCE):
rel = src.relative_to(SOURCE)
target = dest / rel
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, target)
print(f"已写入 {len(collect(SOURCE))} 个文件。重连宿主后生效。")
if (REPO_ROOT / ".agents" / "skills" / SKILL_NAME).is_dir():
print(f"提示:项目内同名技能仍在 .agents/skills/{SKILL_NAME}/,两边内容现在一致。")
return 0
if __name__ == "__main__":
raise SystemExit(main())