@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.
- package/bin/agenthelm.js +1 -1
- package/package.json +2 -2
- package/runtime/index.json +80 -0
- package/runtime/plugins/agent-pack/.codex-plugin/plugin.json +19 -0
- package/runtime/plugins/agent-pack/.mcp.json +12 -0
- package/runtime/plugins/agent-pack/hooks/hooks.json +16 -0
- package/runtime/plugins/agent-pack/mcp.json +14 -0
- package/runtime/plugins/agent-pack/plugin.json +20 -0
- package/runtime/plugins/agent-pack/references/capability-config.md +124 -0
- package/runtime/{agenthelm.py → plugins/agent-pack/runtime/agenthelm.py} +42 -1
- package/runtime/plugins/agent-pack/runtime/agenthelm_runtime.py +388 -0
- package/runtime/plugins/agent-pack/skills/agent-pack/SKILL.md +46 -0
- package/runtime/plugins/agents-init/.codex-plugin/plugin.json +22 -0
- package/runtime/plugins/agents-init/content.json +31 -0
- package/runtime/plugins/agents-init/plugin.json +19 -0
- package/runtime/plugins/agents-init/skills/agents-init/AGENT.template.md +376 -0
- package/runtime/plugins/agents-init/skills/agents-init/SKILL.md +119 -0
- package/runtime/plugins/agents-init/skills/agents-init/index.html +357 -0
- package/runtime/plugins/agents-init/skills/agents-init/launcher.sh +93 -0
- package/runtime/plugins/agents-init/skills/agents-init/project.schema.json +171 -0
- package/runtime/plugins/agents-init/skills/agents-init/scripts/init_project.py +1190 -0
- package/runtime/plugins/agents-init/skills/agents-init/scripts/test_init_project.py +678 -0
- package/runtime/plugins/agents-init/skills/agents-init/skill.json +122 -0
- package/runtime/plugins/opinion-manager/.codex-plugin/plugin.json +26 -0
- package/runtime/plugins/opinion-manager/catalog/README.md +15 -0
- package/runtime/plugins/opinion-manager/catalog/rules/.gitkeep +0 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.align-requirements/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.assumptions-authority/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.batch-questions/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.bilingual-terms/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.calculations/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.chinese-response/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.clean-deliverable/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.complete-options/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.conclusion-first/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.concrete-titles/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.consistent-items/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.device-readable/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.editable-diagrams/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.evidence-inference/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.maintenance-chinese/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.maintenance-project-language/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.markdown-structure/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.portable-files/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.report-context/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.report-formats/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.report-review/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.response-brief/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.response-dense/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.rules-tools/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.semantic-wrap/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.solutions-proportional/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.solutions-three-level/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.tables-compact/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.tables-list-first/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.test-evidence/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.versioned-files/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/templates/.gitkeep +0 -0
- package/runtime/plugins/opinion-manager/catalog/templates/delivery-standard/compact-tables/versions/1.0.0.json +21 -0
- package/runtime/plugins/opinion-manager/catalog/templates/delivery-standard/list-first/versions/1.0.0.json +21 -0
- package/runtime/plugins/opinion-manager/catalog/templates/development-standard/chinese-maintenance/versions/1.0.0.json +15 -0
- package/runtime/plugins/opinion-manager/catalog/templates/development-standard/project-language/versions/1.0.0.json +15 -0
- package/runtime/plugins/opinion-manager/catalog/templates/response-style/brief-chinese/versions/1.0.0.json +19 -0
- package/runtime/plugins/opinion-manager/catalog/templates/response-style/dense-chinese/versions/1.0.0.json +19 -0
- package/runtime/plugins/opinion-manager/catalog/templates/scenario/concise-project/versions/1.0.0.json +39 -0
- package/runtime/plugins/opinion-manager/catalog/templates/scenario/reviewed-project/versions/1.0.0.json +39 -0
- package/runtime/plugins/opinion-manager/catalog/templates/work-method/proportional/versions/1.0.0.json +20 -0
- package/runtime/plugins/opinion-manager/catalog/templates/work-method/three-level/versions/1.0.0.json +20 -0
- package/runtime/plugins/opinion-manager/content.json +63 -0
- package/runtime/plugins/opinion-manager/plugin.json +18 -0
- package/runtime/plugins/opinion-manager/references/lock.schema.json +14 -0
- package/runtime/plugins/opinion-manager/references/onboarding.md +57 -0
- package/runtime/plugins/opinion-manager/references/profile.schema.json +56 -0
- package/runtime/plugins/opinion-manager/references/rule.schema.json +16 -0
- package/runtime/plugins/opinion-manager/references/template.schema.json +71 -0
- package/runtime/plugins/opinion-manager/references/versioned-template.schema.json +19 -0
- package/runtime/plugins/opinion-manager/references/versioning.md +263 -0
- package/runtime/plugins/opinion-manager/runtime/constants.py +7 -0
- package/runtime/plugins/opinion-manager/runtime/context.py +180 -0
- package/runtime/plugins/opinion-manager/runtime/lifecycle.py +395 -0
- package/runtime/plugins/opinion-manager/runtime/opinion_manager.py +419 -0
- package/runtime/plugins/opinion-manager/runtime/versioned.py +705 -0
- package/runtime/plugins/opinion-manager/skills/opinion-manager/SKILL.md +62 -0
- package/runtime/plugins/opinion-manager/skills/opinion-manager/agents/openai.yaml +4 -0
- package/runtime/plugins/opinion-manager/templates/.gitkeep +1 -0
- package/runtime/plugins/project-orchestrator/.codex-plugin/plugin.json +18 -0
- package/runtime/plugins/project-orchestrator/content.json +37 -0
- package/runtime/plugins/project-orchestrator/plugin.json +19 -0
- package/runtime/plugins/project-orchestrator/references/asset-synonyms.example.json +8 -0
- package/runtime/plugins/project-orchestrator/references/case-workspaces.md +159 -0
- package/runtime/plugins/project-orchestrator/references/case.schema.json +31 -0
- package/runtime/plugins/project-orchestrator/runtime/assets.py +1288 -0
- package/runtime/plugins/project-orchestrator/runtime/case.py +557 -0
- package/runtime/plugins/project-orchestrator/runtime/case_workspace.py +350 -0
- package/runtime/plugins/project-orchestrator/runtime/memory.py +363 -0
- package/runtime/plugins/project-orchestrator/runtime/project_base.py +107 -0
- package/runtime/plugins/project-orchestrator/runtime/project_cli.py +599 -0
- package/runtime/plugins/project-orchestrator/runtime/project_demo.py +504 -0
- package/runtime/plugins/project-orchestrator/runtime/project_maintain.py +470 -0
- package/runtime/plugins/project-orchestrator/runtime/project_menu.py +394 -0
- package/runtime/plugins/project-orchestrator/runtime/project_real.py +522 -0
- package/runtime/plugins/project-orchestrator/runtime/project_write.py +329 -0
- package/runtime/plugins/project-orchestrator/runtime/wf.py +439 -0
- package/runtime/plugins/project-orchestrator/skills/project-orchestrator/SKILL.md +230 -0
- package/runtime/plugins/project-orchestrator/skills/project-orchestrator/agents/openai.yaml +4 -0
- package/runtime/plugins/project-orchestrator/templates/CASE.md +39 -0
- /package/runtime/{agent_pack_config.py → plugins/agent-pack/runtime/agent_pack_config.py} +0 -0
- /package/runtime/{mcp_env_launcher.py → plugins/agent-pack/runtime/mcp_env_launcher.py} +0 -0
- /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())
|