@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,419 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import os
6
+ import re
7
+ import stat
8
+ import sys
9
+ import tempfile
10
+ from collections import OrderedDict
11
+ from datetime import UTC, datetime
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ import context
16
+ import lifecycle
17
+ import versioned
18
+ from constants import KNOWN_PLACEHOLDERS
19
+
20
+
21
+ PLUGIN_ROOT = Path(__file__).resolve().parents[1]
22
+ TEMPLATE_ROOT = PLUGIN_ROOT / "templates"
23
+ PLUGIN_MANIFEST = PLUGIN_ROOT / "plugin.json"
24
+ OPINION_FILE = "OPINION.md"
25
+ ID_PATTERN = re.compile(r"^[a-z0-9]+(?:[.-][a-z0-9]+)*$")
26
+ VERSION_PATTERN = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+$")
27
+
28
+
29
+ class OpinionError(RuntimeError):
30
+ pass
31
+
32
+
33
+ def read_json_object(path: Path) -> dict[str, Any]:
34
+ metadata = path.lstat()
35
+ if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISREG(metadata.st_mode):
36
+ raise OpinionError(f"拒绝读取非普通文件:{path}")
37
+ value = json.loads(path.read_text(encoding="utf-8"))
38
+ if not isinstance(value, dict):
39
+ raise OpinionError(f"JSON 根节点必须是对象:{path}")
40
+ return value
41
+
42
+
43
+ def validate_rule(rule: Any, template_id: str) -> dict[str, Any]:
44
+ if not isinstance(rule, dict):
45
+ raise OpinionError(f"模板 {template_id} 的 rules 项必须是对象")
46
+ required = {"id", "title", "level", "scopes", "text"}
47
+ missing = required - set(rule)
48
+ if missing:
49
+ raise OpinionError(f"规则缺少字段 {sorted(missing)}:{template_id}")
50
+ unknown = set(rule) - required
51
+ if unknown:
52
+ raise OpinionError(f"规则包含未知字段 {sorted(unknown)}:{template_id}")
53
+ rule_id = rule["id"]
54
+ if not isinstance(rule_id, str) or not ID_PATTERN.fullmatch(rule_id):
55
+ raise OpinionError(f"规则编号无效:{rule_id!r}")
56
+ if rule["level"] not in {"required", "preferred"}:
57
+ raise OpinionError(f"规则 level 无效:{rule_id}")
58
+ if not isinstance(rule["title"], str) or not rule["title"].strip():
59
+ raise OpinionError(f"规则标题为空:{rule_id}")
60
+ if not isinstance(rule["text"], str) or not rule["text"].strip():
61
+ raise OpinionError(f"规则正文为空:{rule_id}")
62
+ scopes = rule["scopes"]
63
+ if not isinstance(scopes, list) or not scopes or not all(
64
+ isinstance(scope, str) and scope.strip() for scope in scopes
65
+ ) or len(scopes) != len(set(scopes)):
66
+ raise OpinionError(f"规则 scopes 必须是非空字符串数组:{rule_id}")
67
+ return rule
68
+
69
+
70
+ def validate_template(value: dict[str, Any], path: Path) -> dict[str, Any]:
71
+ required = {"id", "version", "name", "description", "scopes", "rules", "questions"}
72
+ missing = required - set(value)
73
+ if missing:
74
+ raise OpinionError(f"模板缺少字段 {sorted(missing)}:{path}")
75
+ unknown = set(value) - required
76
+ if unknown:
77
+ raise OpinionError(f"模板包含未知字段 {sorted(unknown)}:{path}")
78
+ template_id = value["id"]
79
+ if not isinstance(template_id, str) or not ID_PATTERN.fullmatch(template_id):
80
+ raise OpinionError(f"模板编号无效:{template_id!r}")
81
+ if path.stem != template_id:
82
+ raise OpinionError(f"模板文件名必须与编号一致:{path.name}")
83
+ if not isinstance(value["version"], str) or not VERSION_PATTERN.fullmatch(value["version"]):
84
+ raise OpinionError(f"模板版本必须使用三段数字:{template_id}")
85
+ for field in ("name", "description"):
86
+ if not isinstance(value[field], str) or not value[field].strip():
87
+ raise OpinionError(f"模板字段 {field} 不能为空:{template_id}")
88
+ if not isinstance(value["scopes"], list) or not value["scopes"] or not all(
89
+ isinstance(scope, str) and scope.strip() for scope in value["scopes"]
90
+ ) or len(value["scopes"]) != len(set(value["scopes"])):
91
+ raise OpinionError(f"模板 scopes 必须是非空数组:{template_id}")
92
+ rules = value["rules"]
93
+ if not isinstance(rules, list) or not rules:
94
+ raise OpinionError(f"模板 rules 必须是非空数组:{template_id}")
95
+ rule_ids: set[str] = set()
96
+ for rule in rules:
97
+ validated = validate_rule(rule, template_id)
98
+ if validated["id"] in rule_ids:
99
+ raise OpinionError(f"模板内存在重复规则编号:{validated['id']}")
100
+ rule_ids.add(validated["id"])
101
+ questions = value["questions"]
102
+ if not isinstance(questions, list):
103
+ raise OpinionError(f"模板 questions 必须是数组:{template_id}")
104
+ question_ids: set[str] = set()
105
+ for question in questions:
106
+ if not isinstance(question, dict) or set(question) != {"id", "prompt", "options"}:
107
+ raise OpinionError(f"模板问题结构无效:{template_id}")
108
+ if not isinstance(question["id"], str) or not ID_PATTERN.fullmatch(question["id"]):
109
+ raise OpinionError(f"模板问题编号无效:{template_id}")
110
+ if question["id"] in question_ids:
111
+ raise OpinionError(f"模板问题编号重复:{question['id']}")
112
+ question_ids.add(question["id"])
113
+ if not isinstance(question["prompt"], str) or not question["prompt"].strip():
114
+ raise OpinionError(f"模板问题正文为空:{template_id}")
115
+ if not isinstance(question["options"], list) or len(question["options"]) < 2:
116
+ raise OpinionError(f"模板问题至少需要两个选项:{template_id}")
117
+ for option in question["options"]:
118
+ if not isinstance(option, dict) or set(option) != {"label", "recommend"}:
119
+ raise OpinionError(f"模板问题选项结构无效:{template_id}")
120
+ if not isinstance(option["label"], str) or not option["label"].strip():
121
+ raise OpinionError(f"模板问题选项标签为空:{template_id}")
122
+ recommendations = option["recommend"]
123
+ if not isinstance(recommendations, list) or not recommendations or not all(
124
+ isinstance(recommendation, str) and recommendation in rule_ids
125
+ for recommendation in recommendations
126
+ ) or len(recommendations) != len(set(recommendations)):
127
+ raise OpinionError(f"模板问题引用了未知规则:{template_id}")
128
+ return value
129
+
130
+
131
+ def load_catalog() -> OrderedDict[str, dict[str, Any]]:
132
+ if not TEMPLATE_ROOT.is_dir() or TEMPLATE_ROOT.is_symlink():
133
+ raise OpinionError(f"模板目录不可用:{TEMPLATE_ROOT}")
134
+ catalog: OrderedDict[str, dict[str, Any]] = OrderedDict()
135
+ global_rules: dict[str, dict[str, Any]] = {}
136
+ for path in sorted(TEMPLATE_ROOT.glob("*.json")):
137
+ template = validate_template(read_json_object(path), path)
138
+ template_id = template["id"]
139
+ if template_id in catalog:
140
+ raise OpinionError(f"模板编号重复:{template_id}")
141
+ for rule in template["rules"]:
142
+ current = global_rules.get(rule["id"])
143
+ if current is not None and current != rule:
144
+ raise OpinionError(f"相同规则编号的内容不一致:{rule['id']}")
145
+ global_rules[rule["id"]] = rule
146
+ catalog[template_id] = template
147
+ return catalog
148
+
149
+
150
+ def parse_template_spec(spec: str, catalog: OrderedDict[str, dict[str, Any]]) -> str:
151
+ template_id, separator, version = spec.partition("@")
152
+ if template_id not in catalog:
153
+ raise OpinionError(f"未知模板:{template_id}")
154
+ current = catalog[template_id]["version"]
155
+ if separator and version != current:
156
+ raise OpinionError(f"模板版本不匹配:{template_id}@{version},当前版本为 {current}")
157
+ return template_id
158
+
159
+
160
+ def rule_index(catalog: OrderedDict[str, dict[str, Any]]) -> dict[str, tuple[str, dict[str, Any]]]:
161
+ indexed: dict[str, tuple[str, dict[str, Any]]] = {}
162
+ for template_id, template in catalog.items():
163
+ for rule in template["rules"]:
164
+ indexed.setdefault(rule["id"], (template_id, rule))
165
+ return indexed
166
+
167
+
168
+ def read_custom_file(path: Path | None) -> str | None:
169
+ if path is None:
170
+ return None
171
+ metadata = path.lstat()
172
+ if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISREG(metadata.st_mode):
173
+ raise OpinionError(f"自定义规则必须来自普通文件:{path}")
174
+ content = path.read_text(encoding="utf-8").strip()
175
+ if not content:
176
+ raise OpinionError("自定义规则文件为空")
177
+ return content
178
+
179
+
180
+ def select_rules(
181
+ catalog: OrderedDict[str, dict[str, Any]],
182
+ template_specs: list[str],
183
+ rule_ids: list[str],
184
+ ) -> tuple[OrderedDict[str, list[dict[str, Any]]], list[str]]:
185
+ selected: OrderedDict[str, list[dict[str, Any]]] = OrderedDict()
186
+ selected_ids: set[str] = set()
187
+ source_versions: OrderedDict[str, str] = OrderedDict()
188
+ for spec in template_specs:
189
+ template_id = parse_template_spec(spec, catalog)
190
+ template = catalog[template_id]
191
+ source_versions[template_id] = template["version"]
192
+ for rule in template["rules"]:
193
+ if rule["id"] not in selected_ids:
194
+ selected.setdefault(template_id, [])
195
+ selected[template_id].append(rule)
196
+ selected_ids.add(rule["id"])
197
+ indexed = rule_index(catalog)
198
+ for rule_id in rule_ids:
199
+ if rule_id not in indexed:
200
+ raise OpinionError(f"未知规则:{rule_id}")
201
+ template_id, rule = indexed[rule_id]
202
+ source_versions[template_id] = catalog[template_id]["version"]
203
+ if rule_id not in selected_ids:
204
+ selected.setdefault(template_id, [])
205
+ selected[template_id].append(rule)
206
+ selected_ids.add(rule_id)
207
+ sources = [f"{template_id}@{version}" for template_id, version in source_versions.items()]
208
+ return selected, sources
209
+
210
+
211
+ def selection_mode(template_specs: list[str], rule_ids: list[str], custom: str | None) -> str:
212
+ kinds = sum(bool(value) for value in (template_specs, rule_ids, custom))
213
+ if kinds > 1:
214
+ return "mixed"
215
+ if template_specs:
216
+ return "template"
217
+ if rule_ids:
218
+ return "guided"
219
+ return "custom"
220
+
221
+
222
+ def render_opinion(
223
+ catalog: OrderedDict[str, dict[str, Any]],
224
+ selected: OrderedDict[str, list[dict[str, Any]]],
225
+ sources: list[str],
226
+ custom: str | None,
227
+ mode: str,
228
+ ) -> tuple[str, dict[str, Any]]:
229
+ ordered_rule_ids = [rule["id"] for rules in selected.values() for rule in rules]
230
+ metadata = {
231
+ "schema_version": "1.0",
232
+ "provider": "opinion-manager",
233
+ "provider_version": read_json_object(PLUGIN_MANIFEST)["version"],
234
+ "generated_at": datetime.now(UTC).replace(microsecond=0).isoformat(),
235
+ "mode": mode,
236
+ "templates": sources,
237
+ "rules": ordered_rule_ids,
238
+ "custom": custom is not None,
239
+ }
240
+ lines = [
241
+ "<!-- opinion-manager:metadata",
242
+ json.dumps(metadata, ensure_ascii=False, indent=2),
243
+ "-->",
244
+ "",
245
+ "# Agent Opinion",
246
+ "",
247
+ "本文件是当前环境中用户确认的 Agent 行为、表达、判断与交付要求。",
248
+ ]
249
+ if custom:
250
+ lines.extend(("", "## 用户自定义规则", "", custom))
251
+ for template_id, rules in selected.items():
252
+ template = catalog[template_id]
253
+ lines.extend((
254
+ "",
255
+ f"## {template['name']}",
256
+ "",
257
+ f"> 模板:`{template_id}@{template['version']}`。{template['description']}",
258
+ ))
259
+ for level, title in (("required", "强制规则"), ("preferred", "偏好")):
260
+ matching = [rule for rule in rules if rule["level"] == level]
261
+ if not matching:
262
+ continue
263
+ lines.extend(("", f"### {title}", ""))
264
+ for rule in matching:
265
+ scopes = "、".join(rule["scopes"])
266
+ lines.extend((
267
+ f"<!-- opinion-rule:{rule['id']} -->",
268
+ f"- **{rule['title']}**(适用:{scopes}):{rule['text']}",
269
+ ))
270
+ return "\n".join(lines).rstrip() + "\n", metadata
271
+
272
+
273
+ def known_placeholder(content: str) -> bool:
274
+ return content in KNOWN_PLACEHOLDERS
275
+
276
+
277
+ def write_atomic(path: Path, content: str) -> None:
278
+ descriptor, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
279
+ temporary = Path(temporary_name)
280
+ try:
281
+ with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
282
+ handle.write(content)
283
+ handle.flush()
284
+ os.fsync(handle.fileno())
285
+ os.chmod(temporary, 0o644)
286
+ os.replace(temporary, path)
287
+ finally:
288
+ if temporary.exists():
289
+ temporary.unlink()
290
+
291
+
292
+ def apply_opinion(project: Path, content: str, *, replace: bool) -> str:
293
+ root = project.expanduser().resolve()
294
+ if not root.is_dir():
295
+ raise OpinionError(f"项目目录不存在:{root}")
296
+ destination = root / OPINION_FILE
297
+ if destination.is_symlink():
298
+ raise OpinionError(f"拒绝写入符号链接:{destination}")
299
+ if destination.exists():
300
+ if not destination.is_file():
301
+ raise OpinionError(f"目标路径不是普通文件:{destination}")
302
+ current = destination.read_text(encoding="utf-8")
303
+ if current == content:
304
+ return "unchanged"
305
+ if not replace and not known_placeholder(current):
306
+ raise OpinionError("OPINION.md 已包含内容;预览差异并明确使用 --replace")
307
+ write_atomic(destination, content)
308
+ if destination.read_text(encoding="utf-8") != content:
309
+ raise OpinionError("写入后核对 OPINION.md 失败")
310
+ return "written"
311
+
312
+
313
+ def catalog_markdown(catalog: OrderedDict[str, dict[str, Any]]) -> str:
314
+ lines = ["# Opinion 模板目录", ""]
315
+ if not catalog:
316
+ lines.append("当前没有已批准的公开模板。")
317
+ return "\n".join(lines).rstrip() + "\n"
318
+ for template in catalog.values():
319
+ identity = f"{template['family']}/{template['variant']}" if "family" in template else template["id"]
320
+ lines.extend((
321
+ f"## {template['name']} `{identity}@{template['version']}`",
322
+ "",
323
+ template["description"],
324
+ "",
325
+ f"规则数量:{len(template['rules'])};适用场景:{'、'.join(template['scopes'])}。",
326
+ "",
327
+ ))
328
+ return "\n".join(lines).rstrip() + "\n"
329
+
330
+
331
+ def catalog_payload(catalog: OrderedDict[str, dict[str, Any]]) -> dict[str, Any]:
332
+ return {
333
+ "ok": True,
334
+ "templates": list(catalog.values()),
335
+ }
336
+
337
+
338
+ def build_parser() -> argparse.ArgumentParser:
339
+ parser = argparse.ArgumentParser(description="管理版本化 Opinion 模板并生成统一 OPINION.md")
340
+ subparsers = parser.add_subparsers(dest="command", required=True)
341
+
342
+ catalog = subparsers.add_parser("catalog", help="查看模板、规则和引导问题")
343
+ catalog.add_argument("--output", choices=("json", "markdown"), default="markdown")
344
+
345
+ compose = subparsers.add_parser("compose", help="组合模板、规则和自定义内容")
346
+ compose.add_argument("--project", type=Path, required=True)
347
+ compose.add_argument("--template", action="append", default=[])
348
+ compose.add_argument("--rule", action="append", default=[])
349
+ compose.add_argument("--custom-file", type=Path)
350
+ compose.add_argument("--output", choices=("json", "markdown"), default="markdown")
351
+ compose.add_argument("--apply", action="store_true")
352
+ compose.add_argument("--replace", action="store_true")
353
+ versioned.add_commands(subparsers, compose, catalog)
354
+ context.add_command(subparsers)
355
+ lifecycle.add_command(subparsers)
356
+ return parser
357
+
358
+
359
+ def execute(args: argparse.Namespace) -> tuple[int, str]:
360
+ if args.command == "context":
361
+ return context.execute(args)
362
+ if args.command == "lifecycle":
363
+ return lifecycle.execute(args)
364
+ if versioned.handles(args):
365
+ return versioned.execute(args)
366
+ catalog = load_catalog()
367
+ if args.command == "catalog":
368
+ releases = versioned.load_catalog(versioned.PUBLIC_CATALOG)
369
+ combined = OrderedDict(catalog)
370
+ combined.update(releases["templates"])
371
+ if args.output == "json":
372
+ payload = catalog_payload(combined)
373
+ payload["rules"] = list(releases["rules"].values())
374
+ return 0, json.dumps(payload, ensure_ascii=False, indent=2) + "\n"
375
+ return 0, catalog_markdown(combined)
376
+
377
+ lock_path = args.project.expanduser() / "opinion.lock.json"
378
+ if lock_path.exists() or lock_path.is_symlink():
379
+ raise OpinionError("项目已经使用版本锁定;请用 compose --profile 更新")
380
+
381
+ custom = read_custom_file(args.custom_file)
382
+ if not args.template and not args.rule and custom is None:
383
+ raise OpinionError("至少选择一个模板、一个规则或一份自定义规则文件")
384
+ selected, sources = select_rules(catalog, args.template, args.rule)
385
+ mode = selection_mode(args.template, args.rule, custom)
386
+ content, metadata = render_opinion(catalog, selected, sources, custom, mode)
387
+ status = "preview"
388
+ if args.apply:
389
+ status = apply_opinion(args.project, content, replace=args.replace)
390
+ if args.output == "json":
391
+ payload = {
392
+ "ok": True,
393
+ "applied": args.apply,
394
+ "status": status,
395
+ "path": str(args.project.expanduser().resolve() / OPINION_FILE),
396
+ "metadata": metadata,
397
+ "content": content,
398
+ }
399
+ return 0, json.dumps(payload, ensure_ascii=False, indent=2) + "\n"
400
+ return 0, content
401
+
402
+
403
+ def main(argv: list[str] | None = None) -> int:
404
+ parser = build_parser()
405
+ args = parser.parse_args(argv)
406
+ try:
407
+ code, output = execute(args)
408
+ except (OpinionError, versioned.OpinionVersionError, OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
409
+ if getattr(args, "output", "markdown") == "json":
410
+ output = json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False, indent=2) + "\n"
411
+ else:
412
+ output = f"错误:{exc}\n"
413
+ code = 2
414
+ sys.stdout.write(output)
415
+ return code
416
+
417
+
418
+ if __name__ == "__main__":
419
+ raise SystemExit(main())