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,668 @@
|
|
|
1
|
+
"""The init command: compose a directory's AGENTS.md and CLAUDE.md.
|
|
2
|
+
|
|
3
|
+
The first command that modifies files the user already had. Everything it could
|
|
4
|
+
ask about is decided before anything is written, so a declined prompt leaves the
|
|
5
|
+
directory untouched rather than half-composed.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Annotated
|
|
13
|
+
|
|
14
|
+
import typer
|
|
15
|
+
|
|
16
|
+
from mdcompose.core import composition, files, init_ops, managed_block
|
|
17
|
+
from mdcompose.core import config as config_module
|
|
18
|
+
from mdcompose.core import manifest as manifest_module
|
|
19
|
+
from mdcompose.core import platform as platform_module
|
|
20
|
+
from mdcompose.core import snippets as snippets_module
|
|
21
|
+
from mdcompose.core import targets as targets_module
|
|
22
|
+
from mdcompose.core.config import Mode
|
|
23
|
+
from mdcompose.core.exit_codes import EXIT_ATTENTION, AttentionError
|
|
24
|
+
from mdcompose.core.output import OutputContext
|
|
25
|
+
from mdcompose.core.snippets import Snippet
|
|
26
|
+
from mdcompose.prompts import confirm, is_interactive, output_for, prompt_choice
|
|
27
|
+
from mdcompose.version import generated_by
|
|
28
|
+
|
|
29
|
+
DirectoryArgument = Annotated[
|
|
30
|
+
Path | None,
|
|
31
|
+
typer.Argument(help="Directory to compose. Defaults to the current directory."),
|
|
32
|
+
]
|
|
33
|
+
ModeOption = Annotated[
|
|
34
|
+
str | None,
|
|
35
|
+
typer.Option("--mode", help="How CLAUDE.md relates to AGENTS.md: import or copy."),
|
|
36
|
+
]
|
|
37
|
+
SnippetsOption = Annotated[
|
|
38
|
+
str | None,
|
|
39
|
+
typer.Option("--snippets", help="Comma-separated snippet ids. Skips the picker."),
|
|
40
|
+
]
|
|
41
|
+
AcceptOption = Annotated[
|
|
42
|
+
bool,
|
|
43
|
+
typer.Option("--yes", "-y", help="Accept the stack-detected snippets and every prompt."),
|
|
44
|
+
]
|
|
45
|
+
OnDriftOption = Annotated[
|
|
46
|
+
str | None,
|
|
47
|
+
typer.Option("--on-drift", help="Resolve drift without asking: keep, overwrite, or abort."),
|
|
48
|
+
]
|
|
49
|
+
AgentsFromOption = Annotated[
|
|
50
|
+
str | None,
|
|
51
|
+
typer.Option(
|
|
52
|
+
"--agents-from",
|
|
53
|
+
help="Import an AGENTS.md from elsewhere instead of composing one here.",
|
|
54
|
+
),
|
|
55
|
+
]
|
|
56
|
+
GlobalOption = Annotated[
|
|
57
|
+
bool, typer.Option("--global", help="Compose the global file pair instead.")
|
|
58
|
+
]
|
|
59
|
+
ReapplyOption = Annotated[
|
|
60
|
+
bool,
|
|
61
|
+
typer.Option(
|
|
62
|
+
"--reapply",
|
|
63
|
+
help="Re-compose from the recorded selection and mode without opening the picker.",
|
|
64
|
+
),
|
|
65
|
+
]
|
|
66
|
+
GlobalAgentsPathOption = Annotated[
|
|
67
|
+
str | None,
|
|
68
|
+
typer.Option(
|
|
69
|
+
"--global-agents-path",
|
|
70
|
+
help="Where the canonical global AGENTS.md lives. Asked once, then remembered.",
|
|
71
|
+
),
|
|
72
|
+
]
|
|
73
|
+
QuietOption = Annotated[bool, typer.Option("--quiet", "-q", help="Suppress informational output.")]
|
|
74
|
+
NoColourOption = Annotated[bool, typer.Option("--no-color", help="Never colourize output.")]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def register(app: typer.Typer) -> None:
|
|
78
|
+
"""Attach the command to an app, avoiding an import cycle with the CLI module."""
|
|
79
|
+
app.command("init")(init)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def init(
|
|
83
|
+
context: typer.Context,
|
|
84
|
+
directory: DirectoryArgument = None,
|
|
85
|
+
mode: ModeOption = None,
|
|
86
|
+
snippet_ids: SnippetsOption = None,
|
|
87
|
+
accept: AcceptOption = False,
|
|
88
|
+
on_drift: OnDriftOption = None,
|
|
89
|
+
agents_from: AgentsFromOption = None,
|
|
90
|
+
global_scope: GlobalOption = False,
|
|
91
|
+
global_agents_path: GlobalAgentsPathOption = None,
|
|
92
|
+
reapply: ReapplyOption = False,
|
|
93
|
+
quiet: QuietOption = False,
|
|
94
|
+
no_colour: NoColourOption = False,
|
|
95
|
+
) -> None:
|
|
96
|
+
"""Compose AGENTS.md and CLAUDE.md from the snippet library.
|
|
97
|
+
|
|
98
|
+
Re-running reopens the picker with the recorded selections already checked,
|
|
99
|
+
rather than refusing. --reapply skips the picker and re-composes from what
|
|
100
|
+
the manifest recorded. Every prompt has a flag, so the whole command runs
|
|
101
|
+
unattended.
|
|
102
|
+
"""
|
|
103
|
+
output = output_for(context, quiet=quiet, no_colour=no_colour)
|
|
104
|
+
config_directory = platform_module.config_dir()
|
|
105
|
+
configuration = config_module.load_config(config_module.config_path(config_directory))
|
|
106
|
+
library = snippets_module.view(config_module.library_dir(configuration, config_directory))
|
|
107
|
+
|
|
108
|
+
if global_scope:
|
|
109
|
+
_init_global(
|
|
110
|
+
output,
|
|
111
|
+
configuration,
|
|
112
|
+
config_directory,
|
|
113
|
+
library,
|
|
114
|
+
mode,
|
|
115
|
+
snippet_ids,
|
|
116
|
+
accept,
|
|
117
|
+
global_agents_path,
|
|
118
|
+
)
|
|
119
|
+
return
|
|
120
|
+
|
|
121
|
+
root = _resolve_target(directory)
|
|
122
|
+
_warn_onedrive(output, (("config directory", config_directory), ("target directory", root)))
|
|
123
|
+
manifest = manifest_module.load_manifest(manifest_module.manifest_path(root))
|
|
124
|
+
if library.is_empty and manifest is None:
|
|
125
|
+
raise AttentionError(
|
|
126
|
+
f"the snippet library at {library.directory.as_posix()} is empty, so there "
|
|
127
|
+
"is nothing to compose. Add a snippet first."
|
|
128
|
+
)
|
|
129
|
+
if reapply and manifest is None:
|
|
130
|
+
raise AttentionError(
|
|
131
|
+
f"{root}: no mdcompose.lock here, so there is no recorded selection to re-apply"
|
|
132
|
+
)
|
|
133
|
+
_warn_bare_directory(output, root, manifest)
|
|
134
|
+
|
|
135
|
+
interactive_ok = not reapply
|
|
136
|
+
prepared = init_ops.plan(
|
|
137
|
+
root=root,
|
|
138
|
+
library=library,
|
|
139
|
+
manifest=manifest,
|
|
140
|
+
requested_ids=_parse_ids(snippet_ids),
|
|
141
|
+
accept_detected=accept,
|
|
142
|
+
requested_mode=_validated_mode(mode),
|
|
143
|
+
default_mode=_default_mode(configuration, accept=accept),
|
|
144
|
+
selector=_selector(output, accept) if interactive_ok else None,
|
|
145
|
+
ask_mode=_mode_asker(output, accept) if interactive_ok else None,
|
|
146
|
+
import_from=agents_from,
|
|
147
|
+
)
|
|
148
|
+
_refuse_malformed(prepared)
|
|
149
|
+
_warn_empty_selection(output, prepared)
|
|
150
|
+
_warn_redundant_import(output, prepared, root)
|
|
151
|
+
if not _confirm_foreign(output, prepared, accept=accept):
|
|
152
|
+
output.info("nothing written")
|
|
153
|
+
return
|
|
154
|
+
|
|
155
|
+
choices = _drift_choices(output, prepared, on_drift, accept=accept)
|
|
156
|
+
if init_ops.ABORT in choices.values():
|
|
157
|
+
output.info("aborted, nothing written")
|
|
158
|
+
raise typer.Exit(EXIT_ATTENTION)
|
|
159
|
+
|
|
160
|
+
result = init_ops.apply(
|
|
161
|
+
prepared,
|
|
162
|
+
generated_by=generated_by(),
|
|
163
|
+
drift_choices=choices,
|
|
164
|
+
)
|
|
165
|
+
_report(output, prepared, result)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _resolve_target(directory: Path | None) -> Path:
|
|
169
|
+
root = Path.cwd() if directory is None else Path(directory).expanduser()
|
|
170
|
+
if root.is_dir():
|
|
171
|
+
return root
|
|
172
|
+
if directory is not None and _looks_like_a_snippet_id(str(directory)) and not root.exists():
|
|
173
|
+
raise AttentionError(
|
|
174
|
+
f"{root}: not a directory. If '{directory}' is a snippet id it belongs to "
|
|
175
|
+
"--snippets, and a comma-separated list must be quoted so the shell keeps it "
|
|
176
|
+
"as one argument (PowerShell splits an unquoted list on the comma)."
|
|
177
|
+
)
|
|
178
|
+
raise AttentionError(f"{root}: not a directory")
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _looks_like_a_snippet_id(candidate: str) -> bool:
|
|
182
|
+
return "/" not in candidate and "\\" not in candidate and candidate not in {".", ".."}
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _parse_ids(raw: str | None) -> tuple[str, ...] | None:
|
|
186
|
+
if raw is None:
|
|
187
|
+
return None
|
|
188
|
+
return tuple(item.strip() for item in raw.split(",") if item.strip())
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _validated_mode(raw: str | None) -> Mode | None:
|
|
192
|
+
if raw is None:
|
|
193
|
+
return None
|
|
194
|
+
if raw not in {composition.IMPORT, composition.COPY}:
|
|
195
|
+
raise AttentionError(
|
|
196
|
+
f"--mode must be '{composition.IMPORT}' or '{composition.COPY}', found '{raw}'"
|
|
197
|
+
)
|
|
198
|
+
return raw
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _default_mode(
|
|
202
|
+
configuration: config_module.GlobalConfig, *, accept: bool
|
|
203
|
+
) -> Mode | None:
|
|
204
|
+
"""The mode to fall back on when nothing else answers.
|
|
205
|
+
|
|
206
|
+
``--yes`` means accept every prompt, and the mode question is a prompt, so it
|
|
207
|
+
has to answer that one too. Without this a fully scripted first run would
|
|
208
|
+
refuse for want of an answer the user had already given.
|
|
209
|
+
"""
|
|
210
|
+
if configuration.default_mode is not None:
|
|
211
|
+
return configuration.default_mode
|
|
212
|
+
return composition.IMPORT if accept else None
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _selector(output: OutputContext, accept: bool) -> init_ops.Selector | None:
|
|
216
|
+
"""Return an interactive picker, or None when there is nobody to ask."""
|
|
217
|
+
if accept or output.json_mode or not is_interactive():
|
|
218
|
+
return None
|
|
219
|
+
|
|
220
|
+
def pick(
|
|
221
|
+
available: tuple[Snippet, ...], preselected: tuple[str, ...]
|
|
222
|
+
) -> tuple[str, ...]:
|
|
223
|
+
return _run_picker(available, preselected)
|
|
224
|
+
|
|
225
|
+
return pick
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _run_picker(
|
|
229
|
+
available: tuple[Snippet, ...],
|
|
230
|
+
preselected: tuple[str, ...],
|
|
231
|
+
) -> tuple[str, ...]:
|
|
232
|
+
"""Ask which snippets to compose, grouped by category.
|
|
233
|
+
|
|
234
|
+
Imported here rather than at module level so the picker library is only
|
|
235
|
+
loaded when a picker is actually opened, which keeps every non-interactive
|
|
236
|
+
run free of it.
|
|
237
|
+
"""
|
|
238
|
+
import questionary
|
|
239
|
+
|
|
240
|
+
grouped: dict[str, list[Snippet]] = {}
|
|
241
|
+
for snippet in available:
|
|
242
|
+
grouped.setdefault(snippet.category or "uncategorized", []).append(snippet)
|
|
243
|
+
|
|
244
|
+
choices: list[questionary.Choice] = []
|
|
245
|
+
for category in sorted(grouped):
|
|
246
|
+
choices.append(questionary.Separator(f"-- {category} --"))
|
|
247
|
+
for snippet in grouped[category]:
|
|
248
|
+
label = snippet.display_name
|
|
249
|
+
if snippet.description is not None:
|
|
250
|
+
label = f"{label}: {snippet.description}"
|
|
251
|
+
choices.append(
|
|
252
|
+
questionary.Choice(
|
|
253
|
+
title=label, value=snippet.id, checked=snippet.id in preselected
|
|
254
|
+
)
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
answer = questionary.checkbox("Snippets to compose", choices=choices).ask()
|
|
258
|
+
if answer is None:
|
|
259
|
+
raise AttentionError("no selection made")
|
|
260
|
+
return tuple(answer)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _mode_asker(output: OutputContext, accept: bool) -> Callable[[], Mode] | None:
|
|
264
|
+
"""Return a callable that asks for the mode, or None when nobody can be asked."""
|
|
265
|
+
if accept or output.json_mode or not is_interactive():
|
|
266
|
+
return None
|
|
267
|
+
|
|
268
|
+
def ask() -> Mode:
|
|
269
|
+
answer = prompt_choice(
|
|
270
|
+
output,
|
|
271
|
+
"how should CLAUDE.md relate to AGENTS.md",
|
|
272
|
+
options=(composition.IMPORT, composition.COPY),
|
|
273
|
+
default=composition.IMPORT,
|
|
274
|
+
flag="--mode",
|
|
275
|
+
)
|
|
276
|
+
return answer # type: ignore[return-value]
|
|
277
|
+
|
|
278
|
+
return ask
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _refuse_malformed(prepared: init_ops.InitPlan) -> None:
|
|
282
|
+
"""Stop before writing when a managed file's markers cannot be parsed.
|
|
283
|
+
|
|
284
|
+
Harsher than drift on purpose: with drift the boundaries are known and only
|
|
285
|
+
the content is unexpected, but a malformed file gives mdcompose no way to
|
|
286
|
+
tell which bytes it owns, so any write risks destroying user content.
|
|
287
|
+
"""
|
|
288
|
+
for entry in prepared.malformed:
|
|
289
|
+
raise AttentionError(
|
|
290
|
+
f"{entry.path}: {entry.detail}. Fix the markers before composing."
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _warn_onedrive(output: OutputContext, labelled: tuple[tuple[str, Path], ...]) -> None:
|
|
295
|
+
"""Warn once per given path that sits inside a OneDrive-synced folder.
|
|
296
|
+
|
|
297
|
+
A warning only, matching doctor: OneDrive breaks Files On-Demand reads and
|
|
298
|
+
races a mdcompose.lock write, but the run is never blocked.
|
|
299
|
+
"""
|
|
300
|
+
info = platform_module.detect_platform()
|
|
301
|
+
for label, path in labelled:
|
|
302
|
+
root = platform_module.onedrive_root_for(path, info)
|
|
303
|
+
if root is not None:
|
|
304
|
+
output.warn(platform_module.onedrive_warning(label, path, root))
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _warn_bare_directory(
|
|
308
|
+
output: OutputContext, root: Path, manifest: manifest_module.Manifest | None
|
|
309
|
+
) -> None:
|
|
310
|
+
"""Say what init is about to do in a directory that has none of its files.
|
|
311
|
+
|
|
312
|
+
No AGENTS.md, no CLAUDE.md, and no manifest very likely means this is not the
|
|
313
|
+
directory the user meant. init still proceeds, because a first run in a fresh
|
|
314
|
+
project looks exactly the same, but it no longer creates two files in silence.
|
|
315
|
+
"""
|
|
316
|
+
if manifest is not None:
|
|
317
|
+
return
|
|
318
|
+
if files.path_exists(root / "AGENTS.md") or files.path_exists(root / "CLAUDE.md"):
|
|
319
|
+
return
|
|
320
|
+
output.warn(
|
|
321
|
+
f"{root}: no AGENTS.md, CLAUDE.md, or mdcompose.lock here; "
|
|
322
|
+
"init will create AGENTS.md and CLAUDE.md in this directory"
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _warn_empty_selection(output: OutputContext, prepared: init_ops.InitPlan) -> None:
|
|
327
|
+
"""Say plainly when a run composes nothing, so an empty block is not a surprise."""
|
|
328
|
+
if prepared.from_embedded or prepared.selection:
|
|
329
|
+
return
|
|
330
|
+
output.warn("no snippets selected; the managed blocks will be written empty")
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _warn_redundant_import(
|
|
334
|
+
output: OutputContext, prepared: init_ops.InitPlan, root: Path
|
|
335
|
+
) -> None:
|
|
336
|
+
"""Point out an @import that already sits outside the block init will fill.
|
|
337
|
+
|
|
338
|
+
In import mode init writes the import directive into CLAUDE.md's managed
|
|
339
|
+
block. A copy of that directive the user placed elsewhere in the file is now
|
|
340
|
+
redundant, and init leaves it alone because it owns only its own block.
|
|
341
|
+
"""
|
|
342
|
+
if prepared.mode != composition.IMPORT or prepared.imports is None:
|
|
343
|
+
return
|
|
344
|
+
claude = root / "CLAUDE.md"
|
|
345
|
+
if not files.path_exists(claude):
|
|
346
|
+
return
|
|
347
|
+
if not managed_block.read_blocks(claude).is_well_formed:
|
|
348
|
+
return
|
|
349
|
+
outside = managed_block.remove(
|
|
350
|
+
files.read_text(claude),
|
|
351
|
+
managed_block.CLAUDE_MANAGED_BLOCK,
|
|
352
|
+
claude,
|
|
353
|
+
keep_content=False,
|
|
354
|
+
)
|
|
355
|
+
directive = composition.import_directive(prepared.imports).strip()
|
|
356
|
+
if any(line.strip() == directive for line in outside.split("\n")):
|
|
357
|
+
output.warn(
|
|
358
|
+
f"CLAUDE.md already has '{directive}' outside the managed block; "
|
|
359
|
+
"that line is now redundant and init will not remove it"
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _confirm_foreign(
|
|
364
|
+
output: OutputContext, prepared: init_ops.InitPlan, *, accept: bool
|
|
365
|
+
) -> bool:
|
|
366
|
+
"""Show content that came from elsewhere, and ask before writing it.
|
|
367
|
+
|
|
368
|
+
This is the trust boundary. A cloned manifest carries markdown somebody else
|
|
369
|
+
wrote, about to land in a file an AI agent reads. Composing it silently would
|
|
370
|
+
hide it at exactly the moment it should be visible.
|
|
371
|
+
"""
|
|
372
|
+
if not prepared.from_embedded:
|
|
373
|
+
return True
|
|
374
|
+
|
|
375
|
+
output.info("this manifest was not composed on this machine. It would write:")
|
|
376
|
+
for target in prepared.targets:
|
|
377
|
+
output.info(f" {target.relative_path}:")
|
|
378
|
+
output.content_indented(target.content, empty=" (empty)")
|
|
379
|
+
return confirm(
|
|
380
|
+
output,
|
|
381
|
+
"compose these files from the manifest?",
|
|
382
|
+
assume_yes=accept,
|
|
383
|
+
flag="--yes",
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _drift_choices(
|
|
388
|
+
output: OutputContext,
|
|
389
|
+
prepared: init_ops.InitPlan,
|
|
390
|
+
on_drift: str | None,
|
|
391
|
+
*,
|
|
392
|
+
accept: bool,
|
|
393
|
+
) -> dict[str, init_ops.DriftChoice]:
|
|
394
|
+
"""Decide what to do about every drifted file before writing any of them."""
|
|
395
|
+
if not prepared.drifted:
|
|
396
|
+
return {}
|
|
397
|
+
if on_drift is not None:
|
|
398
|
+
decision = _validated_drift_choice(on_drift)
|
|
399
|
+
return {entry.key: decision for entry in prepared.drifted}
|
|
400
|
+
if accept:
|
|
401
|
+
return {entry.key: init_ops.OVERWRITE for entry in prepared.drifted}
|
|
402
|
+
|
|
403
|
+
choices: dict[str, init_ops.DriftChoice] = {}
|
|
404
|
+
for entry in prepared.drifted:
|
|
405
|
+
target = prepared.target_for(entry.key)
|
|
406
|
+
output.warn(f"{entry.path} has been edited by hand since mdcompose wrote it")
|
|
407
|
+
_show_drift(output, target)
|
|
408
|
+
answer = prompt_choice(
|
|
409
|
+
output,
|
|
410
|
+
f"{entry.path.name}",
|
|
411
|
+
options=init_ops.DRIFT_CHOICES,
|
|
412
|
+
default=init_ops.KEEP,
|
|
413
|
+
flag="--on-drift",
|
|
414
|
+
)
|
|
415
|
+
choices[entry.key] = answer # type: ignore[assignment]
|
|
416
|
+
return choices
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _show_drift(output: OutputContext, target: init_ops.FileTarget | None) -> None:
|
|
420
|
+
"""Show what is there against what would replace it."""
|
|
421
|
+
if target is None:
|
|
422
|
+
return
|
|
423
|
+
result = managed_block.read_blocks(target.path)
|
|
424
|
+
block = result.find(target.block_id)
|
|
425
|
+
output.info(" currently:")
|
|
426
|
+
output.content_indented("" if block is None else block.content, empty=" (empty)")
|
|
427
|
+
output.info(" would become:")
|
|
428
|
+
output.content_indented(target.content, empty=" (empty)")
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _validated_drift_choice(candidate: str) -> init_ops.DriftChoice:
|
|
432
|
+
if candidate not in init_ops.DRIFT_CHOICES:
|
|
433
|
+
raise AttentionError(
|
|
434
|
+
f"--on-drift must be one of {', '.join(init_ops.DRIFT_CHOICES)}, found '{candidate}'"
|
|
435
|
+
)
|
|
436
|
+
return candidate
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _report(
|
|
440
|
+
output: OutputContext, prepared: init_ops.InitPlan, result: init_ops.InitResult
|
|
441
|
+
) -> None:
|
|
442
|
+
output.info(f"mode: {prepared.mode}")
|
|
443
|
+
if prepared.detected_stack:
|
|
444
|
+
output.info(f"detected: {', '.join(prepared.detected_stack)}")
|
|
445
|
+
output.info(f"snippets: {', '.join(item.id for item in prepared.selection) or 'none'}")
|
|
446
|
+
for label, keys in (
|
|
447
|
+
("wrote", result.written),
|
|
448
|
+
("unchanged", result.unchanged),
|
|
449
|
+
("kept your edits in", result.kept),
|
|
450
|
+
):
|
|
451
|
+
if keys:
|
|
452
|
+
output.info(f" {label}: {', '.join(keys)}")
|
|
453
|
+
if result.manifest_path is not None:
|
|
454
|
+
output.info(f" recorded in: {result.manifest_path.name}")
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
GLOBAL_AGENTS_CHOICES = ("~/.claude/AGENTS.md", "~/.codex/AGENTS.md")
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _init_global(
|
|
461
|
+
output: OutputContext,
|
|
462
|
+
configuration: config_module.GlobalConfig,
|
|
463
|
+
config_directory: Path,
|
|
464
|
+
library: snippets_module.LibraryView,
|
|
465
|
+
mode: str | None,
|
|
466
|
+
snippet_ids: str | None,
|
|
467
|
+
accept: bool,
|
|
468
|
+
global_agents_path: str | None,
|
|
469
|
+
) -> None:
|
|
470
|
+
"""Compose the global file pair, recording its state in the global config.
|
|
471
|
+
|
|
472
|
+
The same composition machinery as a project, pointed at two different paths.
|
|
473
|
+
Its mode and selection go in the config rather than a manifest, because there
|
|
474
|
+
is no manifest for a home directory and inventing one would put
|
|
475
|
+
project-shaped state somewhere nobody would look for it.
|
|
476
|
+
"""
|
|
477
|
+
if library.is_empty:
|
|
478
|
+
raise AttentionError(
|
|
479
|
+
f"the snippet library at {library.directory.as_posix()} is empty, so there "
|
|
480
|
+
"is nothing to compose."
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
agents_path = _resolve_global_agents_path(
|
|
484
|
+
output, configuration, global_agents_path, accept=accept
|
|
485
|
+
)
|
|
486
|
+
claude_path = platform_module.resolve_global_claude_md(
|
|
487
|
+
platform_module.detect_platform()
|
|
488
|
+
).path
|
|
489
|
+
_warn_onedrive(
|
|
490
|
+
output,
|
|
491
|
+
(
|
|
492
|
+
("config directory", config_directory),
|
|
493
|
+
("global AGENTS.md", agents_path),
|
|
494
|
+
("global CLAUDE.md", claude_path),
|
|
495
|
+
),
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
claude_global = configuration.claude_global
|
|
499
|
+
recorded_mode = None if claude_global is None else claude_global.mode
|
|
500
|
+
resolved_mode = init_ops.resolve_mode(
|
|
501
|
+
requested=_validated_mode(mode),
|
|
502
|
+
recorded=recorded_mode,
|
|
503
|
+
default=_default_mode(configuration, accept=accept),
|
|
504
|
+
ask=_mode_asker(output, accept),
|
|
505
|
+
)
|
|
506
|
+
selection = _global_selection(library, configuration, snippet_ids, accept, output)
|
|
507
|
+
composed = composition.compose(
|
|
508
|
+
selection,
|
|
509
|
+
mode=resolved_mode,
|
|
510
|
+
import_target=init_ops.relative_import_target(agents_path, claude_path.parent),
|
|
511
|
+
)
|
|
512
|
+
|
|
513
|
+
wrote = []
|
|
514
|
+
if composition.apply_to_file(
|
|
515
|
+
agents_path, managed_block.AGENTS_COMPOSITION_BLOCK, composed.agents_block
|
|
516
|
+
):
|
|
517
|
+
wrote.append(agents_path.as_posix())
|
|
518
|
+
if composition.apply_to_file(
|
|
519
|
+
claude_path, managed_block.CLAUDE_MANAGED_BLOCK, composed.claude_block
|
|
520
|
+
):
|
|
521
|
+
wrote.append(claude_path.as_posix())
|
|
522
|
+
|
|
523
|
+
config_module.write_config(
|
|
524
|
+
_recorded_global_config(configuration, agents_path, claude_path, resolved_mode, composed),
|
|
525
|
+
config_module.config_path(config_directory),
|
|
526
|
+
)
|
|
527
|
+
output.info(f"mode: {resolved_mode}")
|
|
528
|
+
output.info(f"snippets: {', '.join(item.id for item in selection) or 'none'}")
|
|
529
|
+
for path in wrote:
|
|
530
|
+
output.info(f" wrote: {path}")
|
|
531
|
+
if not wrote:
|
|
532
|
+
output.info(" unchanged")
|
|
533
|
+
|
|
534
|
+
recorded = _recorded_global_config(
|
|
535
|
+
configuration, agents_path, claude_path, resolved_mode, composed
|
|
536
|
+
)
|
|
537
|
+
_project_to_targets(output, recorded, composed.agents_block, accept)
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def _project_to_targets(
|
|
541
|
+
output: OutputContext,
|
|
542
|
+
configuration: config_module.GlobalConfig,
|
|
543
|
+
canonical_content: str,
|
|
544
|
+
accept: bool,
|
|
545
|
+
) -> None:
|
|
546
|
+
"""Project the canonical AGENTS.md content into every registered target.
|
|
547
|
+
|
|
548
|
+
Failures are isolated: one bad target does not stop the others, and the run
|
|
549
|
+
reports a non-zero outcome so the condition is not swallowed.
|
|
550
|
+
"""
|
|
551
|
+
if not configuration.registered_global_targets:
|
|
552
|
+
return
|
|
553
|
+
drifted = targets_module.drifted_targets(configuration, canonical_content)
|
|
554
|
+
choices: dict[str, targets_module.DriftChoice] = {}
|
|
555
|
+
if drifted:
|
|
556
|
+
decision = _target_drift_decision(output, drifted, accept)
|
|
557
|
+
choices = {entry.label: decision for entry in drifted}
|
|
558
|
+
|
|
559
|
+
result = targets_module.project(configuration, canonical_content, drift_choices=choices)
|
|
560
|
+
for outcome in result.outcomes:
|
|
561
|
+
if outcome.status in {"written", "created"}:
|
|
562
|
+
output.info(f" projected: {outcome.label}")
|
|
563
|
+
elif outcome.status == "kept":
|
|
564
|
+
output.warn(f" {outcome.label}: {outcome.detail}")
|
|
565
|
+
elif outcome.status.startswith("skipped"):
|
|
566
|
+
output.warn(f" {outcome.label}: {outcome.detail}, skipped")
|
|
567
|
+
if result.had_failure:
|
|
568
|
+
raise typer.Exit(EXIT_ATTENTION)
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def _target_drift_decision(
|
|
572
|
+
output: OutputContext,
|
|
573
|
+
drifted: tuple[config_module.TargetEntry, ...],
|
|
574
|
+
accept: bool,
|
|
575
|
+
) -> targets_module.DriftChoice:
|
|
576
|
+
if accept:
|
|
577
|
+
return targets_module.OVERWRITE
|
|
578
|
+
names = ", ".join(entry.label for entry in drifted)
|
|
579
|
+
answer = prompt_choice(
|
|
580
|
+
output,
|
|
581
|
+
f"targets edited by hand ({names})",
|
|
582
|
+
options=targets_module.DRIFT_CHOICES,
|
|
583
|
+
default=targets_module.KEEP,
|
|
584
|
+
flag="--yes",
|
|
585
|
+
)
|
|
586
|
+
return answer # type: ignore[return-value]
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def _resolve_global_agents_path(
|
|
590
|
+
output: OutputContext,
|
|
591
|
+
configuration: config_module.GlobalConfig,
|
|
592
|
+
supplied: str | None,
|
|
593
|
+
*,
|
|
594
|
+
accept: bool,
|
|
595
|
+
) -> Path:
|
|
596
|
+
"""Decide where the canonical global AGENTS.md lives, asking only once.
|
|
597
|
+
|
|
598
|
+
Chosen lazily: only a command that writes the global pair asks, and the
|
|
599
|
+
answer is recorded so it is never asked again.
|
|
600
|
+
"""
|
|
601
|
+
chosen = supplied or configuration.global_agents_path
|
|
602
|
+
if chosen is None:
|
|
603
|
+
chosen = (
|
|
604
|
+
GLOBAL_AGENTS_CHOICES[0]
|
|
605
|
+
if accept
|
|
606
|
+
else prompt_choice(
|
|
607
|
+
output,
|
|
608
|
+
"where should the canonical global AGENTS.md live",
|
|
609
|
+
options=GLOBAL_AGENTS_CHOICES,
|
|
610
|
+
default=GLOBAL_AGENTS_CHOICES[0],
|
|
611
|
+
flag="--global-agents-path",
|
|
612
|
+
)
|
|
613
|
+
)
|
|
614
|
+
resolved = Path(chosen).expanduser()
|
|
615
|
+
if resolved.is_dir():
|
|
616
|
+
raise AttentionError(f"{resolved}: is a directory, not a file")
|
|
617
|
+
return resolved
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def _global_selection(
|
|
621
|
+
library: snippets_module.LibraryView,
|
|
622
|
+
configuration: config_module.GlobalConfig,
|
|
623
|
+
snippet_ids: str | None,
|
|
624
|
+
accept: bool,
|
|
625
|
+
output: OutputContext,
|
|
626
|
+
) -> tuple[Snippet, ...]:
|
|
627
|
+
"""Which snippets the global pair composes."""
|
|
628
|
+
requested = _parse_ids(snippet_ids)
|
|
629
|
+
if requested is not None:
|
|
630
|
+
return tuple(library.require(item) for item in requested)
|
|
631
|
+
recorded = _recorded_global_ids(configuration)
|
|
632
|
+
selector = _selector(output, accept)
|
|
633
|
+
if selector is None:
|
|
634
|
+
if recorded:
|
|
635
|
+
return tuple(library.require(item) for item in recorded)
|
|
636
|
+
raise AttentionError(
|
|
637
|
+
"no snippet selection was supplied and there is no terminal to open the "
|
|
638
|
+
"picker on. Pass --snippets with a comma-separated list."
|
|
639
|
+
)
|
|
640
|
+
return tuple(library.require(item) for item in selector(library.snippets, recorded))
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def _recorded_global_ids(configuration: config_module.GlobalConfig) -> tuple[str, ...]:
|
|
644
|
+
recorded = configuration.extra.get("global_snippet_ids")
|
|
645
|
+
if isinstance(recorded, list) and all(isinstance(item, str) for item in recorded):
|
|
646
|
+
return tuple(recorded)
|
|
647
|
+
return ()
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def _recorded_global_config(
|
|
651
|
+
configuration: config_module.GlobalConfig,
|
|
652
|
+
agents_path: Path,
|
|
653
|
+
claude_path: Path,
|
|
654
|
+
mode: Mode,
|
|
655
|
+
composed: composition.Composition,
|
|
656
|
+
) -> config_module.GlobalConfig:
|
|
657
|
+
extra = dict(configuration.extra)
|
|
658
|
+
extra["global_snippet_ids"] = list(composed.snippet_ids)
|
|
659
|
+
return config_module.GlobalConfig(
|
|
660
|
+
schema_version=configuration.schema_version or config_module.SCHEMA_VERSION,
|
|
661
|
+
snippet_library_path=configuration.snippet_library_path,
|
|
662
|
+
default_mode=configuration.default_mode,
|
|
663
|
+
claude_global=config_module.ClaudeGlobal(path=claude_path.as_posix(), mode=mode),
|
|
664
|
+
global_agents_path=agents_path.as_posix(),
|
|
665
|
+
registered_global_targets=configuration.registered_global_targets,
|
|
666
|
+
extra=extra,
|
|
667
|
+
source=configuration.source,
|
|
668
|
+
)
|