✨ feat(sync): add templates and standards actions
This commit is contained in:
parent
49bbfa13e4
commit
3d1582ce9e
|
|
@ -228,10 +228,426 @@ def vendor_action(config: dict, context: dict) -> int:
|
|||
return 0
|
||||
|
||||
|
||||
def replace_placeholders(text: str, project_name: str | None, date_value: str) -> str:
|
||||
result = text.replace("{{DATE}}", date_value)
|
||||
if project_name:
|
||||
result = result.replace("{{PROJECT_NAME}}", project_name)
|
||||
return result
|
||||
|
||||
|
||||
def backup_path(path: Path, no_backup: bool) -> None:
|
||||
if not path.exists() or no_backup:
|
||||
return
|
||||
timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
|
||||
backup = path.with_name(f"{path.name}.bak.{timestamp}")
|
||||
path.rename(backup)
|
||||
log(f"Backed up: {path} -> {backup}")
|
||||
|
||||
|
||||
def rename_template_files(root: Path) -> None:
|
||||
for template in root.rglob("*.template.md"):
|
||||
target = template.with_name(template.name.replace(".template.md", ".md"))
|
||||
template.rename(target)
|
||||
|
||||
|
||||
def replace_placeholders_in_dir(root: Path, project_name: str | None, date_value: str) -> None:
|
||||
for file_path in root.rglob("*.md"):
|
||||
text = file_path.read_text(encoding="utf-8")
|
||||
updated = replace_placeholders(text, project_name, date_value)
|
||||
if updated != text:
|
||||
file_path.write_text(updated, encoding="utf-8")
|
||||
|
||||
|
||||
def extract_block_lines(text: str, start: str, end: str) -> list[str]:
|
||||
lines = text.splitlines()
|
||||
block: list[str] = []
|
||||
in_block = False
|
||||
for line in lines:
|
||||
if line.strip() == start:
|
||||
in_block = True
|
||||
if in_block:
|
||||
block.append(line)
|
||||
if in_block and line.strip() == end:
|
||||
break
|
||||
if not block or block[-1].strip() != end:
|
||||
return []
|
||||
return block
|
||||
|
||||
|
||||
def update_agents_section(
|
||||
agents_path: Path,
|
||||
template_path: Path,
|
||||
start_marker: str,
|
||||
end_marker: str,
|
||||
project_name: str | None,
|
||||
date_value: str,
|
||||
) -> None:
|
||||
template_text = template_path.read_text(encoding="utf-8")
|
||||
template_text = replace_placeholders(template_text, project_name, date_value)
|
||||
block = extract_block_lines(template_text, start_marker, end_marker)
|
||||
if not block:
|
||||
log("Skip: markers not found in template")
|
||||
return
|
||||
|
||||
if not agents_path.exists():
|
||||
agents_path.write_text(template_text + "\n", encoding="utf-8")
|
||||
log("Created: AGENTS.md")
|
||||
return
|
||||
|
||||
agents_text = agents_path.read_text(encoding="utf-8")
|
||||
if start_marker in agents_text:
|
||||
lines = agents_text.splitlines()
|
||||
updated: list[str] = []
|
||||
in_block = False
|
||||
replaced = False
|
||||
for line in lines:
|
||||
if not replaced and line.strip() == start_marker:
|
||||
updated.extend(block)
|
||||
in_block = True
|
||||
replaced = True
|
||||
continue
|
||||
if in_block:
|
||||
if line.strip() == end_marker:
|
||||
in_block = False
|
||||
continue
|
||||
updated.append(line)
|
||||
agents_path.write_text("\n".join(updated) + "\n", encoding="utf-8")
|
||||
log("Updated: AGENTS.md (section)")
|
||||
else:
|
||||
if ".agents/index.md" in agents_text:
|
||||
log("Skip: AGENTS.md already references .agents/index.md")
|
||||
return
|
||||
updated = agents_text.rstrip("\n") + "\n\n" + "\n".join(block) + "\n"
|
||||
agents_path.write_text(updated, encoding="utf-8")
|
||||
log("Appended: AGENTS.md (section)")
|
||||
|
||||
|
||||
def sync_templates_action(config: dict, context: dict) -> int:
|
||||
project_root: Path = context["project_root"]
|
||||
if project_root.resolve() == PLAYBOOK_ROOT.resolve():
|
||||
log("Skip: playbook root equals project root.")
|
||||
return 0
|
||||
|
||||
templates_dir = PLAYBOOK_ROOT / "templates"
|
||||
if not templates_dir.is_dir():
|
||||
print(f"ERROR: templates not found: {templates_dir}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
project_name = config.get("project_name")
|
||||
date_value = config.get("date") or datetime.now().strftime("%Y-%m-%d")
|
||||
force = bool(config.get("force", False))
|
||||
no_backup = bool(config.get("no_backup", False))
|
||||
full = bool(config.get("full", False))
|
||||
|
||||
memory_src = templates_dir / "memory-bank"
|
||||
prompts_src = templates_dir / "prompts"
|
||||
agents_src = templates_dir / "AGENTS.template.md"
|
||||
rules_src = templates_dir / "AGENT_RULES.template.md"
|
||||
|
||||
if memory_src.is_dir():
|
||||
memory_dst = project_root / "memory-bank"
|
||||
if memory_dst.exists() and not force:
|
||||
log("memory-bank/ already exists. Use force to overwrite.")
|
||||
else:
|
||||
backup_path(memory_dst, no_backup)
|
||||
copytree(memory_src, memory_dst)
|
||||
rename_template_files(memory_dst)
|
||||
replace_placeholders_in_dir(memory_dst, project_name, date_value)
|
||||
log("Synced: memory-bank/")
|
||||
|
||||
if prompts_src.is_dir():
|
||||
prompts_dst = project_root / "docs/prompts"
|
||||
if prompts_dst.exists() and not force:
|
||||
log("docs/prompts/ already exists. Use force to overwrite.")
|
||||
else:
|
||||
backup_path(prompts_dst, no_backup)
|
||||
ensure_dir(prompts_dst.parent)
|
||||
copytree(prompts_src, prompts_dst)
|
||||
rename_template_files(prompts_dst)
|
||||
replace_placeholders_in_dir(prompts_dst, project_name, date_value)
|
||||
log("Synced: docs/prompts/")
|
||||
|
||||
if agents_src.is_file():
|
||||
agents_dst = project_root / "AGENTS.md"
|
||||
if full:
|
||||
start_marker = "<!-- playbook:framework:start -->"
|
||||
end_marker = "<!-- playbook:framework:end -->"
|
||||
else:
|
||||
start_marker = "<!-- playbook:templates:start -->"
|
||||
end_marker = "<!-- playbook:templates:end -->"
|
||||
update_agents_section(
|
||||
agents_dst, agents_src, start_marker, end_marker, project_name, date_value
|
||||
)
|
||||
|
||||
if rules_src.is_file():
|
||||
rules_dst = project_root / "AGENT_RULES.md"
|
||||
if rules_dst.exists() and not force:
|
||||
log("AGENT_RULES.md already exists. Use force to overwrite.")
|
||||
else:
|
||||
backup_path(rules_dst, no_backup)
|
||||
text = rules_src.read_text(encoding="utf-8")
|
||||
text = replace_placeholders(text, project_name, date_value)
|
||||
rules_dst.write_text(text + "\n", encoding="utf-8")
|
||||
log("Synced: AGENT_RULES.md")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def render_agents_block(langs: list[str]) -> list[str]:
|
||||
entries = [f"`.agents/{lang}/index.md`" for lang in langs]
|
||||
langs_line = "、".join(entries) if entries else ""
|
||||
lines = [
|
||||
"<!-- playbook:agents:start -->",
|
||||
"请以 `.agents/` 下的规则为准:",
|
||||
"- 入口:`.agents/index.md`",
|
||||
f"- 语言规则:{langs_line}" if langs_line else "- 语言规则:",
|
||||
"<!-- playbook:agents:end -->",
|
||||
]
|
||||
return lines
|
||||
|
||||
|
||||
def update_agents_block(agents_md: Path, block_lines: list[str]) -> None:
|
||||
start = "<!-- playbook:agents:start -->"
|
||||
end = "<!-- playbook:agents:end -->"
|
||||
if not agents_md.exists():
|
||||
content = "# Agent Instructions\n\n" + "\n".join(block_lines) + "\n"
|
||||
agents_md.write_text(content, encoding="utf-8")
|
||||
log("Created AGENTS.md")
|
||||
return
|
||||
|
||||
text = agents_md.read_text(encoding="utf-8")
|
||||
if start in text:
|
||||
lines = text.splitlines()
|
||||
updated: list[str] = []
|
||||
in_block = False
|
||||
replaced = False
|
||||
for line in lines:
|
||||
if not replaced and line.strip() == start:
|
||||
updated.extend(block_lines)
|
||||
in_block = True
|
||||
replaced = True
|
||||
continue
|
||||
if in_block:
|
||||
if line.strip() == end:
|
||||
in_block = False
|
||||
continue
|
||||
updated.append(line)
|
||||
agents_md.write_text("\n".join(updated) + "\n", encoding="utf-8")
|
||||
log("Updated AGENTS.md (playbook block).")
|
||||
else:
|
||||
if ".agents/index.md" in text:
|
||||
log("Skip: AGENTS.md already references .agents/index.md")
|
||||
return
|
||||
updated = text.rstrip("\n") + "\n\n" + "\n".join(block_lines) + "\n"
|
||||
agents_md.write_text(updated, encoding="utf-8")
|
||||
log("Appended playbook block to AGENTS.md")
|
||||
|
||||
|
||||
def create_agents_index(agents_root: Path, langs: list[str], docs_prefix: str | None) -> None:
|
||||
agents_index = agents_root / "index.md"
|
||||
if agents_index.exists():
|
||||
return
|
||||
lines = [
|
||||
"# .agents(多语言)",
|
||||
"",
|
||||
"本目录用于存放仓库级/语言级的代理规则集。",
|
||||
"",
|
||||
"建议约定:",
|
||||
"",
|
||||
"- `.agents/tsl/`:TSL 相关规则集(由 playbook 同步;适用于 `.tsl`/`.tsf`)",
|
||||
"- `.agents/cpp/`:C++ 相关规则集(由 playbook 同步;适用于 C++23/Modules)",
|
||||
"- `.agents/python/`:Python 相关规则集(由 playbook 同步)",
|
||||
"- `.agents/markdown/`:Markdown 相关规则集(仅代码格式化)",
|
||||
"",
|
||||
"规则发生冲突时,建议以“更靠近代码的目录规则更具体”为准。",
|
||||
"",
|
||||
"入口建议从:",
|
||||
"",
|
||||
]
|
||||
for lang in langs:
|
||||
lines.append(f"- `.agents/{lang}/index.md`")
|
||||
lines += [
|
||||
"",
|
||||
"标准快照文档入口:",
|
||||
"",
|
||||
f"- {docs_prefix or 'docs/standards/playbook/docs/'}",
|
||||
]
|
||||
agents_index.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
log("Created .agents/index.md")
|
||||
|
||||
|
||||
def rewrite_agents_docs_links(agents_dir: Path, docs_prefix: str) -> None:
|
||||
replacements = {
|
||||
"`docs/tsl/": f"`{docs_prefix}/tsl/",
|
||||
"`docs/cpp/": f"`{docs_prefix}/cpp/",
|
||||
"`docs/python/": f"`{docs_prefix}/python/",
|
||||
"`docs/markdown/": f"`{docs_prefix}/markdown/",
|
||||
"`docs/common/": f"`{docs_prefix}/common/",
|
||||
}
|
||||
for md_path in agents_dir.glob("*.md"):
|
||||
if not md_path.is_file():
|
||||
continue
|
||||
text = md_path.read_text(encoding="utf-8")
|
||||
updated = text
|
||||
for old, new in replacements.items():
|
||||
updated = updated.replace(old, new)
|
||||
if updated != text:
|
||||
md_path.write_text(updated, encoding="utf-8")
|
||||
|
||||
|
||||
def read_gitattributes_entries(path: Path) -> list[str]:
|
||||
entries: list[str] = []
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith("#"):
|
||||
continue
|
||||
entries.append(stripped)
|
||||
return entries
|
||||
|
||||
|
||||
def sync_gitattributes_overwrite(src: Path, dst: Path) -> None:
|
||||
if src.resolve() == dst.resolve():
|
||||
log("Skip: .gitattributes source equals destination.")
|
||||
return
|
||||
backup_path(dst, False)
|
||||
copy2(src, dst)
|
||||
log("Synced .gitattributes from standards (overwrite).")
|
||||
|
||||
|
||||
def sync_gitattributes_append(src: Path, dst: Path, source_note: str) -> None:
|
||||
src_entries = read_gitattributes_entries(src)
|
||||
dst_entries: list[str] = []
|
||||
if dst.exists():
|
||||
dst_entries = read_gitattributes_entries(dst)
|
||||
missing = [line for line in src_entries if line not in set(dst_entries)]
|
||||
if not missing:
|
||||
log("No missing .gitattributes rules to append.")
|
||||
return
|
||||
|
||||
original = dst.read_text(encoding="utf-8") if dst.exists() else ""
|
||||
backup_path(dst, False)
|
||||
header = f"# Added from playbook .gitattributes (source: {source_note})"
|
||||
content = original.rstrip("\n")
|
||||
if content:
|
||||
content += "\n\n"
|
||||
content += header + "\n" + "\n".join(missing) + "\n"
|
||||
dst.write_text(content, encoding="utf-8")
|
||||
log("Appended missing .gitattributes rules from standards.")
|
||||
|
||||
|
||||
def sync_gitattributes_block(src: Path, dst: Path) -> None:
|
||||
begin = "# BEGIN playbook .gitattributes"
|
||||
end = "# END playbook .gitattributes"
|
||||
begin_old = "# BEGIN tsl-playbook .gitattributes"
|
||||
end_old = "# END tsl-playbook .gitattributes"
|
||||
|
||||
src_lines = src.read_text(encoding="utf-8").splitlines()
|
||||
block_lines = [begin] + src_lines + [end]
|
||||
|
||||
if dst.exists():
|
||||
original = dst.read_text(encoding="utf-8").splitlines()
|
||||
updated: list[str] = []
|
||||
in_block = False
|
||||
replaced = False
|
||||
for line in original:
|
||||
if line == begin or line == begin_old:
|
||||
if not replaced:
|
||||
updated.extend(block_lines)
|
||||
replaced = True
|
||||
in_block = True
|
||||
continue
|
||||
if in_block:
|
||||
if line == end or line == end_old:
|
||||
in_block = False
|
||||
continue
|
||||
updated.append(line)
|
||||
if not replaced:
|
||||
if updated and updated[-1].strip():
|
||||
updated.append("")
|
||||
updated.extend(block_lines)
|
||||
backup_path(dst, False)
|
||||
dst.write_text("\n".join(updated) + "\n", encoding="utf-8")
|
||||
else:
|
||||
dst.write_text("\n".join(block_lines) + "\n", encoding="utf-8")
|
||||
log("Synced .gitattributes from standards (block).")
|
||||
|
||||
|
||||
def sync_standards_action(config: dict, context: dict) -> int:
|
||||
if "langs" not in config:
|
||||
print("ERROR: langs is required for sync_standards", file=sys.stderr)
|
||||
return 2
|
||||
try:
|
||||
langs = normalize_langs(config.get("langs"))
|
||||
except ValueError as exc:
|
||||
print(f"ERROR: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
project_root: Path = context["project_root"]
|
||||
agents_root = project_root / ".agents"
|
||||
ensure_dir(agents_root)
|
||||
|
||||
timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
|
||||
for lang in langs:
|
||||
src = PLAYBOOK_ROOT / "rulesets" / lang
|
||||
if not src.is_dir():
|
||||
print(f"ERROR: agents ruleset not found: {src}", file=sys.stderr)
|
||||
return 2
|
||||
dst = agents_root / lang
|
||||
if dst.exists():
|
||||
backup = agents_root / f"{lang}.bak.{timestamp}"
|
||||
dst.rename(backup)
|
||||
log(f"Backed up existing {lang} agents -> {backup.name}")
|
||||
copytree(src, dst)
|
||||
log(f"Synced .agents/{lang} from standards.")
|
||||
|
||||
docs_prefix = None
|
||||
try:
|
||||
rel_snapshot = PLAYBOOK_ROOT.resolve().relative_to(project_root.resolve())
|
||||
if str(rel_snapshot) != ".":
|
||||
docs_prefix = f"{rel_snapshot.as_posix()}/docs"
|
||||
except ValueError:
|
||||
docs_prefix = None
|
||||
|
||||
if docs_prefix:
|
||||
for lang in langs:
|
||||
rewrite_agents_docs_links(agents_root / lang, docs_prefix)
|
||||
|
||||
agents_md = project_root / "AGENTS.md"
|
||||
block_lines = render_agents_block(langs)
|
||||
update_agents_block(agents_md, block_lines)
|
||||
|
||||
create_agents_index(agents_root, langs, docs_prefix)
|
||||
|
||||
gitattributes_src = PLAYBOOK_ROOT / ".gitattributes"
|
||||
if gitattributes_src.is_file():
|
||||
mode = str(config.get("gitattr_mode", "append")).lower()
|
||||
gitattributes_dst = project_root / ".gitattributes"
|
||||
source_note = str(gitattributes_src)
|
||||
try:
|
||||
source_note = str(gitattributes_src.resolve().relative_to(project_root.resolve()))
|
||||
except ValueError:
|
||||
source_note = str(gitattributes_src)
|
||||
|
||||
if mode == "skip":
|
||||
log("Skip: .gitattributes sync (mode=skip).")
|
||||
elif mode == "overwrite":
|
||||
sync_gitattributes_overwrite(gitattributes_src, gitattributes_dst)
|
||||
elif mode == "block":
|
||||
sync_gitattributes_block(gitattributes_src, gitattributes_dst)
|
||||
else:
|
||||
sync_gitattributes_append(gitattributes_src, gitattributes_dst, source_note)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def run_action(name: str, config: dict, context: dict) -> int:
|
||||
print(f"[action] {name}")
|
||||
if name == "vendor":
|
||||
return vendor_action(config, context)
|
||||
print(f"[action] {name}")
|
||||
if name == "sync_templates":
|
||||
return sync_templates_action(config, context)
|
||||
if name == "sync_standards":
|
||||
return sync_standards_action(config, context)
|
||||
return 0
|
||||
|
||||
|
||||
|
|
@ -257,6 +673,8 @@ def main(argv: list[str]) -> int:
|
|||
project_root = playbook_config.get("project_root")
|
||||
if project_root:
|
||||
root = Path(project_root).expanduser()
|
||||
if not root.is_absolute():
|
||||
root = (config_path.parent / root).resolve()
|
||||
else:
|
||||
root = config_path.parent
|
||||
context = {"project_root": root.resolve(), "config_path": config_path.resolve()}
|
||||
|
|
|
|||
|
|
@ -65,5 +65,41 @@ langs = ["tsl"]
|
|||
self.assertEqual(result.returncode, 0)
|
||||
self.assertTrue(snapshot.is_file())
|
||||
|
||||
def test_sync_templates_creates_memory_bank(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
config_body = f"""
|
||||
[playbook]
|
||||
project_root = "{tmp_dir}"
|
||||
|
||||
[sync_templates]
|
||||
project_name = "Demo"
|
||||
"""
|
||||
config_path = Path(tmp_dir) / "playbook.toml"
|
||||
config_path.write_text(config_body, encoding="utf-8")
|
||||
|
||||
result = run_cli("-config", str(config_path))
|
||||
|
||||
memory_bank = Path(tmp_dir) / "memory-bank/project-brief.md"
|
||||
self.assertEqual(result.returncode, 0)
|
||||
self.assertTrue(memory_bank.is_file())
|
||||
|
||||
def test_sync_standards_creates_agents(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
config_body = f"""
|
||||
[playbook]
|
||||
project_root = "{tmp_dir}"
|
||||
|
||||
[sync_standards]
|
||||
langs = ["tsl"]
|
||||
"""
|
||||
config_path = Path(tmp_dir) / "playbook.toml"
|
||||
config_path.write_text(config_body, encoding="utf-8")
|
||||
|
||||
result = run_cli("-config", str(config_path))
|
||||
|
||||
agents_index = Path(tmp_dir) / ".agents/tsl/index.md"
|
||||
self.assertEqual(result.returncode, 0)
|
||||
self.assertTrue(agents_index.is_file())
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
Loading…
Reference in New Issue