feat(sync): add templates and standards actions

This commit is contained in:
csh 2026-01-23 14:37:55 +08:00
parent 49bbfa13e4
commit 3d1582ce9e
2 changed files with 455 additions and 1 deletions

View File

@ -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()}

View File

@ -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()