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 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)