Repository navigation
Expand file tree
/
Copy pathbootstrap.py
More file actions
582 lines (498 loc) · 21.1 KB
/
Copy pathbootstrap.py
File metadata and controls
582 lines (498 loc) · 21.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
#!/usr/bin/env python3
"""Bootstrap the project memory system.
Reads ${var}-substituted markdown templates from the 'templates/' directory next
to this script (override with --templates-dir). Renders them into:
- AGENTS.md and CLAUDE.md at the project root
- memory-index.md, product.md, architecture.md, and a dated bootstrap log entry
under docs/memory/
- a README.md in docs/plans/current/ and docs/plans/archive/
- a .gitattributes rule giving memory-index.md git's union merge driver
If AGENTS.md, CLAUDE.md or .gitattributes already exist, interactively offers to
merge in (append a '## Project memory' section to AGENTS.md; prepend '@AGENTS.md'
to CLAUDE.md; append the union rule to .gitattributes unless git already applies
a merge attribute to the index). Refuses to overwrite any files under
docs/memory/. Safe to re-run after partial setup.
Usage:
python3 bootstrap.py "<Project name>" "<One-line description>"
python3 bootstrap.py --project /path/to/repo "<Project>" "<Description>"
python3 bootstrap.py --upgrade --project /path/to/repo
python3 bootstrap.py --upgrade --force --project /path/to/repo
If --project is omitted, the current working directory is used. With --upgrade,
the AGENTS.md marker block is replaced with the current template's contents,
content outside the markers is preserved, and any missing docs/plans/
directories and the .gitattributes rule are added. An upgrade refuses to run
when the marker block holds sections the template does not have, since
replacing it would delete them; --force overrides that.
"""
import argparse
import datetime
import difflib
import os
import pathlib
import re
import string
import subprocess
import sys
START_MARKER = "<!-- project-memory:start -->"
END_MARKER = "<!-- project-memory:end -->"
PLANS_SUBDIRS = ("current", "archive")
INDEX_PATH = "docs/memory/memory-index.md"
UNION_RULE = f"{INDEX_PATH} merge=union"
def git_user_name(cwd: pathlib.Path) -> str:
try:
result = subprocess.run(
["git", "config", "user.name"],
capture_output=True,
text=True,
check=True,
cwd=cwd,
)
return result.stdout.strip() or "unknown"
except (subprocess.CalledProcessError, FileNotFoundError):
return "unknown"
def colorize_diff(diff: str) -> str:
"""Color a unified diff with ANSI codes when stdout is a TTY.
Respects NO_COLOR (https://no-color.org/) and skips coloring when stdout
is piped or redirected so codes don't leak into files.
"""
if not sys.stdout.isatty() or os.environ.get("NO_COLOR"):
return diff
reset = "\033[0m"
bold = "\033[1m"
red = "\033[31m"
green = "\033[32m"
cyan = "\033[36m"
out = []
for line in diff.splitlines(keepends=True):
eol = "\n" if line.endswith("\n") else ""
body = line[: -len(eol)] if eol else line
if body.startswith(("+++", "---")):
out.append(f"{bold}{body}{reset}{eol}")
elif body.startswith("+"):
out.append(f"{green}{body}{reset}{eol}")
elif body.startswith("-"):
out.append(f"{red}{body}{reset}{eol}")
elif body.startswith("@@"):
out.append(f"{cyan}{body}{reset}{eol}")
else:
out.append(line)
return "".join(out)
def confirm(prompt: str) -> bool:
"""Ask yes/no on stdin. Defaults to no, including in non-interactive contexts."""
try:
response = input(prompt).strip().lower()
except EOFError:
return False
return response in ("y", "yes")
def read_text(path: pathlib.Path) -> str:
"""Read a file as UTF-8. Centralizes the encoding choice for portability."""
return path.read_text(encoding="utf-8")
def write_text(path: pathlib.Path, content: str) -> None:
"""Write a file as UTF-8. Centralizes the encoding choice for portability."""
path.write_text(content, encoding="utf-8")
def as_sentence(text: str) -> str:
"""Return text with a trailing period unless it already ends in terminal punctuation."""
text = text.rstrip()
return text if text.endswith((".", "!", "?")) else f"{text}."
def load_template(
templates_dir: pathlib.Path, filename: str, **substitutions: str
) -> str:
"""Load a template file from templates_dir and apply ${var} substitutions."""
path = templates_dir / filename
if not path.is_file():
sys.exit(f"missing template file: {path}")
return string.Template(read_text(path)).substitute(**substitutions)
def blocking_non_directory(path: pathlib.Path) -> pathlib.Path | None:
"""The nearest existing ancestor of path that is not a directory, if any."""
for candidate in (path, *path.parents):
if candidate.is_symlink() or candidate.exists():
return None if candidate.is_dir() else candidate
return None
def ensure_directory(path: pathlib.Path) -> None:
"""Create a directory, exiting with a readable message if a file is in the way."""
try:
path.mkdir(parents=True, exist_ok=True)
except (FileExistsError, NotADirectoryError):
blocker = blocking_non_directory(path) or path
sys.exit(
f"cannot create directory {path}: {blocker} exists and is not a "
f"directory. Move or remove it, then re-run."
)
def write_file(path: pathlib.Path, content: str) -> None:
if path.exists():
sys.exit(f"refusing to overwrite existing file: {path}")
ensure_directory(path.parent)
write_text(path, content)
print(f"wrote {path}")
def extract_memory_block(content: str, source_label: str) -> str:
"""Return the inclusive slice between START_MARKER and END_MARKER, or exit."""
start = content.find(START_MARKER)
end = content.find(END_MARKER)
if start < 0 or end < 0 or end < start:
sys.exit(f"{source_label} missing '{START_MARKER}' / '{END_MARKER}' markers")
return content[start : end + len(END_MARKER)]
ATX_HEADING = re.compile(r"#{1,6}\s+\S")
RULE_LEAD_IN = re.compile(r"\*\*(.+?)\*\*")
def block_sections(block: str) -> list[str]:
"""Return the section markers in a marker block, ignoring fenced code.
Markers are ATX headings and the bold label that opens each template rule.
Only the label is returned for a lead-in, so reworded prose after it is not a
new section. Setext headings are not recognized.
"""
sections = []
in_fence = False
for line in block.splitlines():
stripped = line.strip()
if stripped.startswith(("```", "~~~")):
in_fence = not in_fence
continue
if in_fence:
continue
if ATX_HEADING.match(stripped):
sections.append(stripped)
continue
lead_in = RULE_LEAD_IN.match(stripped)
if lead_in:
sections.append(f"**{lead_in.group(1)}**")
return sections
def custom_sections(current_block: str, template_block: str) -> list[str]:
"""Section markers in the project's marker block that the template lacks.
These are sections a user added inside the markers, which an upgrade would
silently delete. Reworded template prose is not flagged, only new sections.
"""
known = set(block_sections(template_block))
found = []
for section in block_sections(current_block):
if section not in known and section not in found:
found.append(section)
return found
def merge_agents_md(path: pathlib.Path, full_content: str) -> None:
"""Create AGENTS.md, or offer to append memory section to an existing one.
The memory section in the template is delimited by HTML-comment markers
so future template growth (extra sections after it) won't leak into the
appended slice. Detection of "already present" uses the heading text, so
a hand-added section without markers is also recognized.
"""
heading = "## Project memory"
if not path.exists():
path.parent.mkdir(parents=True, exist_ok=True)
write_text(path, full_content)
print(f"wrote {path}")
return
existing = read_text(path)
if heading in existing:
print(f"skipped {path} (memory section already present)")
return
if not confirm(f"{path} exists. Append '{heading}' section to the end? [y/N]: "):
print(
f"skipped {path} - add the memory section manually if you want agents to read these rules"
)
return
memory_section = extract_memory_block(full_content, "AGENTS.md template")
new = existing.rstrip() + "\n\n" + memory_section.strip() + "\n"
write_text(path, new)
print(f"appended memory section to {path}")
def upgrade_agents_md(
project_root: pathlib.Path, templates_dir: pathlib.Path, force: bool = False
) -> bool:
"""Replace the marker block in project AGENTS.md with the template's contents.
Exits non-zero if the block holds sections the template does not have, unless
force is set. Returns True when the block is current afterwards, False when
the user declined.
"""
project_agents = project_root / "AGENTS.md"
if not project_agents.is_file():
sys.exit(
f"AGENTS.md not found at {project_agents} - run bootstrap to create it first"
)
template_agents = templates_dir / "AGENTS.md"
if not template_agents.is_file():
sys.exit(f"template AGENTS.md not found: {template_agents}")
existing = read_text(project_agents)
template = read_text(template_agents)
current_block = extract_memory_block(existing, str(project_agents))
new_block = extract_memory_block(template, str(template_agents))
if current_block == new_block:
print(f"AGENTS.md is already current ({project_agents})")
return True
custom = custom_sections(current_block, new_block)
if custom and not force:
sections = "\n".join(f" {section}" for section in custom)
sys.exit(
f"refusing to upgrade {project_agents}: the marker block holds "
f"sections the template does not have, and replacing the block "
f"would delete them:\n\n{sections}\n\n"
f"Move them below '{END_MARKER}' - content outside the markers is "
f"preserved - then re-run. Pass --force to replace the block anyway."
)
diff = "".join(
difflib.unified_diff(
current_block.splitlines(keepends=True),
new_block.splitlines(keepends=True),
fromfile="current",
tofile="template",
)
)
print(colorize_diff(diff), end="")
if not confirm(f"\nReplace marker block in {project_agents}? [y/N]: "):
print("aborted")
return False
write_text(project_agents, existing.replace(current_block, new_block, 1))
print(f"updated {project_agents}")
return True
def load_plans_readmes(templates_dir: pathlib.Path) -> dict[pathlib.Path, str]:
"""Render the docs/plans READMEs, keyed by path relative to the project root."""
return {
pathlib.Path("docs/plans") / subdir / "README.md": load_template(
templates_dir, f"plans-{subdir}-readme.md"
)
for subdir in PLANS_SUBDIRS
}
def write_plans_readmes(
project_root: pathlib.Path, readmes: dict[pathlib.Path, str]
) -> None:
"""Write the docs/plans READMEs, creating parent directories as needed.
Idempotent: an existing README is left untouched rather than treated as a
conflict, so --upgrade can add the layout to an already-bootstrapped repo.
"""
for relpath, content in readmes.items():
target = project_root / relpath
if target.is_file():
print(f"skipped {target} (already exists)")
continue
if target.exists():
sys.exit(f"not a regular file, refusing to continue: {target}")
ensure_directory(target.parent)
write_text(target, content)
print(f"wrote {target}")
def merge_claude_md(path: pathlib.Path, full_content: str) -> None:
"""Create CLAUDE.md, or offer to prepend the import line to an existing one."""
import_line = full_content.strip()
if not path.exists():
path.parent.mkdir(parents=True, exist_ok=True)
write_text(path, full_content)
print(f"wrote {path}")
return
existing = read_text(path)
if import_line in existing:
print(f"skipped {path} ({import_line} already imported)")
return
if not confirm(f"{path} exists. Prepend '{import_line}' to the top? [y/N]: "):
print(
f"skipped {path} - add '{import_line}' manually so Claude Code reads memory rules"
)
return
new = import_line + "\n\n" + existing
write_text(path, new)
print(f"prepended {import_line} to {path}")
def check_gitattributes(project_root: pathlib.Path) -> None:
"""Exit if .gitattributes is a symlink (dangling included) or not a regular file."""
path = project_root / ".gitattributes"
if path.is_symlink() or (path.exists() and not path.is_file()):
sys.exit(f"not a regular file, refusing to continue: {path}")
def index_merge_attribute(project_root: pathlib.Path) -> str | None:
"""The merge attribute git applies to the index, or None when git cannot say.
Returns git's own value: 'unspecified', 'set', 'unset', or a driver name.
Global attributes are excluded, since they do not travel with the repo.
"""
try:
result = subprocess.run(
[
"git",
"-c",
f"core.attributesFile={os.devnull}",
"check-attr",
"-z",
"merge",
"--",
INDEX_PATH,
],
capture_output=True,
text=True,
check=True,
cwd=project_root,
)
except (subprocess.CalledProcessError, FileNotFoundError):
return None
# -z output is <path> NUL <attribute> NUL <value> NUL.
fields = result.stdout.split("\0")
return fields[2] if len(fields) >= 3 else None
def has_union_rule(content: bytes) -> bool:
"""True if a line is exactly UNION_RULE. Idempotence only; git decides precedence."""
text = content.decode("utf-8", errors="surrogateescape")
return any(line.split() == UNION_RULE.split() for line in text.splitlines())
def merge_gitattributes(project_root: pathlib.Path) -> None:
"""Create .gitattributes with UNION_RULE, or offer to append it to an existing one.
Leaves the file alone when git already applies a merge attribute to the
index, so an explicit choice in the repo is never overridden.
"""
path = project_root / ".gitattributes"
existing = path.read_bytes() if path.exists() else None
current = index_merge_attribute(project_root)
if current == "union":
print(f"skipped {path} (union merge already applies to {INDEX_PATH})")
return
if current not in (None, "unspecified"):
print(
f"skipped {path} - git already applies 'merge: {current}' to "
f"{INDEX_PATH}; change it to merge=union by hand if you want that"
)
return
if existing is not None and has_union_rule(existing):
if current is None:
print(f"skipped {path} ({UNION_RULE} already present)")
else:
print(
f"skipped {path} - {UNION_RULE} is present but git reports "
f"'merge: unspecified' for {INDEX_PATH}, so another rule "
f"overrides it; fix that rule by hand if you want union merges"
)
return
if existing is None:
with path.open("xb") as f:
f.write(UNION_RULE.encode("utf-8") + b"\n")
print(f"wrote {path}")
return
if not confirm(f"{path} exists. Append '{UNION_RULE}'? [y/N]: "):
print(
f"skipped {path} - add '{UNION_RULE}' manually so concurrent log "
f"entries do not conflict"
)
return
eol = b"\r\n" if b"\r\n" in existing else b"\n"
separator = eol if existing and not existing.endswith(b"\n") else b""
with path.open("ab") as f:
f.write(separator + UNION_RULE.encode("utf-8") + eol)
print(f"appended {UNION_RULE} to {path}")
def main() -> None:
ap = argparse.ArgumentParser(
description="Bootstrap a project memory system.",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
ap.add_argument(
"name", nargs="?", help="Project name (not required with --upgrade)"
)
ap.add_argument(
"description",
nargs="?",
help="One-line project description (not required with --upgrade)",
)
ap.add_argument(
"--project",
default=".",
help="Project root directory (default: current directory)",
)
ap.add_argument(
"--author",
default=None,
help="Memory-entry author (default: git config user.name in the project, or 'unknown')",
)
ap.add_argument(
"--templates-dir",
default=None,
help="Directory containing template .md files (default: templates/ next to this script)",
)
ap.add_argument(
"--upgrade",
action="store_true",
help="Re-sync the AGENTS.md marker block to the current template, create any missing docs/plans directories, and add the .gitattributes rule",
)
ap.add_argument(
"--force",
action="store_true",
help="With --upgrade, replace the marker block even when it holds sections the template does not have",
)
args = ap.parse_args()
if args.force and not args.upgrade:
ap.error("--force is only valid with --upgrade")
project_root = pathlib.Path(args.project).resolve()
if not project_root.is_dir():
sys.exit(f"project directory does not exist: {project_root}")
templates_dir = (
pathlib.Path(args.templates_dir).resolve()
if args.templates_dir
else pathlib.Path(__file__).resolve().parent / "templates"
)
if not templates_dir.is_dir():
sys.exit(f"templates directory does not exist: {templates_dir}")
check_gitattributes(project_root)
if args.upgrade:
plans_readmes = load_plans_readmes(templates_dir)
if upgrade_agents_md(project_root, templates_dir, force=args.force):
write_plans_readmes(project_root, plans_readmes)
merge_gitattributes(project_root)
return
if not args.name or not args.description:
ap.error("name and description are required unless --upgrade is set")
today = datetime.date.today().isoformat()
author = args.author or git_user_name(project_root)
# Render all templates.
agents_md = load_template(
templates_dir,
"AGENTS.md",
name=args.name,
desc=args.description,
desc_sentence=as_sentence(args.description),
)
claude_md = load_template(templates_dir, "CLAUDE.md")
memory_index = load_template(
templates_dir, "memory-index.md", name=args.name, today=today
)
product = load_template(
templates_dir,
"product.md",
desc=args.description,
desc_sentence=as_sentence(args.description),
author=author,
today=today,
)
architecture = load_template(
templates_dir,
"architecture.md",
name=args.name,
author=author,
today=today,
)
bootstrap_log = load_template(
templates_dir, "bootstrap-log.md", author=author, today=today
)
plans_readmes = load_plans_readmes(templates_dir)
memory_files = {
pathlib.Path("docs/memory/memory-index.md"): memory_index,
pathlib.Path("docs/memory/product.md"): product,
pathlib.Path("docs/memory/architecture.md"): architecture,
pathlib.Path(f"docs/memory/log/{today}-bootstrap.md"): bootstrap_log,
}
# Pre-flight: bail before any prompts if a memory file already exists.
# Without this, the user could answer prompts and then we'd abort partway.
for relpath in memory_files:
target = project_root / relpath
if target.exists():
sys.exit(f"refusing to overwrite existing file: {target}")
for relpath in (*memory_files, *plans_readmes):
target = project_root / relpath
blocker = blocking_non_directory(target.parent)
if blocker is not None:
sys.exit(
f"cannot create directory {target.parent}: {blocker} exists and "
f"is not a directory. Move or remove it, then re-run."
)
if target.exists() and not target.is_file():
sys.exit(f"not a regular file, refusing to continue: {target}")
# Root files: create new, or interactively offer to merge into existing.
merge_agents_md(project_root / "AGENTS.md", agents_md)
merge_claude_md(project_root / "CLAUDE.md", claude_md)
merge_gitattributes(project_root)
# Memory files: write fresh (pre-flight guaranteed no conflicts).
for relpath, content in memory_files.items():
write_file(project_root / relpath, content)
write_plans_readmes(project_root, plans_readmes)
print()
print(f"Done. Bootstrapped memory system in {project_root}")
print("Next steps:")
print(
" 1. Fill in <placeholder> strings in docs/memory/product.md and docs/memory/architecture.md"
)
print(" 2. Commit as a single commit (e.g. 'Bootstrap project memory system')")
if __name__ == "__main__":
main()