mdcompose 0.1.0__py3-none-any.whl
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.
- mdcompose/__init__.py +0 -0
- mdcompose/cli.py +219 -0
- mdcompose/commands/__init__.py +0 -0
- mdcompose/commands/config_cmd.py +172 -0
- mdcompose/commands/convert_cmd.py +299 -0
- mdcompose/commands/eject_cmd.py +180 -0
- mdcompose/commands/import_cmd.py +351 -0
- mdcompose/commands/init_cmd.py +668 -0
- mdcompose/commands/snippet_cmd.py +312 -0
- mdcompose/commands/target_cmd.py +134 -0
- mdcompose/core/__init__.py +0 -0
- mdcompose/core/composition.py +96 -0
- mdcompose/core/config.py +454 -0
- mdcompose/core/convert_ops.py +193 -0
- mdcompose/core/eject.py +168 -0
- mdcompose/core/exit_codes.py +28 -0
- mdcompose/core/files.py +194 -0
- mdcompose/core/import_ops.py +175 -0
- mdcompose/core/init_ops.py +436 -0
- mdcompose/core/library_ops.py +199 -0
- mdcompose/core/managed_block.py +380 -0
- mdcompose/core/manifest.py +404 -0
- mdcompose/core/output.py +208 -0
- mdcompose/core/platform.py +368 -0
- mdcompose/core/report.py +278 -0
- mdcompose/core/sections.py +215 -0
- mdcompose/core/snippets.py +359 -0
- mdcompose/core/targets.py +200 -0
- mdcompose/prompts.py +102 -0
- mdcompose/version.py +31 -0
- mdcompose-0.1.0.dist-info/METADATA +234 -0
- mdcompose-0.1.0.dist-info/RECORD +35 -0
- mdcompose-0.1.0.dist-info/WHEEL +4 -0
- mdcompose-0.1.0.dist-info/entry_points.txt +2 -0
- mdcompose-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
"""The snippet command group: list, edit, remove, adopt.
|
|
2
|
+
|
|
3
|
+
A thin adapter. Each command parses arguments, resolves the library, calls into
|
|
4
|
+
core, and formats the result. The prompting lives here and the outcome lives in
|
|
5
|
+
core, which is what lets a flag and an answered prompt reach the same code.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import shlex
|
|
13
|
+
import subprocess
|
|
14
|
+
import tempfile
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Annotated
|
|
17
|
+
|
|
18
|
+
import typer
|
|
19
|
+
|
|
20
|
+
from mdcompose.core import config as config_module
|
|
21
|
+
from mdcompose.core import files, library_ops
|
|
22
|
+
from mdcompose.core import manifest as manifest_module
|
|
23
|
+
from mdcompose.core import platform as platform_module
|
|
24
|
+
from mdcompose.core import snippets as snippets_module
|
|
25
|
+
from mdcompose.core.exit_codes import AttentionError
|
|
26
|
+
from mdcompose.core.output import OutputContext
|
|
27
|
+
from mdcompose.prompts import confirm, output_for, prompt_choice
|
|
28
|
+
|
|
29
|
+
app = typer.Typer(
|
|
30
|
+
name="snippet",
|
|
31
|
+
help="Manage the personal snippet library.",
|
|
32
|
+
rich_markup_mode=None,
|
|
33
|
+
no_args_is_help=True,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
QuietOption = Annotated[
|
|
37
|
+
bool, typer.Option("--quiet", "-q", help="Suppress informational output.")
|
|
38
|
+
]
|
|
39
|
+
JsonOption = Annotated[
|
|
40
|
+
bool, typer.Option("--json", help="Emit one JSON document on stdout and nothing else.")
|
|
41
|
+
]
|
|
42
|
+
NoColourOption = Annotated[bool, typer.Option("--no-color", help="Never colourize output.")]
|
|
43
|
+
TagOption = Annotated[str | None, typer.Option("--tag", help="Only snippets carrying this tag.")]
|
|
44
|
+
CategoryOption = Annotated[
|
|
45
|
+
str | None, typer.Option("--category", help="Only snippets in this category.")
|
|
46
|
+
]
|
|
47
|
+
ContentOption = Annotated[
|
|
48
|
+
str | None,
|
|
49
|
+
typer.Option(
|
|
50
|
+
"--content", help="Replacement file contents. Supplying this skips the editor."
|
|
51
|
+
),
|
|
52
|
+
]
|
|
53
|
+
YesOption = Annotated[bool, typer.Option("--yes", "-y", help="Answer yes to every confirmation.")]
|
|
54
|
+
OnCollisionOption = Annotated[
|
|
55
|
+
str | None,
|
|
56
|
+
typer.Option(
|
|
57
|
+
"--on-collision",
|
|
58
|
+
help="Resolve every collision without asking: keep or overwrite.",
|
|
59
|
+
),
|
|
60
|
+
]
|
|
61
|
+
IdsArgument = Annotated[
|
|
62
|
+
list[str] | None,
|
|
63
|
+
typer.Argument(help="Snippet ids to adopt. Defaults to every embedded snippet."),
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def resolve_library() -> snippets_module.LibraryView:
|
|
68
|
+
"""Read the configured snippet library once."""
|
|
69
|
+
directory = platform_module.config_dir()
|
|
70
|
+
configuration = config_module.load_config(config_module.config_path(directory))
|
|
71
|
+
return snippets_module.view(config_module.library_dir(configuration, directory))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@app.command("list")
|
|
75
|
+
def list_snippets(
|
|
76
|
+
context: typer.Context,
|
|
77
|
+
tag: TagOption = None,
|
|
78
|
+
category: CategoryOption = None,
|
|
79
|
+
quiet: QuietOption = False,
|
|
80
|
+
json_output: JsonOption = False,
|
|
81
|
+
no_colour: NoColourOption = False,
|
|
82
|
+
) -> None:
|
|
83
|
+
"""List the snippets in the library.
|
|
84
|
+
|
|
85
|
+
Filters narrow rather than widen: supplying both a tag and a category lists
|
|
86
|
+
only snippets matching both. An empty library is reported, not an error.
|
|
87
|
+
"""
|
|
88
|
+
output = output_for(context, quiet=quiet, json_output=json_output, no_colour=no_colour)
|
|
89
|
+
library = resolve_library()
|
|
90
|
+
matched = snippets_module.filter_snippets(library.snippets, tag=tag, category=category)
|
|
91
|
+
|
|
92
|
+
if output.json_mode:
|
|
93
|
+
output.emit(_as_json(library, matched))
|
|
94
|
+
return
|
|
95
|
+
|
|
96
|
+
if library.is_empty:
|
|
97
|
+
output.info(
|
|
98
|
+
f"the snippet library at {library.directory.as_posix()} is empty. "
|
|
99
|
+
"Add one with 'mdcompose import <file> --save-as-snippet <name>'."
|
|
100
|
+
)
|
|
101
|
+
return
|
|
102
|
+
if not matched:
|
|
103
|
+
output.info("no snippet matches that filter")
|
|
104
|
+
return
|
|
105
|
+
|
|
106
|
+
id_width = max(len(snippet.id) for snippet in matched)
|
|
107
|
+
name_width = max(len(snippet.display_name) for snippet in matched)
|
|
108
|
+
id_width = max(id_width, len("id"))
|
|
109
|
+
name_width = max(name_width, len("name"))
|
|
110
|
+
output.info(f" {'id'.ljust(id_width)} {'name'.ljust(name_width)} description")
|
|
111
|
+
for snippet in matched:
|
|
112
|
+
row_id = snippet.id.ljust(id_width)
|
|
113
|
+
row_name = snippet.display_name.ljust(name_width)
|
|
114
|
+
description = "" if snippet.description is None else snippet.description
|
|
115
|
+
output.info(f" {row_id} {row_name} {description}")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _as_json(
|
|
119
|
+
library: snippets_module.LibraryView, matched: tuple[snippets_module.Snippet, ...]
|
|
120
|
+
) -> str:
|
|
121
|
+
return json.dumps(
|
|
122
|
+
{
|
|
123
|
+
"library": library.directory.as_posix(),
|
|
124
|
+
"snippets": [
|
|
125
|
+
{
|
|
126
|
+
"id": snippet.id,
|
|
127
|
+
"title": snippet.title,
|
|
128
|
+
"description": snippet.description,
|
|
129
|
+
"tags": list(snippet.tags),
|
|
130
|
+
"applies_to": snippet.applies_to,
|
|
131
|
+
"stack_signals": list(snippet.stack_signals),
|
|
132
|
+
"category": snippet.category,
|
|
133
|
+
"order": snippet.order,
|
|
134
|
+
}
|
|
135
|
+
for snippet in matched
|
|
136
|
+
],
|
|
137
|
+
},
|
|
138
|
+
indent=2,
|
|
139
|
+
ensure_ascii=True,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@app.command("edit")
|
|
144
|
+
def edit_snippet(
|
|
145
|
+
context: typer.Context,
|
|
146
|
+
snippet_id: Annotated[str, typer.Argument(help="The snippet to edit.")],
|
|
147
|
+
content: ContentOption = None,
|
|
148
|
+
quiet: QuietOption = False,
|
|
149
|
+
no_colour: NoColourOption = False,
|
|
150
|
+
) -> None:
|
|
151
|
+
"""Open a snippet in the configured editor, or replace its contents directly.
|
|
152
|
+
|
|
153
|
+
The result is validated before it is installed, so a broken edit leaves the
|
|
154
|
+
existing snippet exactly as it was.
|
|
155
|
+
"""
|
|
156
|
+
output = output_for(context, quiet=quiet, no_colour=no_colour)
|
|
157
|
+
library = resolve_library()
|
|
158
|
+
snippet = library.require(snippet_id)
|
|
159
|
+
|
|
160
|
+
if content is not None:
|
|
161
|
+
library_ops.replace_body(library, snippet_id, content)
|
|
162
|
+
output.info(f"wrote {snippet_id}")
|
|
163
|
+
return
|
|
164
|
+
|
|
165
|
+
if snippet.path is None:
|
|
166
|
+
raise AttentionError(f"snippet '{snippet_id}' has no file on disk to edit")
|
|
167
|
+
edited = _edit_in_editor(snippet.path)
|
|
168
|
+
if edited is None:
|
|
169
|
+
output.info(f"{snippet_id} unchanged")
|
|
170
|
+
return
|
|
171
|
+
library_ops.replace_body(library, snippet_id, edited)
|
|
172
|
+
output.info(f"wrote {snippet_id}")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _edit_in_editor(path: Path) -> str | None:
|
|
176
|
+
"""Open a copy in the editor and return the result, or None if unchanged.
|
|
177
|
+
|
|
178
|
+
A copy rather than the file itself, so an edit that fails validation cannot
|
|
179
|
+
leave the library holding it.
|
|
180
|
+
"""
|
|
181
|
+
editor = os.environ.get("VISUAL") or os.environ.get("EDITOR")
|
|
182
|
+
if not editor:
|
|
183
|
+
raise AttentionError(
|
|
184
|
+
"no editor configured. Set VISUAL or EDITOR, or pass --content to "
|
|
185
|
+
"supply the new contents directly."
|
|
186
|
+
)
|
|
187
|
+
original = files.read_text(path)
|
|
188
|
+
with tempfile.TemporaryDirectory() as scratch:
|
|
189
|
+
draft = Path(scratch) / path.name
|
|
190
|
+
files.write_text(draft, original)
|
|
191
|
+
completed = subprocess.run([*shlex.split(editor), str(draft)], check=False)
|
|
192
|
+
if completed.returncode != 0:
|
|
193
|
+
raise AttentionError(f"editor exited with status {completed.returncode}")
|
|
194
|
+
edited = files.read_text(draft)
|
|
195
|
+
return None if files.content_equal(edited, original) else edited
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@app.command("remove")
|
|
199
|
+
def remove_snippet(
|
|
200
|
+
context: typer.Context,
|
|
201
|
+
snippet_id: Annotated[str, typer.Argument(help="The snippet to delete.")],
|
|
202
|
+
yes: YesOption = False,
|
|
203
|
+
quiet: QuietOption = False,
|
|
204
|
+
no_colour: NoColourOption = False,
|
|
205
|
+
) -> None:
|
|
206
|
+
"""Delete a snippet from the library.
|
|
207
|
+
|
|
208
|
+
Confirms first. Never touches a project that already composed it: composition
|
|
209
|
+
copies content, so a composed file keeps working without its source snippet.
|
|
210
|
+
"""
|
|
211
|
+
output = output_for(context, quiet=quiet, no_colour=no_colour)
|
|
212
|
+
library = resolve_library()
|
|
213
|
+
library.require(snippet_id)
|
|
214
|
+
|
|
215
|
+
if not confirm(output, f"delete snippet '{snippet_id}' from the library?", assume_yes=yes):
|
|
216
|
+
output.info(f"kept {snippet_id}")
|
|
217
|
+
return
|
|
218
|
+
library_ops.remove_snippet(library, snippet_id)
|
|
219
|
+
output.info(f"removed {snippet_id}")
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@app.command("adopt")
|
|
223
|
+
def adopt_snippets(
|
|
224
|
+
context: typer.Context,
|
|
225
|
+
ids: IdsArgument = None,
|
|
226
|
+
on_collision: OnCollisionOption = None,
|
|
227
|
+
quiet: QuietOption = False,
|
|
228
|
+
no_colour: NoColourOption = False,
|
|
229
|
+
) -> None:
|
|
230
|
+
"""Save the snippets embedded in this project's manifest into the library.
|
|
231
|
+
|
|
232
|
+
Always explicit, never a side effect of composing, because pulling someone
|
|
233
|
+
else's conventions into a personal library should be a deliberate act.
|
|
234
|
+
Touches no project file and no manifest.
|
|
235
|
+
"""
|
|
236
|
+
output = output_for(context, quiet=quiet, no_colour=no_colour)
|
|
237
|
+
project_root = Path.cwd()
|
|
238
|
+
manifest = manifest_module.load_manifest(manifest_module.manifest_path(project_root))
|
|
239
|
+
if manifest is None:
|
|
240
|
+
raise AttentionError(
|
|
241
|
+
f"{project_root.as_posix()} has no {manifest_module.MANIFEST_FILENAME}, "
|
|
242
|
+
"so there is nothing to adopt"
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
library = resolve_library()
|
|
246
|
+
plan = library_ops.plan_adopt(manifest, library, only=tuple(ids or ()))
|
|
247
|
+
if plan.is_empty:
|
|
248
|
+
output.info("the manifest embeds no snippets")
|
|
249
|
+
return
|
|
250
|
+
|
|
251
|
+
resolutions = _resolve_collisions(output, plan, on_collision)
|
|
252
|
+
result = library_ops.apply_adopt(plan, library.directory, resolutions=resolutions)
|
|
253
|
+
_report_adoption(output, result)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _resolve_collisions(
|
|
257
|
+
output: OutputContext,
|
|
258
|
+
plan: library_ops.AdoptPlan,
|
|
259
|
+
on_collision: str | None,
|
|
260
|
+
) -> dict[str, library_ops.Resolution]:
|
|
261
|
+
"""Decide what to do about each collision, by flag or by asking."""
|
|
262
|
+
if not plan.collisions:
|
|
263
|
+
return {}
|
|
264
|
+
if on_collision is not None:
|
|
265
|
+
decision = _validated_resolution(on_collision)
|
|
266
|
+
return {collision.snippet_id: decision for collision in plan.collisions}
|
|
267
|
+
|
|
268
|
+
resolutions: dict[str, library_ops.Resolution] = {}
|
|
269
|
+
for collision in plan.collisions:
|
|
270
|
+
output.warn(
|
|
271
|
+
f"snippet '{collision.snippet_id}' already exists in the library with "
|
|
272
|
+
"different content"
|
|
273
|
+
)
|
|
274
|
+
_show_difference(output, collision)
|
|
275
|
+
answer = prompt_choice(
|
|
276
|
+
output,
|
|
277
|
+
f"snippet '{collision.snippet_id}'",
|
|
278
|
+
options=library_ops.RESOLUTIONS,
|
|
279
|
+
default=library_ops.KEEP,
|
|
280
|
+
flag="--on-collision",
|
|
281
|
+
)
|
|
282
|
+
resolutions[collision.snippet_id] = answer # type: ignore[assignment]
|
|
283
|
+
return resolutions
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _validated_resolution(candidate: str) -> library_ops.Resolution:
|
|
287
|
+
if candidate not in library_ops.RESOLUTIONS:
|
|
288
|
+
raise AttentionError(
|
|
289
|
+
f"--on-collision must be one of {', '.join(library_ops.RESOLUTIONS)}, "
|
|
290
|
+
f"found '{candidate}'"
|
|
291
|
+
)
|
|
292
|
+
return candidate
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _show_difference(output: OutputContext, collision: library_ops.Collision) -> None:
|
|
296
|
+
"""Show both versions, so the choice is made with the content in view."""
|
|
297
|
+
output.info(f" library copy of '{collision.snippet_id}':")
|
|
298
|
+
output.content_indented(collision.local)
|
|
299
|
+
output.info(f" embedded copy of '{collision.snippet_id}':")
|
|
300
|
+
output.content_indented(collision.embedded)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _report_adoption(output: OutputContext, result: library_ops.AdoptResult) -> None:
|
|
304
|
+
for outcome, phrasing in (
|
|
305
|
+
("written", "adopted"),
|
|
306
|
+
("overwritten", "overwrote"),
|
|
307
|
+
("already-present", "already present"),
|
|
308
|
+
("kept", "kept the library copy of"),
|
|
309
|
+
):
|
|
310
|
+
named = result.ids_with(outcome) # type: ignore[arg-type]
|
|
311
|
+
if named:
|
|
312
|
+
output.info(f" {phrasing}: {', '.join(named)}")
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""The target command group: add, list, remove.
|
|
2
|
+
|
|
3
|
+
A target is a tool-specific global location the canonical global AGENTS.md is
|
|
4
|
+
projected into. These commands own the ``registered_global_targets`` config
|
|
5
|
+
field; ``config set`` refuses it because its uniqueness and absoluteness rules
|
|
6
|
+
cannot be enforced from a single key and value.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Annotated
|
|
14
|
+
|
|
15
|
+
import typer
|
|
16
|
+
|
|
17
|
+
from mdcompose.core import config as config_module
|
|
18
|
+
from mdcompose.core import files, managed_block
|
|
19
|
+
from mdcompose.core import platform as platform_module
|
|
20
|
+
from mdcompose.core import targets as targets_module
|
|
21
|
+
from mdcompose.prompts import output_for
|
|
22
|
+
|
|
23
|
+
app = typer.Typer(
|
|
24
|
+
name="target",
|
|
25
|
+
help="Register tool-specific global locations for projection.",
|
|
26
|
+
rich_markup_mode=None,
|
|
27
|
+
no_args_is_help=True,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
QuietOption = Annotated[bool, typer.Option("--quiet", "-q", help="Suppress informational output.")]
|
|
31
|
+
JsonOption = Annotated[
|
|
32
|
+
bool, typer.Option("--json", help="Emit one JSON document on stdout and nothing else.")
|
|
33
|
+
]
|
|
34
|
+
NoColourOption = Annotated[bool, typer.Option("--no-color", help="Never colourize output.")]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _config() -> tuple[Path, config_module.GlobalConfig]:
|
|
38
|
+
path = config_module.config_path(platform_module.config_dir())
|
|
39
|
+
return path, config_module.load_config(path)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@app.command("add")
|
|
43
|
+
def add(
|
|
44
|
+
context: typer.Context,
|
|
45
|
+
label: Annotated[str, typer.Argument(help="A short name for the target.")],
|
|
46
|
+
path: Annotated[str, typer.Argument(help="Absolute path of the tool's global file.")],
|
|
47
|
+
quiet: QuietOption = False,
|
|
48
|
+
no_colour: NoColourOption = False,
|
|
49
|
+
) -> None:
|
|
50
|
+
"""Register a tool-specific global location. Writes no file at the path."""
|
|
51
|
+
output = output_for(context, quiet=quiet, no_colour=no_colour)
|
|
52
|
+
config_path, configuration = _config()
|
|
53
|
+
updated = targets_module.add_target(configuration, label, path)
|
|
54
|
+
entry = updated.registered_global_targets[-1]
|
|
55
|
+
info = platform_module.detect_platform()
|
|
56
|
+
if platform_module.is_on_windows_mount(Path(entry.path), info):
|
|
57
|
+
output.warn(
|
|
58
|
+
f"{entry.path} is on a Windows drive mounted under "
|
|
59
|
+
f"{platform_module.WINDOWS_MOUNT_PREFIX}, which crosses the WSL and Windows "
|
|
60
|
+
"filesystem boundary"
|
|
61
|
+
)
|
|
62
|
+
config_module.write_config(updated, config_path)
|
|
63
|
+
output.info(f"registered '{label}' at {entry.path}")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@app.command("remove")
|
|
67
|
+
def remove(
|
|
68
|
+
context: typer.Context,
|
|
69
|
+
label: Annotated[str, typer.Argument(help="The target to unregister.")],
|
|
70
|
+
quiet: QuietOption = False,
|
|
71
|
+
no_colour: NoColourOption = False,
|
|
72
|
+
) -> None:
|
|
73
|
+
"""Unregister a target. Its file is left exactly where it is."""
|
|
74
|
+
output = output_for(context, quiet=quiet, no_colour=no_colour)
|
|
75
|
+
config_path, configuration = _config()
|
|
76
|
+
updated = targets_module.remove_target(configuration, label) # raises on an unknown label
|
|
77
|
+
existing = next(t for t in configuration.registered_global_targets if t.label == label)
|
|
78
|
+
config_module.write_config(updated, config_path)
|
|
79
|
+
output.info(
|
|
80
|
+
f"unregistered '{label}'. Its file at {existing.path} was left in place; "
|
|
81
|
+
"delete it by hand if you no longer need it."
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@app.command("list")
|
|
86
|
+
def list_targets(
|
|
87
|
+
context: typer.Context,
|
|
88
|
+
quiet: QuietOption = False,
|
|
89
|
+
json_output: JsonOption = False,
|
|
90
|
+
no_colour: NoColourOption = False,
|
|
91
|
+
) -> None:
|
|
92
|
+
"""Show every registered target, its path, and whether its file is in sync."""
|
|
93
|
+
output = output_for(context, quiet=quiet, json_output=json_output, no_colour=no_colour)
|
|
94
|
+
_, configuration = _config()
|
|
95
|
+
canonical = _canonical_content(configuration)
|
|
96
|
+
entries = configuration.registered_global_targets
|
|
97
|
+
|
|
98
|
+
def sync_of(entry: config_module.TargetEntry) -> str:
|
|
99
|
+
if canonical is None:
|
|
100
|
+
return "unknown"
|
|
101
|
+
return targets_module.sync_status(entry, canonical)
|
|
102
|
+
|
|
103
|
+
if output.json_mode:
|
|
104
|
+
rows = [
|
|
105
|
+
{
|
|
106
|
+
"label": entry.label,
|
|
107
|
+
"path": targets_module.target_path(entry).as_posix(),
|
|
108
|
+
"present": files.path_exists(targets_module.target_path(entry)),
|
|
109
|
+
"sync": sync_of(entry),
|
|
110
|
+
}
|
|
111
|
+
for entry in entries
|
|
112
|
+
]
|
|
113
|
+
output.emit(json.dumps({"targets": rows}, indent=2, ensure_ascii=True))
|
|
114
|
+
return
|
|
115
|
+
if not entries:
|
|
116
|
+
output.info("no targets registered")
|
|
117
|
+
return
|
|
118
|
+
width = max(len(entry.label) for entry in entries)
|
|
119
|
+
for entry in entries:
|
|
120
|
+
path = targets_module.target_path(entry)
|
|
121
|
+
present = "present" if files.path_exists(path) else "absent"
|
|
122
|
+
label = entry.label.ljust(width)
|
|
123
|
+
output.info(f" {label} {sync_of(entry):12} {present} {path.as_posix()}")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _canonical_content(configuration: config_module.GlobalConfig) -> str | None:
|
|
127
|
+
"""The composed content of the canonical global AGENTS.md, or None when unresolvable."""
|
|
128
|
+
if configuration.global_agents_path is None:
|
|
129
|
+
return None
|
|
130
|
+
path = Path(configuration.global_agents_path).expanduser()
|
|
131
|
+
if not (files.path_exists(path) and path.is_file()):
|
|
132
|
+
return None
|
|
133
|
+
block = managed_block.scan(files.read_text(path)).find(targets_module.TARGET_BLOCK)
|
|
134
|
+
return None if block is None else block.content
|
|
File without changes
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Turning a snippet selection into the content of AGENTS.md and CLAUDE.md.
|
|
2
|
+
|
|
3
|
+
Mode is a property of CLAUDE.md alone. AGENTS.md is byte-identical whichever mode
|
|
4
|
+
a project uses, because AGENTS.md is always plain import-agnostic markdown: the
|
|
5
|
+
`@import` directive is a Claude Code mechanism and no other tool resolves it.
|
|
6
|
+
Keeping the mode recorded per pair matches how a user thinks about it, while only
|
|
7
|
+
one branch in this module ever reads it.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Iterable, Sequence
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Literal
|
|
16
|
+
|
|
17
|
+
from mdcompose.core import files, managed_block
|
|
18
|
+
from mdcompose.core.config import Mode
|
|
19
|
+
from mdcompose.core.snippets import Snippet, compose_order
|
|
20
|
+
|
|
21
|
+
IMPORT: Mode = "import"
|
|
22
|
+
COPY: Mode = "copy"
|
|
23
|
+
|
|
24
|
+
SEPARATOR = "\n"
|
|
25
|
+
|
|
26
|
+
Scope = Literal["agents", "claude"]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True, slots=True)
|
|
30
|
+
class Composition:
|
|
31
|
+
"""The content both managed blocks should hold for one selection and mode."""
|
|
32
|
+
|
|
33
|
+
agents_block: str
|
|
34
|
+
claude_block: str
|
|
35
|
+
snippet_ids: tuple[str, ...]
|
|
36
|
+
imports: str | None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def compose(
|
|
40
|
+
selection: Sequence[Snippet],
|
|
41
|
+
*,
|
|
42
|
+
mode: Mode,
|
|
43
|
+
import_target: str = "AGENTS.md",
|
|
44
|
+
) -> Composition:
|
|
45
|
+
"""Work out both blocks' content from one selection.
|
|
46
|
+
|
|
47
|
+
``import_target`` is the path CLAUDE.md points at in import mode, relative to
|
|
48
|
+
CLAUDE.md itself. It is normally the AGENTS.md beside it, but a subdirectory
|
|
49
|
+
CLAUDE.md may import one from elsewhere in the repository.
|
|
50
|
+
"""
|
|
51
|
+
ordered = compose_order(selection)
|
|
52
|
+
return Composition(
|
|
53
|
+
agents_block=_join_bodies(item for item in ordered if item.applies_to_agents()),
|
|
54
|
+
claude_block=(
|
|
55
|
+
import_directive(import_target)
|
|
56
|
+
if mode == IMPORT
|
|
57
|
+
else _join_bodies(item for item in ordered if item.applies_to_claude())
|
|
58
|
+
),
|
|
59
|
+
snippet_ids=tuple(item.id for item in ordered),
|
|
60
|
+
imports=import_target if mode == IMPORT else None,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def import_directive(target: str) -> str:
|
|
65
|
+
"""Return the import directive CLAUDE.md carries in import mode."""
|
|
66
|
+
return f"@{target}\n"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _join_bodies(selected: Iterable[Snippet]) -> str:
|
|
70
|
+
"""Concatenate snippet bodies with one blank line between them."""
|
|
71
|
+
bodies = [files.normalize(item.body).strip("\n") for item in selected]
|
|
72
|
+
present = [body for body in bodies if body]
|
|
73
|
+
if not present:
|
|
74
|
+
return ""
|
|
75
|
+
return SEPARATOR.join(f"{body}\n" for body in present)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def apply_to_file(
|
|
79
|
+
path: Path,
|
|
80
|
+
block_id: str,
|
|
81
|
+
content: str,
|
|
82
|
+
) -> bool:
|
|
83
|
+
"""Write one block into a file, returning whether anything changed.
|
|
84
|
+
|
|
85
|
+
Creates the file when it does not exist. Preserves every byte outside the
|
|
86
|
+
block, and preserves the file's existing line endings. Returns False when the
|
|
87
|
+
result would be identical to what is already there, so a repeat run is a
|
|
88
|
+
no-op rather than a rewrite.
|
|
89
|
+
"""
|
|
90
|
+
existing = files.read_text(path) if files.path_exists(path) and path.is_file() else ""
|
|
91
|
+
updated = managed_block.upsert(existing, block_id, content, path)
|
|
92
|
+
if existing and files.content_equal(existing, updated):
|
|
93
|
+
return False
|
|
94
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
95
|
+
files.write_text(path, updated, line_ending=files.line_ending_for(path))
|
|
96
|
+
return True
|