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