Skip to content

Merge

dotagents._merge

Managed-block merge for init's AGENTS.md/CLAUDE.md (D-init-merge).

init must never clobber a user-customized AGENTS.md. Content owned by dotagents is delimited by literal marker lines and treated as a block within the file: create if missing, prepend if present-without-markers, refresh-in-place if the markers are already there. Detection is by marker presence only, never by prose matching, so it survives user reformatting.

BEGIN_MARKER = '<!-- dotagents:begin -->' module-attribute

END_MARKER = '<!-- dotagents:end -->' module-attribute

merge_block(target, block_source_text, *, force=False, dry_run=False, backup_root=None, begin_marker=BEGIN_MARKER, end_marker=END_MARKER, append=False)

Merge block_source_text (a fully marker-wrapped skeleton file's contents) into target, returning the branch taken: "created" / "block-inserted" / "block-refreshed" / "replaced (--force, backed up)".

Never overwrites content outside the markers unless force is True (then the whole file is replaced, after being backed up to backup_root if the target pre-exists).

begin_marker/end_marker default to the Markdown/HTML comment pair; pass a different pair for other comment syntaxes (e.g. #-prefixed markers for TOML).

append puts the block at the END of an existing file rather than the start. Required for TOML: a [table] header captures every key line that follows it, so prepending a table would silently swallow the user's existing top-level keys into our table.

Source code in src/dotagents/_merge.py
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
def merge_block(
    target: Path,
    block_source_text: str,
    *,
    force: bool = False,
    dry_run: bool = False,
    backup_root: "Path | None" = None,
    begin_marker: str = BEGIN_MARKER,
    end_marker: str = END_MARKER,
    append: bool = False,
) -> str:
    """Merge `block_source_text` (a fully marker-wrapped skeleton file's
    contents) into `target`, returning the branch taken:
    "created" / "block-inserted" / "block-refreshed" / "replaced (--force, backed up)".

    Never overwrites content outside the markers unless `force` is True (then
    the whole file is replaced, after being backed up to `backup_root` if the
    target pre-exists).

    `begin_marker`/`end_marker` default to the Markdown/HTML comment pair; pass a
    different pair for other comment syntaxes (e.g. `#`-prefixed markers for TOML).

    `append` puts the block at the END of an existing file rather than the start.
    Required for TOML: a `[table]` header captures every key line that follows it,
    so prepending a table would silently swallow the user's existing top-level keys
    into our table.
    """
    block = _extract_block(block_source_text, begin_marker, end_marker)

    if force:
        existed = target.exists()
        if existed and backup_root is not None:
            if not dry_run:
                _backup(target, backup_root)
        if not dry_run:
            target.parent.mkdir(parents=True, exist_ok=True)
            target.write_text(block_source_text, encoding="utf-8")
        return "replaced (--force, backed up)" if existed else "created"

    if not target.exists():
        if not dry_run:
            target.parent.mkdir(parents=True, exist_ok=True)
            target.write_text(block_source_text, encoding="utf-8")
        return "created"

    existing = target.read_text(encoding="utf-8")

    if begin_marker in existing and end_marker in existing:
        start = existing.index(begin_marker)
        end = existing.index(end_marker) + len(end_marker)
        new_text = existing[:start] + block + existing[end:]
        if new_text == existing:
            return "skipped (present)"
        if not dry_run:
            target.write_text(new_text, encoding="utf-8")
        return "block-refreshed"

    if append:
        sep = "" if existing.endswith("\n") else "\n"
        new_text = existing + sep + "\n" + block + "\n"
    else:
        new_text = block + "\n\n" + existing
    if not dry_run:
        target.write_text(new_text, encoding="utf-8")
    return "block-inserted"

merge_claude_md(target, block_source_text, *, force=False, dry_run=False, backup_root=None)

Same managed-block rule, but for the CLAUDE.md one-liner: if the file already contains an @AGENTS.md reference (any form), leave it untouched (branch "skipped (present)") instead of requiring exact marker match -- a bare @AGENTS.md file written by hand still counts as "present".

Source code in src/dotagents/_merge.py
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
def merge_claude_md(
    target: Path,
    block_source_text: str,
    *,
    force: bool = False,
    dry_run: bool = False,
    backup_root: "Path | None" = None,
) -> str:
    """Same managed-block rule, but for the CLAUDE.md one-liner: if the file
    already contains an `@AGENTS.md` reference (any form), leave it untouched
    (branch "skipped (present)") instead of requiring exact marker match --
    a bare `@AGENTS.md` file written by hand still counts as "present"."""
    if not force and target.exists():
        existing = target.read_text(encoding="utf-8")
        if "@AGENTS.md" in existing:
            return "skipped (present)"
    return merge_block(
        target, block_source_text, force=force, dry_run=dry_run, backup_root=backup_root
    )

timestamped_backup_root(dest)

Source code in src/dotagents/_merge.py
121
122
def timestamped_backup_root(dest: Path) -> Path:
    return dest / "install_backup" / time.strftime("%Y%m%d-%H%M%S")