Files
Auto-Finance/scripts/install_skill.py
T
wandering 5dda126e21 feat(upload): 建会话即给这一单的手机直链,收件器地址改为显式配置
用户选了 B 方案。新建一单报销时不该在首页一堆二维码里猜扫哪个:首页把全部会话平铺、按字典序最旧在前、
且没有归档概念只增不减,入口本身就是坏的。现在 case_create 直接返回 upload_url。

- src/config.py 新增可选字段 upload_base_url(环境变量 UPLOAD_BASE_URL;留空才退回探测)
- src/cases.py:upload_page_url() 优先用配置;新增 CaseStore.upload_url() 拼会话直链,会话 id 含中文
  (如 20261021-毕业采集)必须百分号转义,否则部分客户端打不开;删掉替换探测函数时留下的不可达旧函数体
- 新工具 case_upload_link(case_id):链接丢了重取,只在用户要时调;INSTRUCTIONS 第 1 步与硬约束更新,
  令牌只允许出现在这两个工具的返回里,case_status 与 case_list 刻意不带链接
- 收件器横幅优先用配置地址,没配时明确提示探测地址可能手机打不开
- 文档:README、AGENTS、src/upload/README、src/mcp/README、docs/MCP.md 标准流程、操作指南 7.5、
  scripts/data/README、config.example.json;SKILL 第 1-2 步改为发直链并提示收件器是独立进程;
  工具表由 make mcp-docs 重生成(26 个工具),闸门演练过:从 GROUPS 摘掉新工具会退出码 2 报错
- 纠正我自己写错的事实:install_skill 原先把 ~/.agents/skills 写死成本机启用,而来源开关会变
  (本机今早 08:11 就翻回 ~/.dsh/skills)。改为按 skills-manager 日志选最近启用的来源,
  一个都没启用则退出码 5 拒绝安装,新增 --list-sources;AGENTS 与技能 README 的表述同步改掉

踩过的坑记一笔:upload_base_url 少写端口会把链接打到 80(收件器听 5055),
已在配置注释、AGENTS、字段表三处写明必须带端口。

测试:tests/test_upload_link.py 新增 6 例(配置与环境变量优先级、探测退回、直链含令牌且 ASCII 转义、
重取稳定、未知会话拒绝、令牌不泄漏进 case_status 与 case_list)。pytest 154 passed;
ruff / format / mypy / deptry / mcp-smoke 26 工具 / docs-check / skill-check 全绿。
端到端实测:拿生成的直链访问真实运行中的收件器返回 200 且页面是上传页。
仍用 --no-verify 提交并手跑等价检查。
2026-10-21 10:05:33 +08:00

222 lines
8.9 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, 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, stamp = "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:]
pos = line.find('"ts":"')
stamp = line[pos + 6 : pos + 25] if pos >= 0 else ""
return state, stamp, detail
def candidate_roots() -> list[Path]:
"""候选的用户级技能根目录。
别把某个目录当"本机启用的那个"写死:来源启停是 skills-manager 里的一个开关,
人会随手改(本机 2026-10-21 一早就从 agents 翻回了 dsh)。所以这里只列候选,
到底装哪儿由 source_state() 查日志决定。
"""
base = os.environ.get("DSH_SKILLS_DIR", "").strip()
if base:
return [Path(base)]
home = resolve_home()
return [home / ".dsh" / "skills", home / ".agents" / "skills"]
def default_dest() -> Path:
"""选最近一次被启用的来源;一个都没有就拒绝安装,而不是装进扫不到的目录。"""
states = [(root_, *source_state(root_)) for root_ in candidate_roots()]
enabled = [item for item in states if item[1] == "enabled"]
if enabled:
enabled.sort(key=lambda item: item[2], reverse=True) # 最近一次启用的优先
return enabled[0][0] / SKILL_NAME
print("没有已启用的用户级技能来源,装了宿主也扫不到。当前状态:", file=sys.stderr)
for item in states:
print(" " + item[1].ljust(9) + str(item[0]), file=sys.stderr)
print("在宿主的技能管理里启用其中一个来源,或用 --dest 指定目录、--list-sources 看完整记录。", file=sys.stderr)
raise SystemExit(5)
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="即使来源被停用也照装")
parser.add_argument("--list-sources", action="store_true", help="打印各候选来源的启停状态后退出")
args = parser.parse_args()
if args.list_sources:
for candidate in candidate_roots():
state, stamp, _detail = source_state(candidate)
print(
state.ljust(9)
+ str(candidate)
+ (" 最后事件 " + stamp if stamp else " (日志里没有这个来源的记录)")
)
return 0
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, _stamp, 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())