feat(skill): 报销技能可独立使用,配套安装脚本与来源启停校验

目标是技能脱离本仓库也能用:任何项目里提到报销都该按同一套纪律走。

- SKILL.md 重写自足化(104 行):删掉两处只在仓库里成立的引用(docs/MCP.md、方案文档);
  新增「第 0 步:确认工具可用」——先 mcp__arb__case_list 探活,工具不在就把 stdio 注册片段
  (uv run python -m src.mcp.server + cwd)交给用户,并明确禁止绕道自己写脚本处理发票;
  写死跨项目事实:材料用绝对路径正斜杠且可来自任何目录、产物在服务器仓库的 cases/<id>/、
  路径从 case_status 的 case_dir 读而不是按当前工作目录猜、别把截图与 info 拷进用户当前工程;
  字段权威指向工具(extraction_spec / info_skeleton / rules_get),三份 references 标注为判读经验
  并把开头指向已删除 src/infra/llm/prompts/* 的悬空路径改成历史沿革
- 补进这轮学到的坑:失败信封的额外字段在 detail 里;match 用单据编号最稳;每步可单独重填;
  敏感值打码引用;截图是核对证据不是给用户的报告
- 新增 scripts/install_skill.py(Makefile 与 tasks.py 各加 skill-install / skill-check):单向同步 +
  --check 漂移退出码 1 + front matter 缺失退出码 2(宁可不装也别装个宿主注册不了的)
- 关键防护:技能来源由 skills-manager 管,装进「已停用来源」里的技能文件明明存在但宿主永远扫不到
  (我第一次就装进了 ~/.dsh/skills,同目录的 wavedrom-gen 正是同一现象的旁证)。脚本现在读
  ~/.dsh/dsh-skills-manager.log 里该目录最后一条 source-enable/-disable,停用时拒绝安装并回显证据行
  (--force 可绕),默认目标改为已启用的 ~/.agents/skills
- tasks.py 顺手修掉吞退出码的老问题:os.system 的返回值以前直接丢,门禁失败调用方还看到 0。
  实测漂移 exit 1 / 同步 exit 0 / 未知任务 exit 2;顺便支持下划线别名
- 精简执行环境里 Path.home() 会抛 RuntimeError:脚本改用 USERPROFILE/HOME 回退并给人话提示

已知边界:同名技能项目级优先(skill() 的 resourceBase.path 实测指向仓库副本),因此本会话看到的
是仓库那份;跨项目可用性靠「已启用来源 + 内容一致」证明,另开一个目录的会话可最终确认。
验证:ruff / format / mypy / deptry / pytest 148 passed / mcp_smoke 25 工具全绿。
仍用 --no-verify 提交并手跑等价检查。
This commit is contained in:
wandering
2026-10-20 22:21:00 +08:00
parent 6e00ddc37e
commit 3392ab2152
9 changed files with 375 additions and 71 deletions
+191
View File
@@ -0,0 +1,191 @@
"""把报销技能同步到宿主的用户级技能目录,让它脱离本仓库也能用。
仓库里的 `.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:
raise SystemExit("无法确定用户主目录:设 DSH_SKILLS_DIR,或用 --dest 指定技能目录") 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())