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.
@@ -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