@sakanano-moe/agenthelm 6.5.3 → 6.6.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 (109) hide show
  1. package/bin/agenthelm.js +1 -1
  2. package/package.json +2 -2
  3. package/runtime/index.json +80 -0
  4. package/runtime/plugins/agent-pack/.codex-plugin/plugin.json +19 -0
  5. package/runtime/plugins/agent-pack/.mcp.json +12 -0
  6. package/runtime/plugins/agent-pack/hooks/hooks.json +16 -0
  7. package/runtime/plugins/agent-pack/mcp.json +14 -0
  8. package/runtime/plugins/agent-pack/plugin.json +20 -0
  9. package/runtime/plugins/agent-pack/references/capability-config.md +124 -0
  10. package/runtime/{agenthelm.py → plugins/agent-pack/runtime/agenthelm.py} +42 -1
  11. package/runtime/plugins/agent-pack/runtime/agenthelm_runtime.py +388 -0
  12. package/runtime/plugins/agent-pack/skills/agent-pack/SKILL.md +46 -0
  13. package/runtime/plugins/agents-init/.codex-plugin/plugin.json +22 -0
  14. package/runtime/plugins/agents-init/content.json +31 -0
  15. package/runtime/plugins/agents-init/plugin.json +19 -0
  16. package/runtime/plugins/agents-init/skills/agents-init/AGENT.template.md +376 -0
  17. package/runtime/plugins/agents-init/skills/agents-init/SKILL.md +119 -0
  18. package/runtime/plugins/agents-init/skills/agents-init/index.html +357 -0
  19. package/runtime/plugins/agents-init/skills/agents-init/launcher.sh +93 -0
  20. package/runtime/plugins/agents-init/skills/agents-init/project.schema.json +171 -0
  21. package/runtime/plugins/agents-init/skills/agents-init/scripts/init_project.py +1190 -0
  22. package/runtime/plugins/agents-init/skills/agents-init/scripts/test_init_project.py +678 -0
  23. package/runtime/plugins/agents-init/skills/agents-init/skill.json +122 -0
  24. package/runtime/plugins/opinion-manager/.codex-plugin/plugin.json +26 -0
  25. package/runtime/plugins/opinion-manager/catalog/README.md +15 -0
  26. package/runtime/plugins/opinion-manager/catalog/rules/.gitkeep +0 -0
  27. package/runtime/plugins/opinion-manager/catalog/rules/om.align-requirements/versions/1.0.0.json +11 -0
  28. package/runtime/plugins/opinion-manager/catalog/rules/om.assumptions-authority/versions/1.0.0.json +11 -0
  29. package/runtime/plugins/opinion-manager/catalog/rules/om.batch-questions/versions/1.0.0.json +11 -0
  30. package/runtime/plugins/opinion-manager/catalog/rules/om.bilingual-terms/versions/1.0.0.json +11 -0
  31. package/runtime/plugins/opinion-manager/catalog/rules/om.calculations/versions/1.0.0.json +11 -0
  32. package/runtime/plugins/opinion-manager/catalog/rules/om.chinese-response/versions/1.0.0.json +11 -0
  33. package/runtime/plugins/opinion-manager/catalog/rules/om.clean-deliverable/versions/1.0.0.json +11 -0
  34. package/runtime/plugins/opinion-manager/catalog/rules/om.complete-options/versions/1.0.0.json +11 -0
  35. package/runtime/plugins/opinion-manager/catalog/rules/om.conclusion-first/versions/1.0.0.json +11 -0
  36. package/runtime/plugins/opinion-manager/catalog/rules/om.concrete-titles/versions/1.0.0.json +11 -0
  37. package/runtime/plugins/opinion-manager/catalog/rules/om.consistent-items/versions/1.0.0.json +11 -0
  38. package/runtime/plugins/opinion-manager/catalog/rules/om.device-readable/versions/1.0.0.json +11 -0
  39. package/runtime/plugins/opinion-manager/catalog/rules/om.editable-diagrams/versions/1.0.0.json +11 -0
  40. package/runtime/plugins/opinion-manager/catalog/rules/om.evidence-inference/versions/1.0.0.json +11 -0
  41. package/runtime/plugins/opinion-manager/catalog/rules/om.maintenance-chinese/versions/1.0.0.json +11 -0
  42. package/runtime/plugins/opinion-manager/catalog/rules/om.maintenance-project-language/versions/1.0.0.json +11 -0
  43. package/runtime/plugins/opinion-manager/catalog/rules/om.markdown-structure/versions/1.0.0.json +11 -0
  44. package/runtime/plugins/opinion-manager/catalog/rules/om.portable-files/versions/1.0.0.json +11 -0
  45. package/runtime/plugins/opinion-manager/catalog/rules/om.report-context/versions/1.0.0.json +11 -0
  46. package/runtime/plugins/opinion-manager/catalog/rules/om.report-formats/versions/1.0.0.json +11 -0
  47. package/runtime/plugins/opinion-manager/catalog/rules/om.report-review/versions/1.0.0.json +11 -0
  48. package/runtime/plugins/opinion-manager/catalog/rules/om.response-brief/versions/1.0.0.json +11 -0
  49. package/runtime/plugins/opinion-manager/catalog/rules/om.response-dense/versions/1.0.0.json +11 -0
  50. package/runtime/plugins/opinion-manager/catalog/rules/om.rules-tools/versions/1.0.0.json +11 -0
  51. package/runtime/plugins/opinion-manager/catalog/rules/om.semantic-wrap/versions/1.0.0.json +11 -0
  52. package/runtime/plugins/opinion-manager/catalog/rules/om.solutions-proportional/versions/1.0.0.json +11 -0
  53. package/runtime/plugins/opinion-manager/catalog/rules/om.solutions-three-level/versions/1.0.0.json +11 -0
  54. package/runtime/plugins/opinion-manager/catalog/rules/om.tables-compact/versions/1.0.0.json +11 -0
  55. package/runtime/plugins/opinion-manager/catalog/rules/om.tables-list-first/versions/1.0.0.json +11 -0
  56. package/runtime/plugins/opinion-manager/catalog/rules/om.test-evidence/versions/1.0.0.json +11 -0
  57. package/runtime/plugins/opinion-manager/catalog/rules/om.versioned-files/versions/1.0.0.json +11 -0
  58. package/runtime/plugins/opinion-manager/catalog/templates/.gitkeep +0 -0
  59. package/runtime/plugins/opinion-manager/catalog/templates/delivery-standard/compact-tables/versions/1.0.0.json +21 -0
  60. package/runtime/plugins/opinion-manager/catalog/templates/delivery-standard/list-first/versions/1.0.0.json +21 -0
  61. package/runtime/plugins/opinion-manager/catalog/templates/development-standard/chinese-maintenance/versions/1.0.0.json +15 -0
  62. package/runtime/plugins/opinion-manager/catalog/templates/development-standard/project-language/versions/1.0.0.json +15 -0
  63. package/runtime/plugins/opinion-manager/catalog/templates/response-style/brief-chinese/versions/1.0.0.json +19 -0
  64. package/runtime/plugins/opinion-manager/catalog/templates/response-style/dense-chinese/versions/1.0.0.json +19 -0
  65. package/runtime/plugins/opinion-manager/catalog/templates/scenario/concise-project/versions/1.0.0.json +39 -0
  66. package/runtime/plugins/opinion-manager/catalog/templates/scenario/reviewed-project/versions/1.0.0.json +39 -0
  67. package/runtime/plugins/opinion-manager/catalog/templates/work-method/proportional/versions/1.0.0.json +20 -0
  68. package/runtime/plugins/opinion-manager/catalog/templates/work-method/three-level/versions/1.0.0.json +20 -0
  69. package/runtime/plugins/opinion-manager/content.json +63 -0
  70. package/runtime/plugins/opinion-manager/plugin.json +18 -0
  71. package/runtime/plugins/opinion-manager/references/lock.schema.json +14 -0
  72. package/runtime/plugins/opinion-manager/references/onboarding.md +57 -0
  73. package/runtime/plugins/opinion-manager/references/profile.schema.json +56 -0
  74. package/runtime/plugins/opinion-manager/references/rule.schema.json +16 -0
  75. package/runtime/plugins/opinion-manager/references/template.schema.json +71 -0
  76. package/runtime/plugins/opinion-manager/references/versioned-template.schema.json +19 -0
  77. package/runtime/plugins/opinion-manager/references/versioning.md +263 -0
  78. package/runtime/plugins/opinion-manager/runtime/constants.py +7 -0
  79. package/runtime/plugins/opinion-manager/runtime/context.py +180 -0
  80. package/runtime/plugins/opinion-manager/runtime/lifecycle.py +395 -0
  81. package/runtime/plugins/opinion-manager/runtime/opinion_manager.py +419 -0
  82. package/runtime/plugins/opinion-manager/runtime/versioned.py +705 -0
  83. package/runtime/plugins/opinion-manager/skills/opinion-manager/SKILL.md +62 -0
  84. package/runtime/plugins/opinion-manager/skills/opinion-manager/agents/openai.yaml +4 -0
  85. package/runtime/plugins/opinion-manager/templates/.gitkeep +1 -0
  86. package/runtime/plugins/project-orchestrator/.codex-plugin/plugin.json +18 -0
  87. package/runtime/plugins/project-orchestrator/content.json +37 -0
  88. package/runtime/plugins/project-orchestrator/plugin.json +19 -0
  89. package/runtime/plugins/project-orchestrator/references/asset-synonyms.example.json +8 -0
  90. package/runtime/plugins/project-orchestrator/references/case-workspaces.md +159 -0
  91. package/runtime/plugins/project-orchestrator/references/case.schema.json +31 -0
  92. package/runtime/plugins/project-orchestrator/runtime/assets.py +1288 -0
  93. package/runtime/plugins/project-orchestrator/runtime/case.py +557 -0
  94. package/runtime/plugins/project-orchestrator/runtime/case_workspace.py +350 -0
  95. package/runtime/plugins/project-orchestrator/runtime/memory.py +363 -0
  96. package/runtime/plugins/project-orchestrator/runtime/project_base.py +107 -0
  97. package/runtime/plugins/project-orchestrator/runtime/project_cli.py +599 -0
  98. package/runtime/plugins/project-orchestrator/runtime/project_demo.py +504 -0
  99. package/runtime/plugins/project-orchestrator/runtime/project_maintain.py +470 -0
  100. package/runtime/plugins/project-orchestrator/runtime/project_menu.py +394 -0
  101. package/runtime/plugins/project-orchestrator/runtime/project_real.py +522 -0
  102. package/runtime/plugins/project-orchestrator/runtime/project_write.py +329 -0
  103. package/runtime/plugins/project-orchestrator/runtime/wf.py +439 -0
  104. package/runtime/plugins/project-orchestrator/skills/project-orchestrator/SKILL.md +230 -0
  105. package/runtime/plugins/project-orchestrator/skills/project-orchestrator/agents/openai.yaml +4 -0
  106. package/runtime/plugins/project-orchestrator/templates/CASE.md +39 -0
  107. /package/runtime/{agent_pack_config.py → plugins/agent-pack/runtime/agent_pack_config.py} +0 -0
  108. /package/runtime/{mcp_env_launcher.py → plugins/agent-pack/runtime/mcp_env_launcher.py} +0 -0
  109. /package/runtime/{mcp_server.py → plugins/agent-pack/runtime/mcp_server.py} +0 -0
@@ -0,0 +1,1190 @@
1
+ #!/usr/bin/env python3
2
+ """使用 agents-init v6 中文模板安全创建或迁移项目骨架。"""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import difflib
8
+ import hashlib
9
+ import json
10
+ import os
11
+ import re
12
+ import stat
13
+ import sys
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+ from typing import Iterable
17
+
18
+
19
+ INITIALIZER_VERSION = "6.6.0"
20
+ PLUGIN_ROOT = Path(__file__).resolve().parents[3]
21
+ PROFILE_SCHEMA = "https://skill.sakanano.moe/skills/agents-init/project.schema.json"
22
+ PROFILE_PATH = Path(".agents/moe.sakanano.agent-pack/project.json")
23
+ # Baseline of the template-derived files this initializer wrote; `check`
24
+ # compares it with the files on disk and the newest content pack version.
25
+ CONTENT_LOCK_PATH = Path(".agents/moe.sakanano.agent-pack/content.lock.json")
26
+ CONTENT_LOCK_OWNER = "agenthelm"
27
+ LEGACY_PROFILE_PATH = Path(".agents/moe.sakanano.project-runtime/project.json")
28
+ RUNTIME_BLOCK = {
29
+ "skill": "project-orchestrator",
30
+ "plugin": "project-orchestrator",
31
+ "capability_manager": "agent-pack",
32
+ "distribution": "marketplace",
33
+ "required": True,
34
+ "commit_policy": "explicit",
35
+ "push_policy": "explicit",
36
+ }
37
+ BUNDLED_PLUGINS = {
38
+ "project-orchestrator": (Path("skills/project-orchestrator/SKILL.md"),),
39
+ "agent-pack": (
40
+ Path("skills/agent-pack/SKILL.md"),
41
+ Path("runtime/agent_pack_config.py"),
42
+ Path("runtime/mcp_server.py"),
43
+ ),
44
+ "opinion-manager": (
45
+ Path("skills/opinion-manager/SKILL.md"),
46
+ Path("runtime/opinion_manager.py"),
47
+ ),
48
+ }
49
+ TEMPLATE_PATH = Path(__file__).resolve().parents[1] / "AGENT.template.md"
50
+ # 项目根目录的人用入口(启动器):文件名由项目选择,内容与名称无关,随内容包更新。
51
+ LAUNCHER_TEMPLATE = Path(__file__).resolve().parents[1] / "launcher.sh"
52
+ DEFAULT_ENTRY = "menu"
53
+ ENTRY_API = 1
54
+ ENTRY_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
55
+ RESERVED_ENTRY_NAMES = {"AGENTS.md", "AGENT.md", "CLAUDE.md", "AGENT.RULES.md", "OPINION.md", "README.md",
56
+ "cases", "docs", "memory", ".agents", ".git"}
57
+ README_START = "<!-- agenthelm:entry -->"
58
+ README_END = "<!-- /agenthelm:entry -->"
59
+ # 内容锁里没有记录模板版本时(早于内容锁的项目),补登的条目使用这个版本,检查会提示刷新。
60
+ UNKNOWN_PACK_VERSION = "0.0.0"
61
+ TEMPLATE_PATTERN = re.compile(
62
+ r"<!-- agents-init:template (?P<name>[^ ]+) -->\n"
63
+ r"```markdown\n(?P<body>.*?)```\n"
64
+ r"<!-- /agents-init:template -->",
65
+ re.DOTALL,
66
+ )
67
+ REQUIRED_TEMPLATES = {
68
+ "AGENTS.md",
69
+ "AGENT.RULES.md",
70
+ "OPINION.md",
71
+ "ROUTE.md",
72
+ ".agent-doc/plan.md",
73
+ ".agent-doc/progress.md",
74
+ ".agent-doc/chat-summary.md",
75
+ "docs/refs/README.md",
76
+ }
77
+
78
+
79
+ class InitError(Exception):
80
+ """Expected initialization failure."""
81
+
82
+ def __init__(self, message: str, *, code: str = "invalid-request") -> None:
83
+ super().__init__(message)
84
+ self.code = code
85
+
86
+
87
+ @dataclass(frozen=True)
88
+ class Inputs:
89
+ project: Path
90
+ name: str
91
+ slug: str
92
+ project_type: tuple[str, ...]
93
+ vcs: str
94
+ stack: tuple[str, ...]
95
+ runtime: str
96
+ agent_cli: tuple[str, ...]
97
+ work_mode: str = "case-workspace"
98
+ entry_launcher: str | None = None
99
+
100
+
101
+ def split_csv(value: str) -> tuple[str, ...]:
102
+ items = tuple(part.strip() for part in value.split(",") if part.strip())
103
+ if not items:
104
+ raise argparse.ArgumentTypeError("expected at least one non-empty value")
105
+ return items
106
+
107
+
108
+ def slugify(value: str) -> str:
109
+ slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
110
+ if not slug:
111
+ raise InitError("--name needs an ASCII --slug override")
112
+ return slug
113
+
114
+
115
+ def json_text(value: object) -> str:
116
+ return json.dumps(value, ensure_ascii=False, indent=2) + "\n"
117
+
118
+
119
+ def load_templates(path: Path = TEMPLATE_PATH) -> dict[str, str]:
120
+ try:
121
+ source = path.read_text(encoding="utf-8")
122
+ except (OSError, UnicodeDecodeError) as exc:
123
+ raise InitError(f"无法读取中文模板:{path}", code="template-invalid") from exc
124
+ templates = {match.group("name"): match.group("body") for match in TEMPLATE_PATTERN.finditer(source)}
125
+ missing = sorted(REQUIRED_TEMPLATES - set(templates))
126
+ if missing:
127
+ raise InitError("中文模板缺少生成区块:" + ", ".join(missing), code="template-invalid")
128
+ return templates
129
+
130
+
131
+ def render_template(template: str, replacements: dict[str, str]) -> str:
132
+ rendered = template
133
+ for token, value in replacements.items():
134
+ rendered = rendered.replace(token, value)
135
+ return rendered
136
+
137
+
138
+ def _is_regular_file(path: Path) -> bool:
139
+ try:
140
+ metadata = path.lstat()
141
+ except FileNotFoundError:
142
+ return False
143
+ return stat.S_ISREG(metadata.st_mode) and not stat.S_ISLNK(metadata.st_mode)
144
+
145
+
146
+ def _read_manifest(path: Path) -> dict:
147
+ try:
148
+ manifest = json.loads(path.read_text(encoding="utf-8"))
149
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
150
+ raise InitError(f"Plugin manifest 无法解析:{path}", code="runtime-required") from exc
151
+ if not isinstance(manifest, dict):
152
+ raise InitError(f"Plugin manifest 根节点必须是对象:{path}", code="runtime-required")
153
+ return manifest
154
+
155
+
156
+ def _version_key(value: str) -> tuple[int, ...]:
157
+ numbers = re.findall(r"\d+", value.split("+", 1)[0])
158
+ return tuple(int(number) for number in numbers) if numbers else ()
159
+
160
+
161
+ def _claude_installed_path(cache_market: Path, name: str) -> Path | None:
162
+ """Claude Code keeps superseded version directories; its install record names the live one."""
163
+ record = cache_market.parent.parent / "installed_plugins.json"
164
+ try:
165
+ installed = json.loads(record.read_text(encoding="utf-8")).get("plugins", {})
166
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError, AttributeError):
167
+ return None
168
+ for entry in installed.get(f"{name}@{cache_market.name}", []):
169
+ path = Path(str(entry.get("installPath", ""))).resolve()
170
+ if path.parent == (cache_market / name).resolve() and (path / "plugin.json").is_file():
171
+ return path
172
+ return None
173
+
174
+
175
+ def locate_companion(agents_init_root: Path, name: str) -> Path | None:
176
+ """Find a companion plugin installed next to this agents-init.
177
+
178
+ Two layouts exist: ``<dir>/<plugin>`` (this repository, Cursor's local
179
+ plugins, agent-pack bootstrap) and ``<marketplace>/<plugin>/<version>``
180
+ (the Claude Code and Codex plugin caches).
181
+ """
182
+ flat = agents_init_root.parent / name
183
+ if (flat / "plugin.json").is_file():
184
+ return flat
185
+ cache_market = agents_init_root.parent.parent
186
+ versions_dir = cache_market / name
187
+ if not versions_dir.is_dir():
188
+ return None
189
+ live = _claude_installed_path(cache_market, name)
190
+ if live is not None:
191
+ return live
192
+ candidates = [path for path in versions_dir.iterdir() if (path / "plugin.json").is_file()]
193
+ if not candidates:
194
+ return None
195
+ return max(candidates, key=lambda path: (_version_key(path.name), path.stat().st_mtime))
196
+
197
+
198
+ def _agents_init_root(plugin_root: Path) -> Path:
199
+ root = plugin_root.expanduser().resolve()
200
+ # A repository checkout passes its root; plugins live under plugins/.
201
+ nested = root / "plugins" / "agents-init"
202
+ return nested if (nested / "plugin.json").is_file() else root
203
+
204
+
205
+ def validate_runtime(plugin_root: Path) -> dict[str, str | bool]:
206
+ root = _agents_init_root(plugin_root)
207
+ if not _is_regular_file(root / "plugin.json"):
208
+ raise InitError(f"缺少 agents-init Plugin manifest:{root / 'plugin.json'}", code="runtime-required")
209
+ if _read_manifest(root / "plugin.json").get("name") != "agents-init":
210
+ raise InitError("Plugin manifest 必须声明 name=agents-init", code="runtime-required")
211
+ missing: list[str] = []
212
+ versions: dict[str, str] = {}
213
+ for name, files in BUNDLED_PLUGINS.items():
214
+ companion = locate_companion(root, name)
215
+ if companion is None:
216
+ missing.append(f"{name}/plugin.json")
217
+ continue
218
+ missing.extend(f"{name}/{relative}" for relative in files if not _is_regular_file(companion / relative))
219
+ manifest = _read_manifest(companion / "plugin.json")
220
+ if manifest.get("name") != name:
221
+ raise InitError(f"同包 Plugin manifest 必须声明 name={name}", code="runtime-required")
222
+ versions[name] = str(manifest.get("version", ""))
223
+ if missing:
224
+ raise InitError(
225
+ "缺少必需的 project-orchestrator / agent-pack / opinion-manager 文件:"
226
+ + ", ".join(missing),
227
+ code="runtime-required",
228
+ )
229
+ return {
230
+ "available": True,
231
+ "source": "bundled" if root == PLUGIN_ROOT.resolve() else "external",
232
+ "plugin": "agents-init",
233
+ "lifecycle": f"project-orchestrator@{versions['project-orchestrator']}",
234
+ "capabilities": f"agent-pack@{versions['agent-pack']}",
235
+ "opinion": f"opinion-manager@{versions['opinion-manager']}",
236
+ }
237
+
238
+
239
+ def read_legacy_profile(root: Path) -> dict | None:
240
+ current = root
241
+ last = len(LEGACY_PROFILE_PATH.parts) - 1
242
+ for index, part in enumerate(LEGACY_PROFILE_PATH.parts):
243
+ current /= part
244
+ try:
245
+ metadata = current.lstat()
246
+ except FileNotFoundError:
247
+ return None
248
+ if stat.S_ISLNK(metadata.st_mode):
249
+ kind = "legacy profile" if index == last else "parent"
250
+ raise InitError(f"refusing symlinked {kind}: {current.relative_to(root)}")
251
+ if not stat.S_ISREG(metadata.st_mode):
252
+ raise InitError(f"legacy profile is not a regular file: {LEGACY_PROFILE_PATH}")
253
+ try:
254
+ profile = json.loads(current.read_text(encoding="utf-8"))
255
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
256
+ raise InitError(f"legacy profile 无法解析:{LEGACY_PROFILE_PATH}", code="profile-invalid") from exc
257
+ if not isinstance(profile, dict) or profile.get("schema_version") != "1.0":
258
+ raise InitError("legacy profile 必须是 schema_version=1.0 的对象", code="profile-invalid")
259
+ return profile
260
+
261
+
262
+ def upgrade_legacy_profile(legacy: dict) -> dict:
263
+ """保留项目自定义字段,只替换运行时归属与版本元数据。"""
264
+ upgraded = {"$schema": PROFILE_SCHEMA, **legacy}
265
+ upgraded["$schema"] = PROFILE_SCHEMA
266
+ upgraded["initializer_version"] = INITIALIZER_VERSION
267
+ runtime = dict(legacy.get("runtime") or {})
268
+ for key in ("skill", "plugin", "capability_manager", "distribution"):
269
+ runtime[key] = RUNTIME_BLOCK[key]
270
+ for key in ("required", "commit_policy", "push_policy"):
271
+ runtime.setdefault(key, RUNTIME_BLOCK[key])
272
+ upgraded["runtime"] = runtime
273
+ return upgraded
274
+
275
+
276
+ def configure_work_mode(profile: dict, mode: str | None) -> dict:
277
+ if profile.get('work') is not None and not isinstance(profile['work'], dict):
278
+ raise InitError('work 配置必须是对象', code='profile-invalid')
279
+ if mode is None:
280
+ return profile
281
+ work = dict(profile.get('work') or {})
282
+ if any(work.get(key) is not None and not isinstance(work[key], dict) for key in ('task', 'case')):
283
+ raise InitError('task/case 配置必须是对象或 null', code='profile-invalid')
284
+ if mode == 'case-workspace':
285
+ if work.get('task'):
286
+ work['legacy_task'] = {**work['task'], 'mode': 'migrate-only'}
287
+ work['task'] = None
288
+ work['case'] = {**(work.get('case') or {}),
289
+ 'root': (work.get('case') or {}).get('root', 'cases'),
290
+ 'artifact_mode': 'case-local'}
291
+ work['mode'] = mode
292
+ profile['work'] = work
293
+ return profile
294
+
295
+
296
+ def validate_inline(label: str, value: str) -> str:
297
+ if not value.strip():
298
+ raise InitError(f"{label} 不能为空")
299
+ if len(value) > 512 or any(ord(character) < 32 for character in value):
300
+ raise InitError(f"{label} 只能包含单行可打印文本")
301
+ return value.strip()
302
+
303
+
304
+ def validate_entry_name(name: str) -> str:
305
+ if not ENTRY_NAME_RE.fullmatch(name) or name in RESERVED_ENTRY_NAMES:
306
+ raise InitError(f"启动器名称不合法:{name}(只能用字母、数字、. _ -,且不能与骨架文件同名)")
307
+ return name
308
+
309
+
310
+ def launcher_text() -> str:
311
+ try:
312
+ return LAUNCHER_TEMPLATE.read_text(encoding="utf-8")
313
+ except (OSError, UnicodeDecodeError) as exc:
314
+ raise InitError(f"无法读取启动器模板:{LAUNCHER_TEMPLATE}", code="template-invalid") from exc
315
+
316
+
317
+ def entry_command(launcher: str | None) -> str:
318
+ return f"./{launcher}" if launcher else "agenthelm project"
319
+
320
+
321
+ def readme_block(launcher: str | None) -> str:
322
+ command = entry_command(launcher)
323
+ return (f"{README_START}\n## 怎样使用\n\n在项目根目录运行 `{command}` 打开项目菜单:找资料、看进展、项目指南、检查与维护。"
324
+ f"不需要 AI 客户端,只需要 Python 3.11+;第一次运行如果提示缺少运行时,按提示安装一次即可。\n\n"
325
+ f"不进菜单也可以:`{command} find 关键词`、`{command} progress`、`{command} --help`。\n{README_END}\n")
326
+
327
+
328
+ def tracked_paths(pack: dict | None, launcher: str | None) -> list[Path]:
329
+ """Files the content lock follows: the pack's fixed list plus this project's launcher."""
330
+ tracked = [Path(path) for path in (pack or {}).get("materializes", [])]
331
+ return tracked + ([Path(launcher)] if launcher else [])
332
+
333
+
334
+ def files_for(inputs: Inputs, templates: dict[str, str] | None = None) -> dict[Path, str]:
335
+ templates = templates or load_templates()
336
+ profile = {
337
+ "$schema": PROFILE_SCHEMA,
338
+ "schema_version": "1.0",
339
+ "initializer_version": INITIALIZER_VERSION,
340
+ "name": inputs.name,
341
+ "profile": {
342
+ "project_type": list(inputs.project_type),
343
+ "vcs": inputs.vcs,
344
+ "stack": list(inputs.stack),
345
+ "runtime": inputs.runtime,
346
+ "agent_cli": list(inputs.agent_cli),
347
+ },
348
+ "runtime": dict(RUNTIME_BLOCK),
349
+ "opinion": {
350
+ "provider": "opinion-manager",
351
+ "project_overlay": "OPINION.md",
352
+ "strict_mode": "smart",
353
+ "loading_mode": "tiered",
354
+ },
355
+ "memory": {
356
+ "provider": "project-orchestrator",
357
+ "root": "memory",
358
+ "index_command": None,
359
+ "check_command": None,
360
+ "policy": None,
361
+ },
362
+ "capabilities": {
363
+ "plugin_roots": [".agents"],
364
+ "plugin_dirs": [],
365
+ "skill_roots": [".agents/skills"],
366
+ "mcp_sources": [".agents/mcp.json"],
367
+ "native_mcp_sources": [],
368
+ "credential_env_file": None,
369
+ "mcp_client_policy": {},
370
+ "destination_skill_root": ".agents/skills",
371
+ "destination_mcp": ".agents/mcp.json",
372
+ },
373
+ "work": {
374
+ "mode": inputs.work_mode,
375
+ "task": None,
376
+ "case": {"root": "cases", "artifact_mode": "case-local"}
377
+ if inputs.work_mode == "case-workspace" else None,
378
+ },
379
+ "privacy": {"forbidden_default_reads": [], "generated_outputs": []},
380
+ }
381
+ if inputs.entry_launcher:
382
+ profile["entry"] = {"api": ENTRY_API, "launcher": inputs.entry_launcher}
383
+ replacements = {
384
+ "ENTRY_COMMAND": entry_command(inputs.entry_launcher),
385
+ "PROJECT_NAME": inputs.name,
386
+ "PROJECT_SLUG": inputs.slug,
387
+ "PROJECT_TYPE": ", ".join(inputs.project_type),
388
+ "VCS": inputs.vcs,
389
+ "STACK": ", ".join(inputs.stack),
390
+ "RUNTIME": inputs.runtime,
391
+ "AGENT_CLI": ", ".join(inputs.agent_cli),
392
+ }
393
+ agents = render_template(templates["AGENTS.md"], replacements)
394
+ route = render_template(templates["ROUTE.md"], replacements)
395
+ files = {
396
+ Path("AGENTS.md"): agents,
397
+ Path("AGENT.RULES.md"): render_template(templates["AGENT.RULES.md"], replacements),
398
+ Path("OPINION.md"): render_template(templates["OPINION.md"], replacements),
399
+ Path("CLAUDE.md"): route,
400
+ Path("AGENT.md"): route,
401
+ Path(".agents/plugin.json"): json_text({
402
+ "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
403
+ "name": inputs.slug,
404
+ "version": "0.1.0",
405
+ "description": "项目本地的可移植 Agent 能力包。",
406
+ }),
407
+ Path(".agents/mcp.json"): json_text({
408
+ "$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json",
409
+ "mcpServers": {},
410
+ }),
411
+ PROFILE_PATH: json_text(profile),
412
+ Path(".agents/skills/.gitkeep"): "",
413
+ Path(".agent-doc/plan.md"): render_template(templates[".agent-doc/plan.md"], replacements),
414
+ Path(".agent-doc/progress.md"): render_template(templates[".agent-doc/progress.md"], replacements),
415
+ Path(".agent-doc/chat-summary.md"): render_template(templates[".agent-doc/chat-summary.md"], replacements),
416
+ Path("memory/.gitkeep"): "",
417
+ Path("docs/refs/README.md"): render_template(templates["docs/refs/README.md"], replacements),
418
+ Path("docs/drafts/.gitkeep"): "",
419
+ }
420
+ if inputs.work_mode == "case-workspace":
421
+ files[Path("cases/.gitkeep")] = ""
422
+ if inputs.entry_launcher:
423
+ files[Path(inputs.entry_launcher)] = launcher_text()
424
+ return files
425
+
426
+
427
+ def validate_root(path: Path) -> Path:
428
+ root = path.expanduser().resolve()
429
+ if not root.exists() or not root.is_dir():
430
+ raise InitError(f"project directory does not exist: {root}")
431
+ return root
432
+
433
+
434
+ def plan_writes(
435
+ root: Path, files: dict[Path, str], *, skip_symlinked_parents: bool = False
436
+ ) -> tuple[list[Path], list[Path], list[Path]]:
437
+ """返回 (create, collisions, symlinked);symlinked 只在迁移模式出现,永不写入。"""
438
+ create: list[Path] = []
439
+ collisions: list[Path] = []
440
+ symlinked: list[Path] = []
441
+ for relative in sorted(files, key=str):
442
+ current = root
443
+ linked = False
444
+ for part in relative.parts[:-1]:
445
+ current /= part
446
+ try:
447
+ metadata = current.lstat()
448
+ except FileNotFoundError:
449
+ break
450
+ if stat.S_ISLNK(metadata.st_mode):
451
+ if not skip_symlinked_parents:
452
+ raise InitError(f"refusing symlinked parent: {current.relative_to(root)}")
453
+ linked = True
454
+ break
455
+ if not stat.S_ISDIR(metadata.st_mode):
456
+ raise InitError(f"parent is not a directory: {current.relative_to(root)}")
457
+ if linked:
458
+ symlinked.append(relative)
459
+ continue
460
+ destination = root / relative
461
+ try:
462
+ destination.lstat()
463
+ exists = True
464
+ except FileNotFoundError:
465
+ exists = False
466
+ (collisions if exists else create).append(relative)
467
+ return create, collisions, symlinked
468
+
469
+
470
+ def _open_parent(root_fd: int, relative: Path, created_dirs: list[Path]) -> int:
471
+ descriptor = os.dup(root_fd)
472
+ traversed = Path()
473
+ flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0)
474
+ try:
475
+ for part in relative.parts[:-1]:
476
+ traversed /= part
477
+ try:
478
+ child = os.open(part, flags, dir_fd=descriptor)
479
+ except FileNotFoundError:
480
+ os.mkdir(part, mode=0o755, dir_fd=descriptor)
481
+ created_dirs.append(traversed)
482
+ child = os.open(part, flags, dir_fd=descriptor)
483
+ except OSError as exc:
484
+ raise InitError(f"unsafe parent path {traversed}: {exc}") from exc
485
+ os.close(descriptor)
486
+ descriptor = child
487
+ return descriptor
488
+ except Exception:
489
+ os.close(descriptor)
490
+ raise
491
+
492
+
493
+ def _write_exclusive(parent_fd: int, name: str, payload: bytes) -> None:
494
+ flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)
495
+ descriptor = os.open(name, flags, 0o644, dir_fd=parent_fd)
496
+ try:
497
+ offset = 0
498
+ while offset < len(payload):
499
+ written = os.write(descriptor, payload[offset:])
500
+ if written <= 0:
501
+ raise OSError("short write")
502
+ offset += written
503
+ os.fsync(descriptor)
504
+ os.fchmod(descriptor, 0o644)
505
+ finally:
506
+ os.close(descriptor)
507
+
508
+
509
+ def _open_existing_parent(root_fd: int, relative: Path) -> int:
510
+ descriptor = os.dup(root_fd)
511
+ flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0)
512
+ try:
513
+ for part in relative.parts[:-1]:
514
+ child = os.open(part, flags, dir_fd=descriptor)
515
+ os.close(descriptor)
516
+ descriptor = child
517
+ return descriptor
518
+ except Exception:
519
+ os.close(descriptor)
520
+ raise
521
+
522
+
523
+ def _unlink_relative(root_fd: int, relative: Path, *, directory: bool = False) -> None:
524
+ parent_fd = _open_existing_parent(root_fd, relative)
525
+ try:
526
+ if directory:
527
+ os.rmdir(relative.name, dir_fd=parent_fd)
528
+ else:
529
+ os.unlink(relative.name, dir_fd=parent_fd)
530
+ finally:
531
+ os.close(parent_fd)
532
+
533
+
534
+ def apply_writes(root: Path, files: dict[Path, str], create: Iterable[Path]) -> None:
535
+ planned = list(create)
536
+ rendered = {relative: files[relative].encode("utf-8") for relative in planned}
537
+ created_files: list[Path] = []
538
+ created_dirs: list[Path] = []
539
+ root_fd = os.open(root, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0))
540
+ try:
541
+ for relative in planned:
542
+ parent_fd = _open_parent(root_fd, relative, created_dirs)
543
+ try:
544
+ _write_exclusive(parent_fd, relative.name, rendered[relative])
545
+ created_files.append(relative)
546
+ finally:
547
+ os.close(parent_fd)
548
+ except Exception as exc:
549
+ cleanup_errors: list[str] = []
550
+ for relative in reversed(created_files):
551
+ try:
552
+ _unlink_relative(root_fd, relative)
553
+ except OSError as cleanup_exc:
554
+ cleanup_errors.append(f"{relative}: {cleanup_exc}")
555
+ for relative in reversed(created_dirs):
556
+ try:
557
+ _unlink_relative(root_fd, relative, directory=True)
558
+ except OSError:
559
+ # A concurrent writer may have added unknown content. Never remove it.
560
+ pass
561
+ detail = f"; rollback incomplete: {', '.join(cleanup_errors)}" if cleanup_errors else ""
562
+ raise InitError(f"initialization rolled back after write failure: {exc}{detail}") from exc
563
+ finally:
564
+ os.close(root_fd)
565
+
566
+
567
+ def apply_migration(
568
+ root: Path,
569
+ files: dict[Path, str],
570
+ create: Iterable[Path],
571
+ replace: Iterable[Path],
572
+ recovery_dir: Path | None,
573
+ retire: Iterable[Path] = (),
574
+ ) -> None:
575
+ create_paths = list(create)
576
+ replace_paths = list(replace)
577
+ retire_paths = list(retire)
578
+ if (replace_paths or retire_paths) and recovery_dir is None:
579
+ raise InitError("--recovery-dir is required when --replace is used or a legacy profile is retired")
580
+ recovery = recovery_dir.expanduser().resolve() if recovery_dir is not None else None
581
+ if recovery is not None:
582
+ if recovery == root or root in recovery.parents:
583
+ raise InitError("--recovery-dir must be outside the project root")
584
+ recovery.mkdir(parents=True, exist_ok=True, mode=0o700)
585
+ if recovery.is_symlink() or not recovery.is_dir():
586
+ raise InitError("--recovery-dir must be a real directory")
587
+ conflicts = [path for path in [*replace_paths, *retire_paths] if (recovery / path).exists()]
588
+ if conflicts:
589
+ raise InitError("recovery collision: " + ", ".join(str(path) for path in conflicts))
590
+
591
+ root_fd = os.open(root, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0))
592
+ created_files: list[Path] = []
593
+ created_dirs: list[Path] = []
594
+ moved: list[tuple[Path, Path]] = []
595
+ try:
596
+ for relative in [*replace_paths, *retire_paths]:
597
+ assert recovery is not None
598
+ original = root / relative
599
+ archived = recovery / relative
600
+ archived.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
601
+ os.replace(original, archived)
602
+ moved.append((archived, original))
603
+ for relative in [*create_paths, *replace_paths]:
604
+ parent_fd = _open_parent(root_fd, relative, created_dirs)
605
+ try:
606
+ _write_exclusive(parent_fd, relative.name, files[relative].encode("utf-8"))
607
+ created_files.append(relative)
608
+ finally:
609
+ os.close(parent_fd)
610
+ except Exception as exc:
611
+ for relative in reversed(created_files):
612
+ try:
613
+ _unlink_relative(root_fd, relative)
614
+ except OSError:
615
+ pass
616
+ for archived, original in reversed(moved):
617
+ if archived.exists() and not original.exists():
618
+ original.parent.mkdir(parents=True, exist_ok=True)
619
+ os.replace(archived, original)
620
+ for relative in reversed(created_dirs):
621
+ try:
622
+ _unlink_relative(root_fd, relative, directory=True)
623
+ except OSError:
624
+ pass
625
+ raise InitError(f"migration rolled back after write failure: {exc}") from exc
626
+ finally:
627
+ os.close(root_fd)
628
+
629
+
630
+ def contract_pack(plugin_root: Path = PLUGIN_ROOT) -> dict | None:
631
+ """The content pack whose rendered files this initializer tracks in the project."""
632
+ try:
633
+ content = json.loads((plugin_root / "content.json").read_text(encoding="utf-8"))
634
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError):
635
+ return None
636
+ for pack in content.get("packs", []):
637
+ if pack.get("project_lock") == CONTENT_LOCK_OWNER:
638
+ return pack
639
+ return None
640
+
641
+
642
+ def content_baseline(pack: dict | None, files: dict[Path, str], written: Iterable[Path],
643
+ launcher: str | None = None) -> dict[str, str]:
644
+ if pack is None:
645
+ return {}
646
+ tracked = set(tracked_paths(pack, launcher))
647
+ return {
648
+ path.as_posix(): hashlib.sha256(files[path].encode("utf-8")).hexdigest()
649
+ for path in sorted(written, key=str)
650
+ if path in tracked
651
+ }
652
+
653
+
654
+ def write_content_lock(root: Path, pack: dict, baseline: dict[str, str]) -> None:
655
+ """Merge this run's baseline into the project's content lock, atomically."""
656
+ path = root / CONTENT_LOCK_PATH
657
+ for parent in [*reversed(CONTENT_LOCK_PATH.parents[:-1])]:
658
+ if (root / parent).is_symlink():
659
+ raise InitError(f"refusing symlinked parent: {parent}")
660
+ if path.is_symlink():
661
+ raise InitError(f"refusing symlinked content lock: {CONTENT_LOCK_PATH}")
662
+ lock: dict = {"schema_version": 1, "packs": {}}
663
+ if path.exists():
664
+ try:
665
+ lock = json.loads(path.read_text(encoding="utf-8"))
666
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
667
+ raise InitError(f"content lock 无法解析:{CONTENT_LOCK_PATH}", code="profile-invalid") from exc
668
+ entry = lock.setdefault("packs", {}).get(pack["id"], {})
669
+ files = {**entry.get("files", {}), **baseline}
670
+ lock["packs"][pack["id"]] = {"plugin": "agents-init", "version": pack["version"], "files": dict(sorted(files.items()))}
671
+ path.parent.mkdir(parents=True, exist_ok=True)
672
+ temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
673
+ temporary.write_text(json_text(lock), encoding="utf-8")
674
+ os.replace(temporary, path)
675
+
676
+
677
+ def _read_project_json(root: Path, relative: Path, label: str) -> dict:
678
+ path = root / relative
679
+ if path.is_symlink() or any(parent.is_symlink() for parent in path.parents if root in parent.parents):
680
+ raise InitError(f"refusing symlinked {label}", code="profile-invalid")
681
+ try:
682
+ data = json.loads(path.read_text(encoding="utf-8"))
683
+ except FileNotFoundError as exc:
684
+ raise InitError(f"缺少 {relative}", code="refresh-unavailable") from exc
685
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
686
+ raise InitError(f"{label} 无法解析:{relative}", code="profile-invalid") from exc
687
+ if not isinstance(data, dict):
688
+ raise InitError(f"{label} 根节点必须是对象", code="profile-invalid")
689
+ return data
690
+
691
+
692
+ def inputs_from_profile(root: Path) -> Inputs:
693
+ """Rebuild the initializer inputs a project was rendered with from its own Profile."""
694
+ profile = _read_project_json(root, PROFILE_PATH, "Project Profile")
695
+ details = profile.get("profile") or {}
696
+ try:
697
+ package = _read_project_json(root, Path(".agents/plugin.json"), "project plugin manifest")
698
+ slug = str(package.get("name") or "")
699
+ except InitError:
700
+ slug = ""
701
+ name = str(profile.get("name") or "")
702
+ return Inputs(
703
+ root, name, slug or slugify(name),
704
+ tuple(details.get("project_type") or ()), str(details.get("vcs") or ""),
705
+ tuple(details.get("stack") or ()), str(details.get("runtime") or ""),
706
+ tuple(details.get("agent_cli") or ()),
707
+ str((profile.get("work") or {}).get("mode") or "legacy"),
708
+ _profile_launcher(profile),
709
+ )
710
+
711
+
712
+ def _profile_launcher(profile: dict) -> str | None:
713
+ entry = profile.get("entry")
714
+ launcher = entry.get("launcher") if isinstance(entry, dict) else None
715
+ return validate_entry_name(launcher) if isinstance(launcher, str) else None
716
+
717
+
718
+ def _unified_diff(relative: str, current: str, new: str, limit: int = 200) -> str:
719
+ lines = list(difflib.unified_diff(
720
+ current.splitlines(keepends=True), new.splitlines(keepends=True),
721
+ fromfile=f"a/{relative}", tofile=f"b/{relative}",
722
+ ))
723
+ if len(lines) > limit:
724
+ lines = lines[:limit] + [f"... ({len(lines) - limit} more diff lines)\n"]
725
+ return "".join(lines)
726
+
727
+
728
+ def refresh_project(args: argparse.Namespace, root: Path) -> tuple[int, dict]:
729
+ """Re-render the tracked template files after a content pack update.
730
+
731
+ Files still identical to their install-time baseline are replaced; files the
732
+ user edited are only replaced with --accept or kept with --keep. Every
733
+ replaced file is copied to --recovery-dir first.
734
+ """
735
+ pack = contract_pack()
736
+ if pack is None:
737
+ raise InitError("当前 agents-init 未声明可刷新的内容包", code="refresh-unavailable")
738
+ try:
739
+ lock = _read_project_json(root, CONTENT_LOCK_PATH, "content lock")
740
+ except InitError as exc:
741
+ if exc.code != "refresh-unavailable":
742
+ raise
743
+ lock = {"schema_version": 1, "packs": {}}
744
+ # Without a baseline (projects initialised before content locks existed),
745
+ # every file that differs from the new render counts as locally modified.
746
+ entry = (lock.get("packs") or {}).get(pack["id"]) or {}
747
+ baseline: dict[str, str] = dict(entry.get("files") or {})
748
+ inputs = inputs_from_profile(root)
749
+ rendered = files_for(inputs)
750
+ accept = {Path(value) for value in args.accept}
751
+ keep = {Path(value) for value in args.keep}
752
+ tracked = tracked_paths(pack, inputs.entry_launcher)
753
+ unknown = (accept | keep) - set(tracked)
754
+ if unknown:
755
+ raise InitError("不是内容包跟踪的文件:" + ", ".join(sorted(map(str, unknown))))
756
+ if accept & keep:
757
+ raise InitError("同一文件不能同时 --accept 与 --keep:" + ", ".join(sorted(map(str, accept & keep))))
758
+ plan: list[dict] = []
759
+ for relative in tracked:
760
+ new = rendered[relative]
761
+ new_digest = hashlib.sha256(new.encode("utf-8")).hexdigest()
762
+ path = root / relative
763
+ if path.is_symlink():
764
+ raise InitError(f"refusing symlinked file: {relative}")
765
+ current = path.read_text(encoding="utf-8") if path.is_file() else None
766
+ current_digest = hashlib.sha256(current.encode("utf-8")).hexdigest() if current is not None else None
767
+ if current_digest == new_digest:
768
+ state, action = "unchanged", "keep"
769
+ elif current is None:
770
+ state = "missing"
771
+ action = "create" if relative in accept else "skip"
772
+ elif new_digest == baseline.get(relative.as_posix()):
773
+ # The new template renders this file exactly as before; the user's edits stay untouched.
774
+ state, action = "local-only", "keep"
775
+ elif current_digest == baseline.get(relative.as_posix()):
776
+ state, action = "clean", "keep" if relative in keep else "replace"
777
+ else:
778
+ state = "modified"
779
+ action = "replace" if relative in accept else "keep" if relative in keep else "blocked"
780
+ item = {"path": relative.as_posix(), "state": state, "action": action, "new_sha256": new_digest}
781
+ if action in {"replace", "blocked", "create"} or (state == "clean" and action == "keep"):
782
+ item["diff"] = _unified_diff(relative.as_posix(), current or "", new)
783
+ plan.append(item)
784
+ blocked = [item["path"] for item in plan if item["action"] == "blocked"]
785
+ replacing = [Path(item["path"]) for item in plan if item["action"] == "replace"]
786
+ result = {
787
+ "ok": not blocked,
788
+ "mode": "refresh",
789
+ "applied": False,
790
+ "pack": f"{pack['id']}@{pack['version']}",
791
+ "from_version": entry.get("version"),
792
+ "files": plan,
793
+ "blocked": blocked,
794
+ }
795
+ if blocked:
796
+ result["hint"] = "本地已改动的文件需逐个选择:--accept <文件> 用新模板替换(先备份),或 --keep <文件> 保留本地"
797
+ if not args.apply or blocked:
798
+ return (0 if not blocked else 1), result
799
+ if replacing and args.recovery_dir is None:
800
+ raise InitError("--recovery-dir is required when refresh replaces files")
801
+ if replacing:
802
+ recovery = args.recovery_dir.expanduser().resolve()
803
+ if recovery == root or root in recovery.parents:
804
+ raise InitError("--recovery-dir must be outside the project root")
805
+ for relative in replacing:
806
+ archived = recovery / relative
807
+ if archived.exists():
808
+ raise InitError(f"recovery collision: {relative}")
809
+ archived.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
810
+ archived.write_bytes((root / relative).read_bytes())
811
+ result["recovery"] = str(recovery)
812
+ for item in plan:
813
+ relative = Path(item["path"])
814
+ if item["action"] in {"replace", "create"}:
815
+ target = root / relative
816
+ target.parent.mkdir(parents=True, exist_ok=True)
817
+ temporary = target.with_name(f".{target.name}.{os.getpid()}.tmp")
818
+ temporary.write_text(rendered[relative], encoding="utf-8")
819
+ if relative == Path(inputs.entry_launcher or "\0"):
820
+ temporary.chmod(0o755)
821
+ os.replace(temporary, target)
822
+ files = dict(baseline)
823
+ for item in plan:
824
+ if item["action"] in {"replace", "create"} or item["state"] == "unchanged":
825
+ files[item["path"]] = item["new_sha256"]
826
+ # Files kept on purpose keep their old baseline: the tool did not write them.
827
+ lock.setdefault("packs", {})[pack["id"]] = {"plugin": "agents-init", "version": pack["version"],
828
+ "files": dict(sorted(files.items()))}
829
+ lock_path = root / CONTENT_LOCK_PATH
830
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
831
+ temporary = lock_path.with_name(f".{lock_path.name}.{os.getpid()}.tmp")
832
+ temporary.write_text(json_text(lock), encoding="utf-8")
833
+ os.replace(temporary, lock_path)
834
+ result["applied"] = True
835
+ return 0, result
836
+
837
+
838
+ def _atomic_write(path: Path, text: str, mode: int | None = None) -> None:
839
+ temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
840
+ temporary.write_text(text, encoding="utf-8")
841
+ if mode is not None:
842
+ temporary.chmod(mode)
843
+ os.replace(temporary, path)
844
+
845
+
846
+ def readme_plan(root: Path, name: str, launcher: str | None) -> dict | None:
847
+ """README.md: create it with the entry section, or append the section once (marked)."""
848
+ path = root / "README.md"
849
+ if path.is_symlink():
850
+ raise InitError("refusing symlinked README.md")
851
+ if not path.exists():
852
+ return {"path": "README.md", "action": "create", "text": f"# {name}\n\n{readme_block(launcher)}"}
853
+ current = path.read_text(encoding="utf-8")
854
+ if README_START in current:
855
+ return None
856
+ return {"path": "README.md", "action": "append",
857
+ "text": current.rstrip("\n") + "\n\n" + readme_block(launcher)}
858
+
859
+
860
+ def add_entry(args: argparse.Namespace, root: Path) -> tuple[int, dict]:
861
+ """Give an initialised project its root launcher and Profile ``entry`` block.
862
+
863
+ Only the Profile, the launcher, the content lock (and README.md with
864
+ --readme-entry) are touched; other template files follow the normal refresh.
865
+ An existing file with the launcher's name is the user's: it is kept or
866
+ replaced only by explicit --keep / --accept, replacements backed up first.
867
+ """
868
+ if not args.entry_name:
869
+ raise InitError("--mode entry 需要 --entry-name")
870
+ name = validate_entry_name(args.entry_name)
871
+ profile = _read_project_json(root, PROFILE_PATH, "Project Profile")
872
+ entry = profile.get("entry") if isinstance(profile.get("entry"), dict) else {}
873
+ if entry.get("launcher") and entry["launcher"] != name:
874
+ raise InitError(f"项目已声明入口 ./{entry['launcher']};改名请同时修改 Profile 的 entry.launcher 与文件名")
875
+ template = launcher_text()
876
+ target = root / name
877
+ if target.is_symlink():
878
+ raise InitError(f"refusing symlinked launcher: {name}")
879
+ current = target.read_text(encoding="utf-8", errors="replace") if target.is_file() else None
880
+ if target.exists() and not target.is_file():
881
+ raise InitError(f"{name} 已存在且不是文件")
882
+ chosen = {Path(value) for value in args.accept} | {Path(value) for value in args.keep}
883
+ if chosen - {Path(name)}:
884
+ raise InitError("--mode entry 只接受启动器文件的 --accept / --keep:" + name)
885
+ if current is None:
886
+ action = "create"
887
+ elif current == template:
888
+ action = "unchanged"
889
+ elif name in args.accept:
890
+ action = "replace"
891
+ elif name in args.keep:
892
+ action = "keep"
893
+ else:
894
+ action = "blocked"
895
+ new_profile = {**profile, "entry": {**entry, "api": entry.get("api", ENTRY_API), "launcher": name}}
896
+ profile_changed = new_profile != profile
897
+ readme = readme_plan(root, str(profile.get("name") or root.name), name) if args.readme_entry else None
898
+ writes = ([name] if action in ("create", "replace") else []) + ([str(PROFILE_PATH)] if profile_changed else []) \
899
+ + ([readme["path"]] if readme else [])
900
+ if action in ("create", "replace", "unchanged"):
901
+ writes.append(str(CONTENT_LOCK_PATH))
902
+ labels = {"create": "新建启动器", "replace": "用模板替换(先备份)", "unchanged": "已是模板内容",
903
+ "keep": "保留你的文件(不跟踪更新)", "blocked": "已存在同名文件,需要选择"}
904
+ changes = [f"{name}:{labels[action]}"]
905
+ if profile_changed:
906
+ changes.append(f"Project Profile:加入 entry(api {new_profile['entry']['api']},launcher {name})")
907
+ if readme:
908
+ changes.append("README.md:" + ("新建并写入「怎样使用」" if readme["action"] == "create" else "追加「怎样使用」一节"))
909
+ result = {
910
+ "ok": action != "blocked", "mode": "entry", "applied": False,
911
+ "entry": {"name": name, "action": action, "writes": writes, "changes": changes,
912
+ "collisions": [name] if action == "blocked" else [],
913
+ "profile_diff": _unified_diff(str(PROFILE_PATH), json_text(profile), json_text(new_profile))
914
+ if profile_changed else ""},
915
+ "blocked": [name] if action == "blocked" else [],
916
+ "readme_snippet": readme_block(name) if not readme else "",
917
+ }
918
+ if action == "blocked":
919
+ result["hint"] = f"{name} 已存在:--accept {name} 用模板替换(先备份到 --recovery-dir),或 --keep {name} 保留你的文件"
920
+ if not args.apply or action == "blocked":
921
+ return (0 if action != "blocked" else 1), result
922
+ if action == "replace":
923
+ if args.recovery_dir is None:
924
+ raise InitError("--recovery-dir is required when replacing the launcher")
925
+ recovery = args.recovery_dir.expanduser().resolve()
926
+ if recovery == root or root in recovery.parents:
927
+ raise InitError("--recovery-dir must be outside the project root")
928
+ archived = recovery / name
929
+ if archived.exists():
930
+ raise InitError(f"recovery collision: {name}")
931
+ archived.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
932
+ archived.write_bytes(target.read_bytes())
933
+ result["recovery"] = str(recovery)
934
+ if action in ("create", "replace"):
935
+ _atomic_write(target, template, 0o755)
936
+ if readme:
937
+ _atomic_write(root / readme["path"], readme["text"])
938
+ if profile_changed:
939
+ _atomic_write(root / PROFILE_PATH, json_text(new_profile))
940
+ if action in ("create", "replace", "unchanged"):
941
+ pack = contract_pack()
942
+ if pack is not None:
943
+ lock_path = root / CONTENT_LOCK_PATH
944
+ lock = _read_project_json(root, CONTENT_LOCK_PATH, "content lock") if lock_path.exists() \
945
+ else {"schema_version": 1, "packs": {}}
946
+ pack_entry = lock.setdefault("packs", {}).get(pack["id"]) or \
947
+ {"plugin": "agents-init", "version": UNKNOWN_PACK_VERSION, "files": {}}
948
+ pack_entry["files"] = dict(sorted({**pack_entry.get("files", {}),
949
+ name: hashlib.sha256(template.encode("utf-8")).hexdigest()}.items()))
950
+ lock["packs"][pack["id"]] = pack_entry
951
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
952
+ _atomic_write(lock_path, json_text(lock))
953
+ result["applied"] = True
954
+ return 0, result
955
+
956
+
957
+ def parser() -> argparse.ArgumentParser:
958
+ result = argparse.ArgumentParser(description=__doc__)
959
+ result.add_argument("--project", type=Path, required=True)
960
+ result.add_argument("--name")
961
+ result.add_argument("--slug")
962
+ result.add_argument("--project-type", type=split_csv)
963
+ result.add_argument("--vcs")
964
+ result.add_argument("--stack", type=split_csv)
965
+ result.add_argument("--runtime")
966
+ result.add_argument("--agent-cli", type=split_csv)
967
+ result.add_argument("--mode", choices=("init", "migrate", "refresh", "entry"), default="init",
968
+ help="refresh:内容包更新后按 Profile 重新渲染已跟踪的模板文件;"
969
+ "entry:为已初始化的项目补上根目录启动器与 Profile 的 entry 段")
970
+ result.add_argument("--entry-name", help=f"根目录启动器文件名(init 缺省 {DEFAULT_ENTRY};entry 模式必填)")
971
+ result.add_argument("--no-entry", action="store_true", help="init:不生成根目录启动器")
972
+ result.add_argument("--readme-entry", action="store_true",
973
+ help="在已有 README.md 末尾追加「怎样使用」一节(带标记,只追加一次);init 时 README 不存在会直接新建")
974
+ result.add_argument("--accept", action="append", default=[], help="refresh:用新模板替换这个本地已改动的文件")
975
+ result.add_argument("--keep", action="append", default=[], help="refresh:保留这个文件的本地内容")
976
+ result.add_argument("--work-mode", choices=("case-workspace", "legacy"), default=None,
977
+ help="新项目默认 case-workspace;既有项目只在显式选择后更改模式")
978
+ result.add_argument("--replace", action="append", default=[], help="known skeleton path to replace in migrate mode")
979
+ result.add_argument("--skip", action="append", default=[], help="known skeleton path the project does not want (migrate mode)")
980
+ result.add_argument("--recovery-dir", type=Path, help="outside-project backup directory required for replacements")
981
+ result.add_argument(
982
+ "--runtime-plugin-root",
983
+ type=Path,
984
+ default=PLUGIN_ROOT,
985
+ help=(
986
+ "agents-init Plugin 根目录(同级查找 project-orchestrator、agent-pack、"
987
+ "opinion-manager),或含 plugins/ 的仓库根目录"
988
+ ),
989
+ )
990
+ result.add_argument("--apply", action="store_true", help="create the complete skeleton only when no collision exists")
991
+ result.add_argument("--output", choices=("text", "json"), default="text")
992
+ return result
993
+
994
+
995
+ def main(argv: list[str] | None = None) -> int:
996
+ args = parser().parse_args(argv)
997
+ try:
998
+ root = validate_root(args.project)
999
+ if args.mode == "entry":
1000
+ code, result = add_entry(args, root)
1001
+ if args.output == "json":
1002
+ print(json_text(result), end="")
1003
+ else:
1004
+ print(("Applied" if result["applied"] else "Blocked" if result["blocked"] else "Dry-run")
1005
+ + f": entry ./{result['entry']['name']}")
1006
+ for change in result["entry"]["changes"]:
1007
+ print(f" {change}")
1008
+ if result.get("hint"):
1009
+ print(result["hint"])
1010
+ if result.get("readme_snippet"):
1011
+ print("README 里可以加上:\n" + result["readme_snippet"])
1012
+ return code
1013
+ if args.mode == "refresh":
1014
+ code, result = refresh_project(args, root)
1015
+ if args.output == "json":
1016
+ print(json_text(result), end="")
1017
+ else:
1018
+ label = "Applied" if result["applied"] else ("Blocked" if result["blocked"] else "Dry-run")
1019
+ print(f"{label}: {result['pack']} (from {result['from_version']})")
1020
+ for item in result["files"]:
1021
+ print(f" {item['action']:8} {item['path']} [{item['state']}]")
1022
+ if result.get("hint"):
1023
+ print(result["hint"])
1024
+ return code
1025
+ missing = [flag for flag, value in (("--name", args.name), ("--project-type", args.project_type),
1026
+ ("--vcs", args.vcs), ("--stack", args.stack),
1027
+ ("--runtime", args.runtime), ("--agent-cli", args.agent_cli))
1028
+ if not value]
1029
+ if missing:
1030
+ raise InitError("init/migrate 需要:" + ", ".join(missing))
1031
+ runtime_status = validate_runtime(args.runtime_plugin_root)
1032
+ slug = args.slug or slugify(args.name)
1033
+ if not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", slug):
1034
+ raise InitError("--slug must be lowercase ASCII kebab-case")
1035
+ inputs = Inputs(
1036
+ root,
1037
+ validate_inline("--name", args.name),
1038
+ slug,
1039
+ tuple(validate_inline("--project-type", value) for value in args.project_type),
1040
+ validate_inline("--vcs", args.vcs),
1041
+ tuple(validate_inline("--stack", value) for value in args.stack),
1042
+ validate_inline("--runtime", args.runtime),
1043
+ tuple(validate_inline("--agent-cli", value) for value in args.agent_cli),
1044
+ args.work_mode or ('legacy' if args.mode == 'migrate' else 'case-workspace'),
1045
+ None if args.no_entry else validate_entry_name(args.entry_name) if args.entry_name
1046
+ else DEFAULT_ENTRY if args.mode == "init" else None,
1047
+ )
1048
+ files = files_for(inputs)
1049
+ if args.mode == "migrate" and (root / PROFILE_PATH).exists():
1050
+ profile_file = root / PROFILE_PATH
1051
+ if profile_file.is_symlink() or any(parent.is_symlink() for parent in profile_file.parents if parent != root):
1052
+ raise InitError("refusing symlinked Project Profile", code="profile-invalid")
1053
+ try:
1054
+ current_profile = json.loads(profile_file.read_text(encoding="utf-8"))
1055
+ except (OSError, UnicodeError, ValueError) as exc:
1056
+ raise InitError('Project Profile 无法解析', code='profile-invalid') from exc
1057
+ if not isinstance(current_profile, dict) or current_profile.get("schema_version") != "1.0":
1058
+ raise InitError("unsupported Project Profile", code="profile-invalid")
1059
+ upgraded = configure_work_mode(upgrade_legacy_profile(current_profile), args.work_mode)
1060
+ files[PROFILE_PATH] = json_text(upgraded)
1061
+ if upgraded.get("work", {}).get("mode") != "case-workspace":
1062
+ files.pop(Path("cases/.gitkeep"), None)
1063
+ legacy_profile = read_legacy_profile(root)
1064
+ retire: list[Path] = []
1065
+ if legacy_profile is not None:
1066
+ if args.mode == "init":
1067
+ raise InitError(
1068
+ f"检测到 v5 Project Profile({LEGACY_PROFILE_PATH}),请改用 --mode migrate",
1069
+ code="migrate-required",
1070
+ )
1071
+ if not (root / PROFILE_PATH).exists():
1072
+ upgraded = configure_work_mode(upgrade_legacy_profile(legacy_profile), args.work_mode)
1073
+ files[PROFILE_PATH] = json_text(upgraded)
1074
+ if upgraded.get('work', {}).get('mode') != 'case-workspace':
1075
+ files.pop(Path('cases/.gitkeep'), None)
1076
+ retire.append(LEGACY_PROFILE_PATH)
1077
+ output_profile = json.loads(files[PROFILE_PATH])
1078
+ files.pop(Path('cases/.gitkeep'), None)
1079
+ if output_profile.get('work', {}).get('mode') == 'case-workspace':
1080
+ case_root = (output_profile['work'].get('case') or {}).get('root', 'cases')
1081
+ if not isinstance(case_root, str) or not case_root or Path(case_root).is_absolute() or '..' in Path(case_root).parts or '\\' in case_root or re.match(r'^[A-Za-z]:', case_root) or case_root == '.' or set(Path(case_root).parts) & {'.git', '.agents', '.codex', '.cursor', '.claude', 'local-ai', 'confidential'}:
1082
+ raise InitError('Case root 必须是项目内明确相对目录', code='profile-invalid')
1083
+ files[Path(case_root) / '.gitkeep'] = ''
1084
+ requested_skip = {Path(value) for value in args.skip}
1085
+ if requested_skip:
1086
+ if args.mode == "init":
1087
+ raise InitError("--skip is available only in migrate mode")
1088
+ unknown_skip = requested_skip - set(files)
1089
+ if unknown_skip:
1090
+ raise InitError("unknown skeleton path: " + ", ".join(str(path) for path in sorted(unknown_skip, key=str)))
1091
+ if PROFILE_PATH in requested_skip:
1092
+ raise InitError(f"{PROFILE_PATH} cannot be skipped")
1093
+ for path in requested_skip:
1094
+ del files[path]
1095
+ readme = None
1096
+ if args.mode == "init" and not (root / "README.md").exists():
1097
+ files[Path("README.md")] = f"# {inputs.name}\n\n{readme_block(inputs.entry_launcher)}"
1098
+ elif args.readme_entry:
1099
+ readme = readme_plan(root, inputs.name, inputs.entry_launcher)
1100
+ create, collisions, symlinked = plan_writes(
1101
+ root, files, skip_symlinked_parents=args.mode == "migrate"
1102
+ )
1103
+ requested_replace = {Path(value) for value in args.replace}
1104
+ if retire and PROFILE_PATH in symlinked:
1105
+ raise InitError(f"cannot upgrade legacy profile: {PROFILE_PATH} has a symlinked parent")
1106
+ linked_replace = requested_replace & set(symlinked)
1107
+ if linked_replace:
1108
+ raise InitError(
1109
+ "refusing to replace through symlinked parent: "
1110
+ + ", ".join(str(path) for path in sorted(linked_replace, key=str))
1111
+ )
1112
+ if args.mode == "init" and requested_replace:
1113
+ raise InitError("--replace is available only in migrate mode")
1114
+ unknown_replace = requested_replace - set(files) - set(symlinked)
1115
+ if unknown_replace:
1116
+ raise InitError("unknown skeleton path: " + ", ".join(str(path) for path in sorted(unknown_replace, key=str)))
1117
+ missing_replace = requested_replace - set(collisions)
1118
+ if missing_replace:
1119
+ raise InitError("--replace path does not exist: " + ", ".join(str(path) for path in sorted(missing_replace, key=str)))
1120
+ replace = [path for path in collisions if path in requested_replace]
1121
+ preserved = [path for path in collisions if path not in requested_replace]
1122
+ pack = contract_pack()
1123
+ baseline = content_baseline(pack, files, [*create, *replace], inputs.entry_launcher)
1124
+ if args.mode == "init":
1125
+ applied = bool(args.apply and not collisions)
1126
+ if applied:
1127
+ apply_writes(root, files, create)
1128
+ ok = not collisions
1129
+ else:
1130
+ applied = bool(args.apply)
1131
+ if applied:
1132
+ apply_migration(root, files, create, replace, args.recovery_dir, retire)
1133
+ if retire:
1134
+ try:
1135
+ (root / LEGACY_PROFILE_PATH.parent).rmdir()
1136
+ except OSError:
1137
+ pass
1138
+ ok = True
1139
+ if applied and inputs.entry_launcher and Path(inputs.entry_launcher) in [*create, *replace]:
1140
+ os.chmod(root / inputs.entry_launcher, 0o755)
1141
+ if applied and readme:
1142
+ _atomic_write(root / readme["path"], readme["text"])
1143
+ if applied and pack is not None and baseline:
1144
+ write_content_lock(root, pack, baseline)
1145
+ result = {
1146
+ "ok": ok,
1147
+ "mode": args.mode,
1148
+ "applied": applied,
1149
+ "create": [str(path) for path in create],
1150
+ "replace": [str(path) for path in replace],
1151
+ "preserved_collisions": [str(path) for path in preserved],
1152
+ "skipped_symlinked_parents": [str(path) for path in symlinked],
1153
+ "skipped_by_request": sorted(str(path) for path in requested_skip),
1154
+ "retire": [str(path) for path in retire],
1155
+ "profile_upgraded_from": str(LEGACY_PROFILE_PATH) if retire else None,
1156
+ "runtime": runtime_status,
1157
+ "entry": {"launcher": inputs.entry_launcher, "command": entry_command(inputs.entry_launcher)},
1158
+ "readme": readme["action"] if readme else ("create" if Path("README.md") in create else "untouched"),
1159
+ "content_lock": {
1160
+ "path": str(CONTENT_LOCK_PATH),
1161
+ "pack": f"{pack['id']}@{pack['version']}",
1162
+ "files": sorted(baseline),
1163
+ } if pack is not None and baseline else None,
1164
+ }
1165
+ if args.output == "json":
1166
+ print(json_text(result), end="")
1167
+ else:
1168
+ label = "Applied" if applied else ("Blocked" if args.mode == "init" and args.apply and collisions else "Dry-run")
1169
+ print(label + f": {len(create)} create, {len(replace)} replace, {len(preserved)} preserved collision(s)")
1170
+ for path in create:
1171
+ print(f" create {path}")
1172
+ for path in replace:
1173
+ print(f" replace {path}")
1174
+ for path in preserved:
1175
+ print(f" preserve {path}")
1176
+ for path in symlinked:
1177
+ print(f" skip {path} (symlinked parent)")
1178
+ for path in retire:
1179
+ print(f" retire {path} (upgraded into {PROFILE_PATH})")
1180
+ return 0 if ok else 1
1181
+ except InitError as error:
1182
+ if args.output == "json":
1183
+ print(json_text({"ok": False, "code": error.code, "error": str(error)}), end="")
1184
+ else:
1185
+ print(f"error: {error}", file=sys.stderr)
1186
+ return 2
1187
+
1188
+
1189
+ if __name__ == "__main__":
1190
+ raise SystemExit(main())