5dda126e21
用户选了 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 提交并手跑等价检查。
222 lines
8.9 KiB
Python
222 lines
8.9 KiB
Python
"""把报销技能同步到宿主的用户级技能目录,让它脱离本仓库也能用。
|
||
|
||
仓库里的 `.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())
|