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
mdcompose/__init__.py
ADDED
|
File without changes
|
mdcompose/cli.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""The mdcompose command line interface.
|
|
2
|
+
|
|
3
|
+
A thin adapter over the core layer. Commands parse arguments, call core, and
|
|
4
|
+
format the result. No detection, path resolution, encoding, or config logic
|
|
5
|
+
lives here, which is what lets the core behavior be documented as a contract
|
|
6
|
+
independent of Python and of Typer.
|
|
7
|
+
|
|
8
|
+
Rich markup is disabled deliberately. Its help output draws boxes with Unicode
|
|
9
|
+
characters, which would break the plain-ASCII guarantee that exists so a legacy
|
|
10
|
+
Windows console cannot raise an encoding error.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Annotated
|
|
18
|
+
|
|
19
|
+
import typer
|
|
20
|
+
|
|
21
|
+
from mdcompose.commands import (
|
|
22
|
+
config_cmd,
|
|
23
|
+
convert_cmd,
|
|
24
|
+
eject_cmd,
|
|
25
|
+
import_cmd,
|
|
26
|
+
init_cmd,
|
|
27
|
+
snippet_cmd,
|
|
28
|
+
target_cmd,
|
|
29
|
+
)
|
|
30
|
+
from mdcompose.core import report as report_module
|
|
31
|
+
from mdcompose.core.exit_codes import EXIT_ATTENTION, EXIT_INTERNAL, EXIT_OK, AttentionError
|
|
32
|
+
from mdcompose.core.output import GlobalOptions, OutputContext, assert_ascii
|
|
33
|
+
from mdcompose.prompts import output_for
|
|
34
|
+
from mdcompose.version import PACKAGE_NAME, resolve_version
|
|
35
|
+
|
|
36
|
+
app = typer.Typer(
|
|
37
|
+
name=PACKAGE_NAME,
|
|
38
|
+
help="Manage CLAUDE.md and AGENTS.md, and compose them from a personal snippet library.",
|
|
39
|
+
rich_markup_mode=None,
|
|
40
|
+
add_completion=False,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _version_callback(requested: bool) -> None:
|
|
45
|
+
if not requested:
|
|
46
|
+
return
|
|
47
|
+
print(assert_ascii(resolve_version()), file=sys.stdout)
|
|
48
|
+
raise typer.Exit(EXIT_OK)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
QuietOption = Annotated[
|
|
52
|
+
bool,
|
|
53
|
+
typer.Option("--quiet", "-q", help="Suppress informational output. Errors still print."),
|
|
54
|
+
]
|
|
55
|
+
JsonOption = Annotated[
|
|
56
|
+
bool,
|
|
57
|
+
typer.Option("--json", help="Emit one JSON document on stdout and nothing else."),
|
|
58
|
+
]
|
|
59
|
+
NoColourOption = Annotated[
|
|
60
|
+
bool,
|
|
61
|
+
typer.Option("--no-color", help="Never colourize output."),
|
|
62
|
+
]
|
|
63
|
+
VersionOption = Annotated[
|
|
64
|
+
bool,
|
|
65
|
+
typer.Option(
|
|
66
|
+
"--version",
|
|
67
|
+
callback=_version_callback,
|
|
68
|
+
is_eager=True,
|
|
69
|
+
help="Show the version and exit.",
|
|
70
|
+
),
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@app.callback()
|
|
75
|
+
def main(
|
|
76
|
+
context: typer.Context,
|
|
77
|
+
quiet: QuietOption = False,
|
|
78
|
+
json_output: JsonOption = False,
|
|
79
|
+
no_colour: NoColourOption = False,
|
|
80
|
+
_version: VersionOption = False,
|
|
81
|
+
) -> None:
|
|
82
|
+
"""Record the flags every command shares on the Typer context."""
|
|
83
|
+
context.obj = GlobalOptions(quiet=quiet, json_mode=json_output, no_colour=no_colour)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
app.add_typer(snippet_cmd.app, name="snippet")
|
|
87
|
+
app.add_typer(config_cmd.app, name="config")
|
|
88
|
+
app.add_typer(target_cmd.app, name="target")
|
|
89
|
+
init_cmd.register(app)
|
|
90
|
+
import_cmd.register(app)
|
|
91
|
+
convert_cmd.register(app)
|
|
92
|
+
eject_cmd.register(app)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@app.command()
|
|
96
|
+
def doctor(
|
|
97
|
+
context: typer.Context,
|
|
98
|
+
quiet: QuietOption = False,
|
|
99
|
+
json_output: JsonOption = False,
|
|
100
|
+
no_colour: NoColourOption = False,
|
|
101
|
+
) -> None:
|
|
102
|
+
"""Report the detected platform and every path mdcompose resolves.
|
|
103
|
+
|
|
104
|
+
Creates nothing, modifies nothing, and never prompts, so it is safe to run
|
|
105
|
+
anywhere. Exits 0 when healthy and 1 when something needs attention, which
|
|
106
|
+
makes it usable as a CI gate without parsing its output.
|
|
107
|
+
"""
|
|
108
|
+
output = output_for(
|
|
109
|
+
context, quiet=quiet, json_output=json_output, no_colour=no_colour
|
|
110
|
+
)
|
|
111
|
+
report = report_module.build_doctor_report(project_root=Path.cwd())
|
|
112
|
+
|
|
113
|
+
for warning in report.warnings:
|
|
114
|
+
output.warn(warning)
|
|
115
|
+
|
|
116
|
+
if output.json_mode:
|
|
117
|
+
output.emit(report.to_json())
|
|
118
|
+
else:
|
|
119
|
+
render_doctor(output, report)
|
|
120
|
+
|
|
121
|
+
if report.needs_attention:
|
|
122
|
+
raise typer.Exit(EXIT_ATTENTION)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def render_doctor(output: OutputContext, report: report_module.DoctorReport) -> None:
|
|
126
|
+
"""Format the report for a person reading a terminal.
|
|
127
|
+
|
|
128
|
+
Every status is readable from the text, so nothing depends on colour, and
|
|
129
|
+
the columns align because every generated character is ASCII.
|
|
130
|
+
"""
|
|
131
|
+
output.info(output.bold("platform"))
|
|
132
|
+
output.info(f" os {report.os_name}")
|
|
133
|
+
output.info(f" wsl {'yes' if report.is_wsl else 'no'}")
|
|
134
|
+
output.info("")
|
|
135
|
+
output.info(output.bold("paths"))
|
|
136
|
+
|
|
137
|
+
label_width = max(len(entry.label) for entry in report.entries)
|
|
138
|
+
status_width = max(len(entry.status) for entry in report.entries)
|
|
139
|
+
for entry in report.entries:
|
|
140
|
+
location = "-" if entry.resolved is None else entry.resolved.path.as_posix()
|
|
141
|
+
label = entry.label.ljust(label_width)
|
|
142
|
+
status = entry.status.ljust(status_width)
|
|
143
|
+
output.info(f" {label} {status} {location}")
|
|
144
|
+
|
|
145
|
+
if report.targets:
|
|
146
|
+
output.info("")
|
|
147
|
+
output.info(output.bold("registered targets"))
|
|
148
|
+
label_width = max(len(target.label) for target in report.targets)
|
|
149
|
+
for target in report.targets:
|
|
150
|
+
present = "present" if target.present else "absent"
|
|
151
|
+
output.info(
|
|
152
|
+
f" {target.label.ljust(label_width)} {target.sync:12} {present} {target.path}"
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
output.info("")
|
|
156
|
+
output.info(output.bold("managed files"))
|
|
157
|
+
if report.drift is None:
|
|
158
|
+
output.info(" not initialized, mdcompose has not composed this directory")
|
|
159
|
+
return
|
|
160
|
+
if not report.drift:
|
|
161
|
+
output.info(" the manifest records no managed files")
|
|
162
|
+
return
|
|
163
|
+
key_width = max(len(drift.key) for drift in report.drift)
|
|
164
|
+
for drift in report.drift:
|
|
165
|
+
detail = "" if drift.detail is None else f" ({drift.detail})"
|
|
166
|
+
output.info(f" {drift.key.ljust(key_width)} {drift.status}{detail}")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def run(argv: list[str] | None = None) -> int:
|
|
170
|
+
"""The single error boundary. Every exception becomes an exit code here.
|
|
171
|
+
|
|
172
|
+
``AttentionError`` means mdcompose worked and is reporting a real condition
|
|
173
|
+
the user must resolve, so it exits 1 with the message on stderr. A usage
|
|
174
|
+
error or an unexpected exception means mdcompose could not do its job, so
|
|
175
|
+
both exit 2.
|
|
176
|
+
|
|
177
|
+
Typer 0.27 vendors Click as a private module, so the usage error family is
|
|
178
|
+
caught through the public ``typer.TyperException``, which every vendored
|
|
179
|
+
Click exception inherits from.
|
|
180
|
+
|
|
181
|
+
A command signals a non-zero outcome by raising ``typer.Exit``. Outside
|
|
182
|
+
standalone mode Typer does not let that propagate; it converts it into the
|
|
183
|
+
value ``app`` returns. So the return value is the exit code, and ignoring it
|
|
184
|
+
would silently turn every non-zero outcome into success.
|
|
185
|
+
|
|
186
|
+
``argv`` defaults to the real command line. Tests pass it explicitly so the
|
|
187
|
+
boundary itself is exercised rather than bypassed.
|
|
188
|
+
|
|
189
|
+
A bare invocation shows help and succeeds. Running mdcompose with no
|
|
190
|
+
arguments is not mdcompose failing, so it does not earn exit code 2.
|
|
191
|
+
"""
|
|
192
|
+
arguments = sys.argv[1:] if argv is None else argv
|
|
193
|
+
if not arguments:
|
|
194
|
+
arguments = ["--help"]
|
|
195
|
+
try:
|
|
196
|
+
result = app(args=arguments, prog_name=PACKAGE_NAME, standalone_mode=False)
|
|
197
|
+
except AttentionError as error:
|
|
198
|
+
print(assert_ascii(f"error: {error.message}"), file=sys.stderr)
|
|
199
|
+
return EXIT_ATTENTION
|
|
200
|
+
except typer.Exit as request:
|
|
201
|
+
return request.exit_code
|
|
202
|
+
except typer.Abort:
|
|
203
|
+
print("error: aborted", file=sys.stderr)
|
|
204
|
+
return EXIT_ATTENTION
|
|
205
|
+
except typer.TyperException as error:
|
|
206
|
+
show = getattr(error, "show", None)
|
|
207
|
+
if show is None:
|
|
208
|
+
print(f"error: {error}", file=sys.stderr)
|
|
209
|
+
else:
|
|
210
|
+
show()
|
|
211
|
+
return EXIT_INTERNAL
|
|
212
|
+
except Exception as error: # the boundary must catch everything
|
|
213
|
+
print(
|
|
214
|
+
f"error: mdcompose failed unexpectedly ({type(error).__name__}: {error}). "
|
|
215
|
+
"This is a bug in mdcompose.",
|
|
216
|
+
file=sys.stderr,
|
|
217
|
+
)
|
|
218
|
+
return EXIT_INTERNAL
|
|
219
|
+
return EXIT_OK if result is None else int(result)
|
|
File without changes
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""The config command group: show, set, unset, edit.
|
|
2
|
+
|
|
3
|
+
The write half of a capability change 1 left half-built. Reading the config
|
|
4
|
+
already existed; these commands are the way to change it without hand-editing a
|
|
5
|
+
JSON file whose location the user would have to work out from `doctor`.
|
|
6
|
+
|
|
7
|
+
Every write goes through `config.write_config`, which validates nothing itself:
|
|
8
|
+
validation is `config.apply_set`'s job and runs before the file is touched, so a
|
|
9
|
+
rejected value cannot corrupt the config.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import shlex
|
|
16
|
+
import subprocess
|
|
17
|
+
import tempfile
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Annotated
|
|
20
|
+
|
|
21
|
+
import typer
|
|
22
|
+
|
|
23
|
+
from mdcompose.core import config as config_module
|
|
24
|
+
from mdcompose.core import files
|
|
25
|
+
from mdcompose.core import platform as platform_module
|
|
26
|
+
from mdcompose.core.exit_codes import AttentionError
|
|
27
|
+
from mdcompose.core.output import OutputContext
|
|
28
|
+
from mdcompose.prompts import output_for
|
|
29
|
+
|
|
30
|
+
app = typer.Typer(
|
|
31
|
+
name="config",
|
|
32
|
+
help="Show and change the global configuration.",
|
|
33
|
+
rich_markup_mode=None,
|
|
34
|
+
no_args_is_help=True,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
QuietOption = Annotated[bool, typer.Option("--quiet", "-q", help="Suppress informational output.")]
|
|
38
|
+
NoColourOption = Annotated[bool, typer.Option("--no-color", help="Never colourize output.")]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _resolve() -> tuple[Path, Path]:
|
|
42
|
+
directory = platform_module.config_dir()
|
|
43
|
+
return directory, config_module.config_path(directory)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@app.command("show")
|
|
47
|
+
def show(
|
|
48
|
+
context: typer.Context,
|
|
49
|
+
quiet: QuietOption = False,
|
|
50
|
+
no_colour: NoColourOption = False,
|
|
51
|
+
) -> None:
|
|
52
|
+
"""Print every recognized field, its effective value, and where the value came from.
|
|
53
|
+
|
|
54
|
+
Creates nothing. With no config file, every field prints its default or
|
|
55
|
+
not-configured state and the exit code is 0.
|
|
56
|
+
"""
|
|
57
|
+
output = output_for(context, quiet=quiet, no_colour=no_colour)
|
|
58
|
+
directory, path = _resolve()
|
|
59
|
+
configuration = config_module.load_config(path)
|
|
60
|
+
present = "present" if files.path_exists(path) else "absent"
|
|
61
|
+
output.info(f"config file {path.as_posix()} {present}")
|
|
62
|
+
|
|
63
|
+
views = config_module.describe(configuration, directory)
|
|
64
|
+
key_width = max(len(view.key) for view in views)
|
|
65
|
+
state_width = max(len(view.state) for view in views)
|
|
66
|
+
for view in views:
|
|
67
|
+
output.info(f" {view.key.ljust(key_width)} {view.state.ljust(state_width)} {view.value}")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@app.command("set")
|
|
71
|
+
def set_value(
|
|
72
|
+
context: typer.Context,
|
|
73
|
+
key: Annotated[str, typer.Argument(help="Field to set, dotted for a nested field.")],
|
|
74
|
+
value: Annotated[str, typer.Argument(help="New value.")],
|
|
75
|
+
quiet: QuietOption = False,
|
|
76
|
+
no_colour: NoColourOption = False,
|
|
77
|
+
) -> None:
|
|
78
|
+
"""Validate a value against the field's schema, then write it.
|
|
79
|
+
|
|
80
|
+
A dotted key addresses a nested field. An invalid value, an unknown key, and
|
|
81
|
+
schema_version are each refused before the file is touched.
|
|
82
|
+
"""
|
|
83
|
+
output = output_for(context, quiet=quiet, no_colour=no_colour)
|
|
84
|
+
_, path = _resolve()
|
|
85
|
+
configuration = config_module.load_config(path)
|
|
86
|
+
updated = config_module.apply_set(configuration, key, value)
|
|
87
|
+
if config_module.is_path_key(key):
|
|
88
|
+
_warn_windows_mount(output, value)
|
|
89
|
+
config_module.write_config(updated, path)
|
|
90
|
+
output.info(f"set {key}")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@app.command("unset")
|
|
94
|
+
def unset_value(
|
|
95
|
+
context: typer.Context,
|
|
96
|
+
key: Annotated[str, typer.Argument(help="Field to remove, dotted for a nested field.")],
|
|
97
|
+
quiet: QuietOption = False,
|
|
98
|
+
no_colour: NoColourOption = False,
|
|
99
|
+
) -> None:
|
|
100
|
+
"""Remove an explicitly set field so it falls back to its default or to unset.
|
|
101
|
+
|
|
102
|
+
A field that is already absent is a no-op: the file is left unchanged and the
|
|
103
|
+
exit code is 0. An unknown key is refused.
|
|
104
|
+
"""
|
|
105
|
+
output = output_for(context, quiet=quiet, no_colour=no_colour)
|
|
106
|
+
_, path = _resolve()
|
|
107
|
+
configuration = config_module.load_config(path)
|
|
108
|
+
if not config_module.is_set(configuration, key):
|
|
109
|
+
config_module.apply_unset(configuration, key) # raises on an unknown key
|
|
110
|
+
output.info(f"{key} was not set")
|
|
111
|
+
return
|
|
112
|
+
config_module.write_config(config_module.apply_unset(configuration, key), path)
|
|
113
|
+
output.info(f"unset {key}")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@app.command("edit")
|
|
117
|
+
def edit(
|
|
118
|
+
context: typer.Context,
|
|
119
|
+
quiet: QuietOption = False,
|
|
120
|
+
no_colour: NoColourOption = False,
|
|
121
|
+
) -> None:
|
|
122
|
+
"""Open the config in the configured editor, validating the result before installing it.
|
|
123
|
+
|
|
124
|
+
With no config file, the editor opens on the current defaults. An edit that
|
|
125
|
+
is not valid JSON or violates the schema is refused and the existing config
|
|
126
|
+
is left byte-identical.
|
|
127
|
+
"""
|
|
128
|
+
output = output_for(context, quiet=quiet, no_colour=no_colour)
|
|
129
|
+
_, path = _resolve()
|
|
130
|
+
original = files.read_text(path) if files.path_exists(path) else config_module.render(
|
|
131
|
+
config_module.GlobalConfig()
|
|
132
|
+
)
|
|
133
|
+
edited = _edit_in_editor(original, path.name)
|
|
134
|
+
if edited is None or files.content_equal(edited, original):
|
|
135
|
+
output.info("config unchanged")
|
|
136
|
+
return
|
|
137
|
+
validated = config_module.load_config_text(edited, path)
|
|
138
|
+
config_module.write_config(validated, path)
|
|
139
|
+
output.info(f"wrote {path.as_posix()}")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _edit_in_editor(seed: str, name: str) -> str | None:
|
|
143
|
+
"""Open a copy of ``seed`` in the editor and return the result, or None if unchanged."""
|
|
144
|
+
editor = os.environ.get("VISUAL") or os.environ.get("EDITOR")
|
|
145
|
+
if not editor:
|
|
146
|
+
raise AttentionError(
|
|
147
|
+
"no editor configured. Set VISUAL or EDITOR, or use 'config set' to change "
|
|
148
|
+
"one field at a time."
|
|
149
|
+
)
|
|
150
|
+
with tempfile.TemporaryDirectory() as scratch:
|
|
151
|
+
draft = Path(scratch) / name
|
|
152
|
+
files.write_text(draft, seed)
|
|
153
|
+
completed = subprocess.run([*shlex.split(editor), str(draft)], check=False)
|
|
154
|
+
if completed.returncode != 0:
|
|
155
|
+
raise AttentionError(f"editor exited with status {completed.returncode}")
|
|
156
|
+
result = files.read_text(draft)
|
|
157
|
+
return None if result == seed else result
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _warn_windows_mount(output: OutputContext, raw_value: str) -> None:
|
|
161
|
+
"""Warn when a path field is set to a location across the WSL and Windows boundary.
|
|
162
|
+
|
|
163
|
+
Checked against the value the user typed, not the stored expansion: on
|
|
164
|
+
Windows the expansion of a ``/mnt/`` path is not itself under ``/mnt/``.
|
|
165
|
+
"""
|
|
166
|
+
info = platform_module.detect_platform()
|
|
167
|
+
if platform_module.is_on_windows_mount(Path(raw_value), info):
|
|
168
|
+
output.warn(
|
|
169
|
+
f"{raw_value} is on a Windows drive mounted under "
|
|
170
|
+
f"{platform_module.WINDOWS_MOUNT_PREFIX}, which crosses the WSL and Windows "
|
|
171
|
+
"filesystem boundary"
|
|
172
|
+
)
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
"""The convert command: move unmanaged content between AGENTS.md and CLAUDE.md.
|
|
2
|
+
|
|
3
|
+
`convert` moves the sections a user wrote into the wrong file: Claude-only prose
|
|
4
|
+
that belongs in the shared AGENTS.md, or the reverse. It reads and writes only
|
|
5
|
+
content outside managed blocks, so it never overlaps with `init`. The content is
|
|
6
|
+
removed from the source once the target write succeeds, which is why every run
|
|
7
|
+
shows a diff and asks before writing.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Annotated
|
|
15
|
+
|
|
16
|
+
import typer
|
|
17
|
+
|
|
18
|
+
from mdcompose.commands.import_cmd import _picker as _section_picker
|
|
19
|
+
from mdcompose.core import composition, convert_ops, files, import_ops, init_ops, managed_block
|
|
20
|
+
from mdcompose.core import config as config_module
|
|
21
|
+
from mdcompose.core import manifest as manifest_module
|
|
22
|
+
from mdcompose.core import platform as platform_module
|
|
23
|
+
from mdcompose.core.config import Mode
|
|
24
|
+
from mdcompose.core.exit_codes import AttentionError
|
|
25
|
+
from mdcompose.core.output import OutputContext
|
|
26
|
+
from mdcompose.core.sections import Section
|
|
27
|
+
from mdcompose.prompts import is_interactive, output_for, prompt_choice
|
|
28
|
+
from mdcompose.version import generated_by
|
|
29
|
+
|
|
30
|
+
SourceArgument = Annotated[
|
|
31
|
+
str, typer.Argument(help="File to move content out of: AGENTS.md or CLAUDE.md.")
|
|
32
|
+
]
|
|
33
|
+
TargetArgument = Annotated[
|
|
34
|
+
str, typer.Argument(help="File to move content into: the other of the pair.")
|
|
35
|
+
]
|
|
36
|
+
SectionOption = Annotated[
|
|
37
|
+
list[str] | None,
|
|
38
|
+
typer.Option(
|
|
39
|
+
"--section", help="Move the section with this heading. Repeatable. Bypasses the picker."
|
|
40
|
+
),
|
|
41
|
+
]
|
|
42
|
+
ModeOption = Annotated[
|
|
43
|
+
str | None,
|
|
44
|
+
typer.Option("--mode", help="How a new CLAUDE.md block relates to AGENTS.md: import or copy."),
|
|
45
|
+
]
|
|
46
|
+
YesOption = Annotated[
|
|
47
|
+
bool, typer.Option("--yes", "-y", help="Confirm the conversion without a prompt.")
|
|
48
|
+
]
|
|
49
|
+
QuietOption = Annotated[bool, typer.Option("--quiet", "-q", help="Suppress informational output.")]
|
|
50
|
+
NoColourOption = Annotated[bool, typer.Option("--no-color", help="Never colourize output.")]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def register(app: typer.Typer) -> None:
|
|
54
|
+
"""Attach the command to an app under its real name."""
|
|
55
|
+
app.command("convert")(convert_files)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def convert_files(
|
|
59
|
+
context: typer.Context,
|
|
60
|
+
source: SourceArgument,
|
|
61
|
+
target: TargetArgument,
|
|
62
|
+
section_names: SectionOption = None,
|
|
63
|
+
mode: ModeOption = None,
|
|
64
|
+
yes: YesOption = False,
|
|
65
|
+
quiet: QuietOption = False,
|
|
66
|
+
no_colour: NoColourOption = False,
|
|
67
|
+
) -> None:
|
|
68
|
+
"""Move chosen sections of one project file into the other, outside its block.
|
|
69
|
+
|
|
70
|
+
The moved content leaves the source and lands in the target's user-owned
|
|
71
|
+
region, so a later `init` neither overwrites nor removes it. --mode applies
|
|
72
|
+
only when the target is a CLAUDE.md with no managed block, since creating one
|
|
73
|
+
means deciding what it holds.
|
|
74
|
+
"""
|
|
75
|
+
output = output_for(context, quiet=quiet, no_colour=no_colour)
|
|
76
|
+
info = platform_module.detect_platform()
|
|
77
|
+
source_path, target_path = _resolve_pair(source, target, info)
|
|
78
|
+
convert_ops.require_different(source_path, target_path)
|
|
79
|
+
|
|
80
|
+
project_root = Path.cwd()
|
|
81
|
+
requested_mode = _validated_mode(mode)
|
|
82
|
+
_reject_mode_where_it_cannot_apply(target_path, requested_mode)
|
|
83
|
+
|
|
84
|
+
source_text = files.read_text(source_path)
|
|
85
|
+
available = convert_ops.unmanaged_sections(source_text, source_path)
|
|
86
|
+
if _nothing_convertible(available):
|
|
87
|
+
output.info("nothing to convert, the source has no content outside a managed block")
|
|
88
|
+
return
|
|
89
|
+
|
|
90
|
+
names = tuple(section_names or ())
|
|
91
|
+
selected = import_ops.resolve_selection(
|
|
92
|
+
available, requested_headings=names or None, selector=_section_picker(output)
|
|
93
|
+
)
|
|
94
|
+
if not selected:
|
|
95
|
+
output.info("nothing selected, nothing converted")
|
|
96
|
+
return
|
|
97
|
+
|
|
98
|
+
new_block_mode = _new_block_mode(output, target_path, project_root, requested_mode)
|
|
99
|
+
target_text = files.read_text(target_path) if target_path.is_file() else ""
|
|
100
|
+
plan = convert_ops.plan_conversion(
|
|
101
|
+
source_path,
|
|
102
|
+
target_path,
|
|
103
|
+
moved=selected,
|
|
104
|
+
source_text=source_text,
|
|
105
|
+
target_text=target_text,
|
|
106
|
+
new_block_mode=new_block_mode,
|
|
107
|
+
)
|
|
108
|
+
diff = convert_ops.render_diff(plan)
|
|
109
|
+
if diff:
|
|
110
|
+
output.content(diff)
|
|
111
|
+
if not _confirmed(output, yes):
|
|
112
|
+
output.info("declined, nothing converted")
|
|
113
|
+
return
|
|
114
|
+
|
|
115
|
+
convert_ops.apply_conversion(plan)
|
|
116
|
+
if new_block_mode is not None:
|
|
117
|
+
_record_mode(project_root, target_path, new_block_mode)
|
|
118
|
+
plural = "" if len(selected) == 1 else "s"
|
|
119
|
+
output.info(
|
|
120
|
+
f"moved {len(selected)} section{plural} from {source_path.name} to {target_path.name}"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _validated_mode(raw: str | None) -> Mode | None:
|
|
125
|
+
if raw is None:
|
|
126
|
+
return None
|
|
127
|
+
if raw not in {composition.IMPORT, composition.COPY}:
|
|
128
|
+
raise AttentionError(
|
|
129
|
+
f"--mode must be '{composition.IMPORT}' or '{composition.COPY}', found '{raw}'"
|
|
130
|
+
)
|
|
131
|
+
return raw
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _reject_mode_where_it_cannot_apply(target_path: Path, requested: Mode | None) -> None:
|
|
135
|
+
"""--mode is rejected, not ignored, everywhere it cannot apply.
|
|
136
|
+
|
|
137
|
+
It applies only when creating a claude-managed block: a CLAUDE.md target with
|
|
138
|
+
no block yet. On AGENTS.md, or a CLAUDE.md that already has a block, supplying
|
|
139
|
+
it means the user expected something that will not happen.
|
|
140
|
+
"""
|
|
141
|
+
if requested is None:
|
|
142
|
+
return
|
|
143
|
+
if target_path.name != platform_module.CLAUDE_MD_NAME:
|
|
144
|
+
raise AttentionError("--mode does not apply to AGENTS.md, which is always plain markdown")
|
|
145
|
+
if _has_claude_block(target_path):
|
|
146
|
+
raise AttentionError(
|
|
147
|
+
"CLAUDE.md already has a managed block; a mode change belongs to init, not convert"
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _has_claude_block(target_path: Path) -> bool:
|
|
152
|
+
return (
|
|
153
|
+
target_path.is_file()
|
|
154
|
+
and managed_block.read_blocks(target_path).find(managed_block.CLAUDE_MANAGED_BLOCK)
|
|
155
|
+
is not None
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _new_block_mode(
|
|
160
|
+
output: OutputContext,
|
|
161
|
+
target_path: Path,
|
|
162
|
+
project_root: Path,
|
|
163
|
+
requested: Mode | None,
|
|
164
|
+
) -> Mode | None:
|
|
165
|
+
"""The mode for a claude-managed block this run would create, or None.
|
|
166
|
+
|
|
167
|
+
Only a CLAUDE.md target with no block yet needs one. Precedence follows
|
|
168
|
+
`init`: an explicit flag, then the recorded project mode, then the configured
|
|
169
|
+
default, then a question, then a refusal naming the flag.
|
|
170
|
+
"""
|
|
171
|
+
if target_path.name != platform_module.CLAUDE_MD_NAME or _has_claude_block(target_path):
|
|
172
|
+
return None
|
|
173
|
+
|
|
174
|
+
configuration = config_module.load_config(
|
|
175
|
+
config_module.config_path(platform_module.config_dir())
|
|
176
|
+
)
|
|
177
|
+
manifest = manifest_module.load_manifest(manifest_module.manifest_path(project_root))
|
|
178
|
+
recorded = None
|
|
179
|
+
entry = None if manifest is None else manifest.files.get(manifest_module.CLAUDE_MD_KEY)
|
|
180
|
+
if entry is not None:
|
|
181
|
+
recorded = entry.mode
|
|
182
|
+
return init_ops.resolve_mode(
|
|
183
|
+
requested=requested,
|
|
184
|
+
recorded=recorded,
|
|
185
|
+
default=configuration.default_mode,
|
|
186
|
+
ask=_mode_asker(output),
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _mode_asker(output: OutputContext) -> Callable[[], Mode] | None:
|
|
191
|
+
if output.json_mode or not is_interactive():
|
|
192
|
+
return None
|
|
193
|
+
|
|
194
|
+
def ask() -> Mode:
|
|
195
|
+
answer = prompt_choice(
|
|
196
|
+
output,
|
|
197
|
+
"the target CLAUDE.md needs a managed block; how should it relate to AGENTS.md",
|
|
198
|
+
options=(composition.IMPORT, composition.COPY),
|
|
199
|
+
default=composition.IMPORT,
|
|
200
|
+
flag="--mode",
|
|
201
|
+
)
|
|
202
|
+
return answer # type: ignore[return-value]
|
|
203
|
+
|
|
204
|
+
return ask
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _record_mode(project_root: Path, target_path: Path, mode: Mode) -> None:
|
|
208
|
+
"""Record the created block's mode, updating the manifest or writing a new one."""
|
|
209
|
+
block = managed_block.read_blocks(target_path).find(managed_block.CLAUDE_MANAGED_BLOCK)
|
|
210
|
+
block_hash = files.hash_content("" if block is None else block.content)
|
|
211
|
+
imports = "AGENTS.md" if mode == composition.IMPORT else None
|
|
212
|
+
entry = manifest_module.FileEntry(
|
|
213
|
+
path=platform_module.CLAUDE_MD_NAME,
|
|
214
|
+
mode=mode,
|
|
215
|
+
managed_block_hash=block_hash,
|
|
216
|
+
imports=imports,
|
|
217
|
+
)
|
|
218
|
+
path = manifest_module.manifest_path(project_root)
|
|
219
|
+
manifest = manifest_module.load_manifest(path)
|
|
220
|
+
if manifest is None:
|
|
221
|
+
built = manifest_module.build(
|
|
222
|
+
generated_by=generated_by(),
|
|
223
|
+
generated_at=_now(),
|
|
224
|
+
detected_stack=(),
|
|
225
|
+
snippets=(),
|
|
226
|
+
files_recorded={manifest_module.CLAUDE_MD_KEY: entry},
|
|
227
|
+
source=path,
|
|
228
|
+
)
|
|
229
|
+
else:
|
|
230
|
+
files_recorded = dict(manifest.files)
|
|
231
|
+
files_recorded[manifest_module.CLAUDE_MD_KEY] = entry
|
|
232
|
+
built = manifest_module.build(
|
|
233
|
+
generated_by=manifest.generated_by or "",
|
|
234
|
+
generated_at=manifest.generated_at or _now(),
|
|
235
|
+
detected_stack=manifest.detected_stack,
|
|
236
|
+
snippets=manifest.snippets_in_order(),
|
|
237
|
+
files_recorded=files_recorded,
|
|
238
|
+
source=path,
|
|
239
|
+
)
|
|
240
|
+
manifest_module.write_manifest(built, path)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _now() -> str:
|
|
244
|
+
from datetime import UTC, datetime
|
|
245
|
+
|
|
246
|
+
return datetime.now(UTC).replace(microsecond=0).isoformat()
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _resolve_pair(
|
|
250
|
+
source: str, target: str, info: platform_module.PlatformInfo
|
|
251
|
+
) -> tuple[Path, Path]:
|
|
252
|
+
"""Resolve both arguments to the project's own AGENTS.md or CLAUDE.md."""
|
|
253
|
+
project = Path.cwd()
|
|
254
|
+
supported = {
|
|
255
|
+
files.normalized_path(project / platform_module.AGENTS_MD_NAME): project
|
|
256
|
+
/ platform_module.AGENTS_MD_NAME,
|
|
257
|
+
files.normalized_path(project / platform_module.CLAUDE_MD_NAME): project
|
|
258
|
+
/ platform_module.CLAUDE_MD_NAME,
|
|
259
|
+
}
|
|
260
|
+
source_path = _one_of(source, info, supported)
|
|
261
|
+
target_path = _one_of(target, info, supported)
|
|
262
|
+
if not source_path.is_file():
|
|
263
|
+
raise AttentionError(f"{source_path}: no such file to convert from")
|
|
264
|
+
return source_path, target_path
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _one_of(
|
|
268
|
+
raw: str, info: platform_module.PlatformInfo, supported: dict[str, Path]
|
|
269
|
+
) -> Path:
|
|
270
|
+
resolved = platform_module.resolve_path(raw, info)
|
|
271
|
+
match = supported.get(files.normalized_path(resolved.path))
|
|
272
|
+
if match is None:
|
|
273
|
+
raise AttentionError(
|
|
274
|
+
f"{resolved.path}: convert operates on this project's AGENTS.md and CLAUDE.md, "
|
|
275
|
+
"nothing else"
|
|
276
|
+
)
|
|
277
|
+
return match
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _nothing_convertible(available: tuple[Section, ...]) -> bool:
|
|
281
|
+
if not available:
|
|
282
|
+
return True
|
|
283
|
+
return all(section.synthetic and not section.content.strip() for section in available)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _confirmed(output: OutputContext, yes: bool) -> bool:
|
|
287
|
+
"""Confirm the conversion after the diff, refusing to guess with nobody to ask.
|
|
288
|
+
|
|
289
|
+
The diff is always printed first, by the caller, so a scripted run with --yes
|
|
290
|
+
still records what changed.
|
|
291
|
+
"""
|
|
292
|
+
if yes:
|
|
293
|
+
return True
|
|
294
|
+
if output.json_mode or not is_interactive():
|
|
295
|
+
raise AttentionError(
|
|
296
|
+
"convert moves content between both files. Re-run with --yes to apply the "
|
|
297
|
+
"diff shown above."
|
|
298
|
+
)
|
|
299
|
+
return typer.confirm("apply this conversion?", default=False, err=True)
|