@sakanano-moe/agenthelm 6.5.3 → 6.7.0

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.
Files changed (112) hide show
  1. package/README.md +68 -0
  2. package/README.zh-CN.md +28 -0
  3. package/bin/agenthelm.js +1 -1
  4. package/package.json +2 -2
  5. package/runtime/index.json +80 -0
  6. package/runtime/plugins/agent-pack/.codex-plugin/plugin.json +26 -0
  7. package/runtime/plugins/agent-pack/.mcp.json +12 -0
  8. package/runtime/plugins/agent-pack/hooks/hooks.json +16 -0
  9. package/runtime/plugins/agent-pack/mcp.json +14 -0
  10. package/runtime/plugins/agent-pack/plugin.json +20 -0
  11. package/runtime/plugins/agent-pack/references/capability-config.md +148 -0
  12. package/runtime/plugins/agent-pack/runtime/agent_pack_claude.py +448 -0
  13. package/runtime/{agent_pack_config.py → plugins/agent-pack/runtime/agent_pack_config.py} +178 -22
  14. package/runtime/{agenthelm.py → plugins/agent-pack/runtime/agenthelm.py} +42 -1
  15. package/runtime/plugins/agent-pack/runtime/agenthelm_runtime.py +388 -0
  16. package/runtime/{mcp_server.py → plugins/agent-pack/runtime/mcp_server.py} +30 -8
  17. package/runtime/plugins/agent-pack/skills/agent-pack/SKILL.md +46 -0
  18. package/runtime/plugins/agents-init/.codex-plugin/plugin.json +22 -0
  19. package/runtime/plugins/agents-init/content.json +31 -0
  20. package/runtime/plugins/agents-init/plugin.json +19 -0
  21. package/runtime/plugins/agents-init/skills/agents-init/AGENT.template.md +376 -0
  22. package/runtime/plugins/agents-init/skills/agents-init/SKILL.md +119 -0
  23. package/runtime/plugins/agents-init/skills/agents-init/index.html +357 -0
  24. package/runtime/plugins/agents-init/skills/agents-init/launcher.sh +93 -0
  25. package/runtime/plugins/agents-init/skills/agents-init/project.schema.json +171 -0
  26. package/runtime/plugins/agents-init/skills/agents-init/scripts/init_project.py +1190 -0
  27. package/runtime/plugins/agents-init/skills/agents-init/scripts/test_init_project.py +678 -0
  28. package/runtime/plugins/agents-init/skills/agents-init/skill.json +122 -0
  29. package/runtime/plugins/opinion-manager/.codex-plugin/plugin.json +26 -0
  30. package/runtime/plugins/opinion-manager/catalog/README.md +15 -0
  31. package/runtime/plugins/opinion-manager/catalog/rules/.gitkeep +0 -0
  32. package/runtime/plugins/opinion-manager/catalog/rules/om.align-requirements/versions/1.0.0.json +11 -0
  33. package/runtime/plugins/opinion-manager/catalog/rules/om.assumptions-authority/versions/1.0.0.json +11 -0
  34. package/runtime/plugins/opinion-manager/catalog/rules/om.batch-questions/versions/1.0.0.json +11 -0
  35. package/runtime/plugins/opinion-manager/catalog/rules/om.bilingual-terms/versions/1.0.0.json +11 -0
  36. package/runtime/plugins/opinion-manager/catalog/rules/om.calculations/versions/1.0.0.json +11 -0
  37. package/runtime/plugins/opinion-manager/catalog/rules/om.chinese-response/versions/1.0.0.json +11 -0
  38. package/runtime/plugins/opinion-manager/catalog/rules/om.clean-deliverable/versions/1.0.0.json +11 -0
  39. package/runtime/plugins/opinion-manager/catalog/rules/om.complete-options/versions/1.0.0.json +11 -0
  40. package/runtime/plugins/opinion-manager/catalog/rules/om.conclusion-first/versions/1.0.0.json +11 -0
  41. package/runtime/plugins/opinion-manager/catalog/rules/om.concrete-titles/versions/1.0.0.json +11 -0
  42. package/runtime/plugins/opinion-manager/catalog/rules/om.consistent-items/versions/1.0.0.json +11 -0
  43. package/runtime/plugins/opinion-manager/catalog/rules/om.device-readable/versions/1.0.0.json +11 -0
  44. package/runtime/plugins/opinion-manager/catalog/rules/om.editable-diagrams/versions/1.0.0.json +11 -0
  45. package/runtime/plugins/opinion-manager/catalog/rules/om.evidence-inference/versions/1.0.0.json +11 -0
  46. package/runtime/plugins/opinion-manager/catalog/rules/om.maintenance-chinese/versions/1.0.0.json +11 -0
  47. package/runtime/plugins/opinion-manager/catalog/rules/om.maintenance-project-language/versions/1.0.0.json +11 -0
  48. package/runtime/plugins/opinion-manager/catalog/rules/om.markdown-structure/versions/1.0.0.json +11 -0
  49. package/runtime/plugins/opinion-manager/catalog/rules/om.portable-files/versions/1.0.0.json +11 -0
  50. package/runtime/plugins/opinion-manager/catalog/rules/om.report-context/versions/1.0.0.json +11 -0
  51. package/runtime/plugins/opinion-manager/catalog/rules/om.report-formats/versions/1.0.0.json +11 -0
  52. package/runtime/plugins/opinion-manager/catalog/rules/om.report-review/versions/1.0.0.json +11 -0
  53. package/runtime/plugins/opinion-manager/catalog/rules/om.response-brief/versions/1.0.0.json +11 -0
  54. package/runtime/plugins/opinion-manager/catalog/rules/om.response-dense/versions/1.0.0.json +11 -0
  55. package/runtime/plugins/opinion-manager/catalog/rules/om.rules-tools/versions/1.0.0.json +11 -0
  56. package/runtime/plugins/opinion-manager/catalog/rules/om.semantic-wrap/versions/1.0.0.json +11 -0
  57. package/runtime/plugins/opinion-manager/catalog/rules/om.solutions-proportional/versions/1.0.0.json +11 -0
  58. package/runtime/plugins/opinion-manager/catalog/rules/om.solutions-three-level/versions/1.0.0.json +11 -0
  59. package/runtime/plugins/opinion-manager/catalog/rules/om.tables-compact/versions/1.0.0.json +11 -0
  60. package/runtime/plugins/opinion-manager/catalog/rules/om.tables-list-first/versions/1.0.0.json +11 -0
  61. package/runtime/plugins/opinion-manager/catalog/rules/om.test-evidence/versions/1.0.0.json +11 -0
  62. package/runtime/plugins/opinion-manager/catalog/rules/om.versioned-files/versions/1.0.0.json +11 -0
  63. package/runtime/plugins/opinion-manager/catalog/templates/.gitkeep +0 -0
  64. package/runtime/plugins/opinion-manager/catalog/templates/delivery-standard/compact-tables/versions/1.0.0.json +21 -0
  65. package/runtime/plugins/opinion-manager/catalog/templates/delivery-standard/list-first/versions/1.0.0.json +21 -0
  66. package/runtime/plugins/opinion-manager/catalog/templates/development-standard/chinese-maintenance/versions/1.0.0.json +15 -0
  67. package/runtime/plugins/opinion-manager/catalog/templates/development-standard/project-language/versions/1.0.0.json +15 -0
  68. package/runtime/plugins/opinion-manager/catalog/templates/response-style/brief-chinese/versions/1.0.0.json +19 -0
  69. package/runtime/plugins/opinion-manager/catalog/templates/response-style/dense-chinese/versions/1.0.0.json +19 -0
  70. package/runtime/plugins/opinion-manager/catalog/templates/scenario/concise-project/versions/1.0.0.json +39 -0
  71. package/runtime/plugins/opinion-manager/catalog/templates/scenario/reviewed-project/versions/1.0.0.json +39 -0
  72. package/runtime/plugins/opinion-manager/catalog/templates/work-method/proportional/versions/1.0.0.json +20 -0
  73. package/runtime/plugins/opinion-manager/catalog/templates/work-method/three-level/versions/1.0.0.json +20 -0
  74. package/runtime/plugins/opinion-manager/content.json +63 -0
  75. package/runtime/plugins/opinion-manager/plugin.json +18 -0
  76. package/runtime/plugins/opinion-manager/references/lock.schema.json +14 -0
  77. package/runtime/plugins/opinion-manager/references/onboarding.md +57 -0
  78. package/runtime/plugins/opinion-manager/references/profile.schema.json +56 -0
  79. package/runtime/plugins/opinion-manager/references/rule.schema.json +16 -0
  80. package/runtime/plugins/opinion-manager/references/template.schema.json +71 -0
  81. package/runtime/plugins/opinion-manager/references/versioned-template.schema.json +19 -0
  82. package/runtime/plugins/opinion-manager/references/versioning.md +263 -0
  83. package/runtime/plugins/opinion-manager/runtime/constants.py +7 -0
  84. package/runtime/plugins/opinion-manager/runtime/context.py +180 -0
  85. package/runtime/plugins/opinion-manager/runtime/lifecycle.py +395 -0
  86. package/runtime/plugins/opinion-manager/runtime/opinion_manager.py +419 -0
  87. package/runtime/plugins/opinion-manager/runtime/versioned.py +705 -0
  88. package/runtime/plugins/opinion-manager/skills/opinion-manager/SKILL.md +62 -0
  89. package/runtime/plugins/opinion-manager/skills/opinion-manager/agents/openai.yaml +4 -0
  90. package/runtime/plugins/opinion-manager/templates/.gitkeep +1 -0
  91. package/runtime/plugins/project-orchestrator/.codex-plugin/plugin.json +24 -0
  92. package/runtime/plugins/project-orchestrator/content.json +37 -0
  93. package/runtime/plugins/project-orchestrator/plugin.json +19 -0
  94. package/runtime/plugins/project-orchestrator/references/asset-synonyms.example.json +8 -0
  95. package/runtime/plugins/project-orchestrator/references/case-workspaces.md +159 -0
  96. package/runtime/plugins/project-orchestrator/references/case.schema.json +31 -0
  97. package/runtime/plugins/project-orchestrator/runtime/assets.py +1288 -0
  98. package/runtime/plugins/project-orchestrator/runtime/case.py +557 -0
  99. package/runtime/plugins/project-orchestrator/runtime/case_workspace.py +350 -0
  100. package/runtime/plugins/project-orchestrator/runtime/memory.py +363 -0
  101. package/runtime/plugins/project-orchestrator/runtime/project_base.py +107 -0
  102. package/runtime/plugins/project-orchestrator/runtime/project_cli.py +599 -0
  103. package/runtime/plugins/project-orchestrator/runtime/project_demo.py +504 -0
  104. package/runtime/plugins/project-orchestrator/runtime/project_maintain.py +470 -0
  105. package/runtime/plugins/project-orchestrator/runtime/project_menu.py +394 -0
  106. package/runtime/plugins/project-orchestrator/runtime/project_real.py +522 -0
  107. package/runtime/plugins/project-orchestrator/runtime/project_write.py +329 -0
  108. package/runtime/plugins/project-orchestrator/runtime/wf.py +439 -0
  109. package/runtime/plugins/project-orchestrator/skills/project-orchestrator/SKILL.md +230 -0
  110. package/runtime/plugins/project-orchestrator/skills/project-orchestrator/agents/openai.yaml +4 -0
  111. package/runtime/plugins/project-orchestrator/templates/CASE.md +39 -0
  112. /package/runtime/{mcp_env_launcher.py → plugins/agent-pack/runtime/mcp_env_launcher.py} +0 -0
@@ -0,0 +1,329 @@
1
+ """项目入口的写入:记录事项与项目专用工具。
2
+
3
+ 写入一律先预览、确认后执行,执行后回读验证:
4
+
5
+ - 默认 Case 工具:经 case.py 的 plan_create / plan_update 生成新内容,写入前核对内容摘要与
6
+ ``updated_at``,别人刚改过就拒绝(退出码 5)。未知字段与其他小节原样保留。
7
+ - 项目声明了自己的事项工具:只有在 ``entry.items.write`` 为 true 时经它写入(约定见
8
+ case-workspaces.md「项目入口适配」);否则如实说明缺口,绝不绕过它直接改文件。
9
+ - 项目工具(tools.json):只运行明确声明、当前可用、由人选中并确认的那一个;参数列表直接
10
+ 执行,不经过 shell。
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import difflib
16
+ import hashlib
17
+ import json
18
+ import os
19
+ import re
20
+ import shutil
21
+ import subprocess
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ import case as case_tool
26
+ from project_base import DEFAULT_TOOLS, EntryError, inside
27
+
28
+ TOOL_ID_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
29
+ TOOL_TIMEOUT_SECONDS = 600
30
+ DIFF_LIMIT = 80
31
+
32
+
33
+ # ---------------------------------------------------------------- record
34
+
35
+ def record_capability(provider: Any) -> tuple[str, str]:
36
+ adapter = provider.entry["items"]
37
+ if adapter:
38
+ if adapter.get("write"):
39
+ return "available", f"经项目工具 {' '.join(adapter['command'])} 写入"
40
+ return "unavailable", f"项目事项工具 {' '.join(adapter['command'])} 没有声明写接口(entry.items.write)"
41
+ if provider.case_command:
42
+ return "unavailable", f"项目声明了自己的 Case 工具({provider.case_command}),本入口不绕过它写入"
43
+ if provider.work_mode != "case-workspace":
44
+ return "unavailable", "项目没有使用 Case 工作目录"
45
+ return "available", ""
46
+
47
+
48
+ def _gap(provider: Any) -> EntryError:
49
+ state, reason = record_capability(provider)
50
+ steps = [reason]
51
+ if provider.entry["items"] or provider.case_command:
52
+ steps.append("需要时请直接用项目自己的工具;或请项目维护者按 case-workspaces.md「项目入口适配」补上写接口")
53
+ return EntryError("这个项目不能在本入口记录事项", "unavailable", steps)
54
+
55
+
56
+ def _diff(path: str, current: str, new: str) -> list[str]:
57
+ lines = [line.rstrip("\n") for line in difflib.unified_diff(
58
+ current.splitlines(keepends=True), new.splitlines(keepends=True), lineterm="", n=1)][2:]
59
+ return lines[:DIFF_LIMIT] + ([f"…另有 {len(lines) - DIFF_LIMIT} 行"] if len(lines) > DIFF_LIMIT else [])
60
+
61
+
62
+ def _slug(change: dict) -> str:
63
+ raw = change.get("slug") or re.sub(r"[^a-z0-9]+", "-", str(change.get("title") or "").lower()).strip("-")
64
+ return raw or "item"
65
+
66
+
67
+ def _case_args(change: dict, **extra: Any) -> argparse.Namespace:
68
+ values = {"status": change.get("status"), "next_action": change.get("next_action"),
69
+ "current_state": None, "waiting_for": change.get("waiting_for"),
70
+ "resume_when": change.get("resume_when"), "title": None, "log": change.get("log"),
71
+ "decision": None, "result": None, "add_deliverable": list(change.get("add_deliverable") or []),
72
+ "add_evidence": [], "add_tag": [], "add_alias": [], "expect_updated_at": None, "expect_sha256": None}
73
+ values.update(extra)
74
+ return argparse.Namespace(**values)
75
+
76
+
77
+ def _new_args(change: dict) -> argparse.Namespace:
78
+ next_action = change.get("next_action") or ""
79
+ return argparse.Namespace(title=change.get("title"), slug=_slug(change), goal=change.get("goal"),
80
+ next_action=next_action, status="active" if next_action else "captured",
81
+ done_when=None, source=None, current_state=None, tag=[])
82
+
83
+
84
+ def _project_relative(root: Path, value: str) -> str:
85
+ """Accept an absolute path inside the project (as copied from 找资料) as well as a relative one."""
86
+ path = Path(value.strip())
87
+ if path.is_absolute():
88
+ resolved = path.resolve()
89
+ if root in resolved.parents:
90
+ return resolved.relative_to(root).as_posix()
91
+ return value.strip()
92
+
93
+
94
+ def _case_relative(root: Path, case_dir: Path, value: str) -> str:
95
+ """Files inside the Case are recorded relative to it, the way CASE.md links are written."""
96
+ absolute = (root / value).resolve()
97
+ return absolute.relative_to(case_dir).as_posix() if case_dir in absolute.parents else value
98
+
99
+
100
+ def preview(provider: Any, target: str | None, change: dict) -> dict:
101
+ state, _ = record_capability(provider)
102
+ if state != "available":
103
+ raise _gap(provider)
104
+ change = {**change, "add_deliverable": [_project_relative(provider.root, value)
105
+ for value in change.get("add_deliverable") or []]}
106
+ for path in change["add_deliverable"]:
107
+ inside(provider.root, path, "交付物路径")
108
+ if not (provider.root / path).exists():
109
+ raise EntryError(f"要登记的文件不存在:{path}", "usage", ["路径相对于项目根目录"])
110
+ if provider.entry["items"]:
111
+ return _tool_preview(provider, target, change)
112
+ try:
113
+ project = case_tool.Project(provider.root)
114
+ if target is None:
115
+ if not (change.get("title") and change.get("goal")):
116
+ raise EntryError("新建事项需要标题和目标", "usage")
117
+ case_id, text = case_tool.plan_create(project, _new_args(change))
118
+ path = f"{project.relative(project.case_root)}/{case_id}/CASE.md"
119
+ front = text.split("\n---\n", 1)[0].splitlines()[1:]
120
+ lines = [f"+{line}" for line in front] + [" (正文按 Case 模板生成:背景、下一动作、工作记录等小节)"]
121
+ return {"kind": "new", "target": None, "path": path, "lines": lines,
122
+ "expect_updated_at": None, "expect_sha256": None, "change": change, "simulated": False,
123
+ "note": "编号以写入时为准(同一天有人先建了事项时顺延)"}
124
+ item = provider.item(target)
125
+ case_dir = (provider.root / item["path"]).parent
126
+ change = {**change, "add_deliverable": [_case_relative(provider.root, case_dir, value)
127
+ for value in change.get("add_deliverable") or []]}
128
+ path, current, text, _ = case_tool.plan_update(project, _case_args(change, case=item["id"]))
129
+ except case_tool.CaseError as exc:
130
+ raise EntryError(str(exc), "conflict" if exc.code == "conflict" else "usage") from exc
131
+ return {"kind": "update", "target": item["id"], "path": project.relative(path),
132
+ "lines": _diff(project.relative(path), current, text), "expect_updated_at": item["updated_at"],
133
+ "expect_sha256": hashlib.sha256(current.encode("utf-8")).hexdigest(), "change": change,
134
+ "simulated": False}
135
+
136
+
137
+ def apply(provider: Any, planned: dict) -> dict:
138
+ if provider.entry["items"]:
139
+ return _tool_apply(provider, planned)
140
+ change = planned["change"]
141
+ try:
142
+ project = case_tool.Project(provider.root)
143
+ if planned["kind"] == "new":
144
+ result = case_tool.create(project, _new_args(change))
145
+ data, _ = case_tool.read_case(provider.root / result["path"])
146
+ verified = data.get("title") == change.get("title")
147
+ return {"path": result["path"], "id": result["id"], "simulated": False,
148
+ "verify": ("写入后重新读取:已建立 " if verified else "写入后读回不一致:") + result["path"]}
149
+ before, _ = case_tool.read_case(provider.root / planned["path"])
150
+ result = case_tool.update(project, _case_args(change, case=planned["target"],
151
+ expect_updated_at=planned["expect_updated_at"],
152
+ expect_sha256=planned["expect_sha256"]))
153
+ after, _ = case_tool.read_case(provider.root / planned["path"])
154
+ except case_tool.CaseError as exc:
155
+ if exc.code == "conflict":
156
+ raise EntryError("这个事项在你预览之后被修改过,没有写入", "conflict",
157
+ [str(exc), "重新打开预览,看过对方的改动后再提交"]) from exc
158
+ raise EntryError(str(exc), "usage") from exc
159
+ kept = [key for key in before if key in after]
160
+ return {"path": planned["path"], "id": result["id"], "simulated": False, "changed": result["changed"],
161
+ "verify": f"写入后重新读取:已更新 {'、'.join(result['changed'])};原有 {len(kept)} 个字段与其他小节保留"}
162
+
163
+
164
+ def _tool_flags(change: dict) -> list[str]:
165
+ flags = []
166
+ for key, flag in (("title", "--title"), ("goal", "--goal"), ("next_action", "--next-action"),
167
+ ("waiting_for", "--waiting-for"), ("status", "--status"), ("log", "--log")):
168
+ if change.get(key):
169
+ flags += [flag, str(change[key])]
170
+ for path in change.get("add_deliverable") or []:
171
+ flags += ["--add-deliverable", path]
172
+ return flags
173
+
174
+
175
+ def _run_items_tool(provider: Any, arguments: list[str]) -> tuple[int, dict]:
176
+ command = [*provider.entry["items"]["command"], *arguments, "--output", "json"]
177
+ try:
178
+ completed = subprocess.run(command, cwd=provider.root, capture_output=True, text=True, check=False,
179
+ timeout=120)
180
+ except (OSError, subprocess.TimeoutExpired) as exc:
181
+ raise EntryError(f"项目事项工具无法运行:{exc}", "failed", [" ".join(command)]) from exc
182
+ try:
183
+ payload = json.loads(completed.stdout or "{}")
184
+ except ValueError:
185
+ payload = {"error": (completed.stderr or completed.stdout).strip()[:300]}
186
+ return completed.returncode, payload if isinstance(payload, dict) else {}
187
+
188
+
189
+ def _tool_preview(provider: Any, target: str | None, change: dict) -> dict:
190
+ if target is None:
191
+ arguments = ["new", *_tool_flags(change), "--dry-run"]
192
+ item = None
193
+ else:
194
+ item = provider.item(target)
195
+ arguments = ["update", item["id"], *_tool_flags(change), "--dry-run"]
196
+ code, payload = _run_items_tool(provider, arguments)
197
+ if code != 0:
198
+ raise EntryError(f"项目事项工具拒绝了这次修改:{payload.get('error') or payload}", "usage")
199
+ lines = payload.get("preview") or (payload.get("diff") or "").splitlines() or ["(项目工具没有给出预览内容)"]
200
+ return {"kind": "new" if item is None else "update", "target": item["id"] if item else None,
201
+ "path": payload.get("path") or (item["path"] if item else ""), "lines": [str(line) for line in lines],
202
+ "expect_updated_at": item["updated_at"] if item else None, "expect_sha256": None, "change": change,
203
+ "simulated": False}
204
+
205
+
206
+ def _tool_apply(provider: Any, planned: dict) -> dict:
207
+ change = planned["change"]
208
+ if planned["kind"] == "new":
209
+ arguments = ["new", *_tool_flags(change)]
210
+ else:
211
+ arguments = ["update", planned["target"], *_tool_flags(change)]
212
+ if planned["expect_updated_at"]:
213
+ arguments += ["--expect-updated-at", planned["expect_updated_at"]]
214
+ code, payload = _run_items_tool(provider, arguments)
215
+ if code != 0:
216
+ if payload.get("code") == "conflict":
217
+ raise EntryError("这个事项在你预览之后被修改过,项目工具没有写入", "conflict",
218
+ ["重新打开预览,看过对方的改动后再提交"])
219
+ raise EntryError(f"项目事项工具写入失败:{payload.get('error') or payload}", "failed")
220
+ provider._items = None
221
+ return {"path": payload.get("path") or planned["path"], "simulated": False,
222
+ "verify": "项目工具已写入;重新读取列表:" + ("找到该事项" if _still_listed(provider, planned, payload)
223
+ else "列表里没有找到,请用项目工具核对")}
224
+
225
+
226
+ def _still_listed(provider: Any, planned: dict, payload: dict) -> bool:
227
+ wanted = payload.get("id") or planned["target"]
228
+ try:
229
+ return any(item["id"] == wanted for item in provider.items()["items"])
230
+ except EntryError:
231
+ return False
232
+
233
+
234
+ # ---------------------------------------------------------------- project tools
235
+
236
+ def load_tools(provider: Any) -> dict:
237
+ """Declared tools with their current availability; never runs anything."""
238
+ declared = provider.entry["tools"]
239
+ relative = declared or DEFAULT_TOOLS.as_posix()
240
+ try:
241
+ path = inside(provider.root, relative, "entry.tools")
242
+ except EntryError as exc:
243
+ return {"tools": [], "error": str(exc), "path": relative}
244
+ if not path.is_file():
245
+ return {"tools": [], "error": f"entry.tools 指向的文件不存在:{relative}" if declared else "",
246
+ "path": relative}
247
+ try:
248
+ data = json.loads(path.read_text(encoding="utf-8"))
249
+ raw = data["tools"]
250
+ if not isinstance(raw, list):
251
+ raise TypeError("tools 必须是列表")
252
+ tools = [_tool(provider, item) for item in raw]
253
+ except (OSError, UnicodeDecodeError, ValueError, KeyError, TypeError) as exc:
254
+ return {"tools": [], "error": f"{relative} 格式不对:{exc}", "path": relative}
255
+ ids = [tool["id"] for tool in tools]
256
+ if len(ids) != len(set(ids)):
257
+ return {"tools": [], "error": f"{relative} 里有重复的工具 id", "path": relative}
258
+ return {"tools": tools, "error": "", "path": relative}
259
+
260
+
261
+ def _tool(provider: Any, item: Any) -> dict:
262
+ if not isinstance(item, dict):
263
+ raise TypeError("每个工具必须是对象")
264
+ tool_id, command = item.get("id"), item.get("command")
265
+ if not isinstance(tool_id, str) or not TOOL_ID_RE.fullmatch(tool_id):
266
+ raise ValueError(f"工具 id 只能用小写字母、数字和连字符:{tool_id!r}")
267
+ if not (isinstance(command, list) and command and all(isinstance(part, str) and part for part in command)):
268
+ raise ValueError(f"{tool_id}: command 必须是非空参数列表(不经过 shell)")
269
+ requires = item.get("requires") or {}
270
+ commands = [str(value) for value in requires.get("commands", [command[0]])]
271
+ env_names = [str(value) for value in requires.get("env", [])]
272
+ writes = [str(value) for value in item.get("writes", [])]
273
+ network = bool(item.get("network", False))
274
+ missing = [name for name in commands if not _command_available(provider.root, name)]
275
+ unset = [name for name in env_names if not os.environ.get(name)]
276
+ reasons = ([f"缺少命令 {'、'.join(missing)}"] if missing else []) + \
277
+ ([f"缺少环境变量 {'、'.join(unset)}"] if unset else []) + \
278
+ (["当前离线"] if network and provider.offline else [])
279
+ return {"id": tool_id, "title": str(item.get("title") or tool_id), "description": str(item.get("description") or ""),
280
+ "command": command, "network": network, "account": "、".join(env_names) or None, "writes": writes,
281
+ "requires": commands + env_names, "state": "unavailable" if reasons else "available",
282
+ "reason": ";".join(reasons)}
283
+
284
+
285
+ def _command_available(root: Path, name: str) -> bool:
286
+ if Path(name).is_absolute():
287
+ return Path(name).is_file() and os.access(name, os.X_OK)
288
+ if "/" in name:
289
+ try:
290
+ candidate = inside(root, name, "工具命令")
291
+ except EntryError:
292
+ return False
293
+ return candidate.is_file()
294
+ return shutil.which(name) is not None
295
+
296
+
297
+ def check_tools(provider: Any, findings: list[dict], ok: list[str]) -> None:
298
+ loaded = load_tools(provider)
299
+ if loaded["error"]:
300
+ findings.append({"id": "tools-invalid", "level": "suggest", "area": "项目工具", "title": "项目工具声明有误",
301
+ "reason": loaded["error"], "impact": "项目工具暂不可用,其他功能不受影响",
302
+ "next": [{"label": f"修正 {loaded['path']}", "action": ""}]})
303
+ return
304
+ for tool in loaded["tools"]:
305
+ if tool["state"] != "available":
306
+ findings.append({"id": f"tool-{tool['id']}", "level": "unavailable", "area": "项目工具",
307
+ "title": f"“{tool['title']}”当前不可用", "reason": tool["reason"],
308
+ "impact": "只影响这一项工具", "next": [{"label": "补齐后重新检查", "action": ""}]})
309
+ if loaded["tools"]:
310
+ ok.append(f"项目工具({sum(tool['state'] == 'available' for tool in loaded['tools'])}/{len(loaded['tools'])} 可用)")
311
+
312
+
313
+ def run_tool(provider: Any, tool_id: str, *, capture: bool = True) -> dict:
314
+ tool = next((tool for tool in load_tools(provider)["tools"] if tool["id"] == tool_id), None)
315
+ if tool is None:
316
+ raise EntryError(f"项目没有声明这个工具:{tool_id}", "not-found")
317
+ if tool["state"] != "available":
318
+ raise EntryError(f"“{tool['title']}”当前不可用", "unavailable", [tool["reason"]])
319
+ try:
320
+ completed = subprocess.run(tool["command"], cwd=provider.root, check=False, timeout=TOOL_TIMEOUT_SECONDS,
321
+ capture_output=capture, text=True)
322
+ except (OSError, subprocess.TimeoutExpired) as exc:
323
+ raise EntryError(f"“{tool['title']}”无法运行:{exc}", "failed") from exc
324
+ output = ((completed.stdout or "") + (completed.stderr or "")).strip() if capture else ""
325
+ if completed.returncode != 0:
326
+ raise EntryError(f"“{tool['title']}”运行失败(退出码 {completed.returncode})", "failed",
327
+ [line for line in output.splitlines()[-5:]] or ["没有输出"])
328
+ return {"id": tool_id, "simulated": False, "exit_code": completed.returncode, "output": output[-2000:],
329
+ "message": f"已运行:{' '.join(tool['command'])}" + (f"\n{output[-1200:]}" if output else "")}