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,180 @@
|
|
|
1
|
+
"""The eject command: stop mdcompose managing a directory.
|
|
2
|
+
|
|
3
|
+
Removes the managed block markers from a target's files and deletes its
|
|
4
|
+
``mdcompose.lock``. The block content stays as plain markdown by default so the
|
|
5
|
+
files keep working; ``--strip`` removes it too. Everything outside a block,
|
|
6
|
+
including anything `import` or `convert` added, is left exactly as it was, and
|
|
7
|
+
the snippet library is never touched.
|
|
8
|
+
|
|
9
|
+
Re-running `init` after an eject adds a fresh managed block alongside whatever
|
|
10
|
+
was kept. Ejecting is not a reversible toggle.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import replace
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Annotated
|
|
18
|
+
|
|
19
|
+
import typer
|
|
20
|
+
|
|
21
|
+
from mdcompose.core import config as config_module
|
|
22
|
+
from mdcompose.core import eject as eject_module
|
|
23
|
+
from mdcompose.core import files, managed_block
|
|
24
|
+
from mdcompose.core import platform as platform_module
|
|
25
|
+
from mdcompose.core.exit_codes import AttentionError
|
|
26
|
+
from mdcompose.core.output import OutputContext
|
|
27
|
+
from mdcompose.prompts import is_interactive, output_for
|
|
28
|
+
|
|
29
|
+
DirectoryArgument = Annotated[
|
|
30
|
+
Path | None,
|
|
31
|
+
typer.Argument(help="Directory to eject. Defaults to the current directory."),
|
|
32
|
+
]
|
|
33
|
+
StripOption = Annotated[
|
|
34
|
+
bool,
|
|
35
|
+
typer.Option("--strip", help="Remove the block content too, not just the markers."),
|
|
36
|
+
]
|
|
37
|
+
GlobalOption = Annotated[
|
|
38
|
+
bool, typer.Option("--global", help="Eject the global file pair instead of a project.")
|
|
39
|
+
]
|
|
40
|
+
YesOption = Annotated[
|
|
41
|
+
bool, typer.Option("--yes", "-y", help="Confirm the eject without a prompt.")
|
|
42
|
+
]
|
|
43
|
+
QuietOption = Annotated[bool, typer.Option("--quiet", "-q", help="Suppress informational output.")]
|
|
44
|
+
NoColourOption = Annotated[bool, typer.Option("--no-color", help="Never colourize output.")]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def register(app: typer.Typer) -> None:
|
|
48
|
+
"""Attach the command to an app under its real name."""
|
|
49
|
+
app.command("eject")(eject)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def eject(
|
|
53
|
+
context: typer.Context,
|
|
54
|
+
directory: DirectoryArgument = None,
|
|
55
|
+
strip: StripOption = False,
|
|
56
|
+
global_scope: GlobalOption = False,
|
|
57
|
+
yes: YesOption = False,
|
|
58
|
+
quiet: QuietOption = False,
|
|
59
|
+
no_colour: NoColourOption = False,
|
|
60
|
+
) -> None:
|
|
61
|
+
"""Take mdcompose's markers back out of a directory and delete its manifest.
|
|
62
|
+
|
|
63
|
+
The block content is kept as plain markdown unless --strip is given. Content
|
|
64
|
+
outside a block, and the snippet library, are never touched. Re-running init
|
|
65
|
+
later adds a fresh block alongside what was kept.
|
|
66
|
+
"""
|
|
67
|
+
output = output_for(context, quiet=quiet, no_colour=no_colour)
|
|
68
|
+
if global_scope:
|
|
69
|
+
_eject_global(output, strip, yes)
|
|
70
|
+
return
|
|
71
|
+
|
|
72
|
+
root = _resolve_target(directory)
|
|
73
|
+
plan = eject_module.plan_eject(root, strip=strip)
|
|
74
|
+
if plan.malformed:
|
|
75
|
+
raise AttentionError(eject_module.malformed_message(plan))
|
|
76
|
+
if plan.is_empty:
|
|
77
|
+
output.info("nothing to eject, this directory is not managed by mdcompose")
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
_run(output, plan, yes)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _run(output: OutputContext, plan: eject_module.EjectPlan, yes: bool) -> None:
|
|
84
|
+
for note in eject_module.summary(plan):
|
|
85
|
+
output.warn(note)
|
|
86
|
+
diff = eject_module.render_diff(plan)
|
|
87
|
+
if diff:
|
|
88
|
+
output.content(diff)
|
|
89
|
+
if not _confirmed(output, yes):
|
|
90
|
+
output.info("declined, nothing changed")
|
|
91
|
+
return
|
|
92
|
+
touched = eject_module.apply_eject(plan)
|
|
93
|
+
output.info(f"ejected: {', '.join(touched)}" if touched else "nothing to change")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _eject_global(output: OutputContext, strip: bool, yes: bool) -> None:
|
|
97
|
+
"""Eject the global pair and clear the mode and composition it recorded."""
|
|
98
|
+
info = platform_module.detect_platform()
|
|
99
|
+
config_directory = platform_module.config_dir()
|
|
100
|
+
configuration = config_module.load_config(config_module.config_path(config_directory))
|
|
101
|
+
claude_path = platform_module.resolve_global_claude_md(info).path
|
|
102
|
+
agents_path = (
|
|
103
|
+
None
|
|
104
|
+
if configuration.global_agents_path is None
|
|
105
|
+
else Path(configuration.global_agents_path).expanduser()
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
target_paths = [
|
|
109
|
+
(Path(entry.path).expanduser(), managed_block.AGENTS_COMPOSITION_BLOCK)
|
|
110
|
+
for entry in configuration.registered_global_targets
|
|
111
|
+
]
|
|
112
|
+
changes = [
|
|
113
|
+
_global_change(path, block_id, strip)
|
|
114
|
+
for path, block_id in [
|
|
115
|
+
(claude_path, managed_block.CLAUDE_MANAGED_BLOCK),
|
|
116
|
+
(agents_path, managed_block.AGENTS_COMPOSITION_BLOCK),
|
|
117
|
+
*target_paths,
|
|
118
|
+
]
|
|
119
|
+
if path is not None
|
|
120
|
+
]
|
|
121
|
+
malformed = [c for c in changes if c is None]
|
|
122
|
+
real = [c for c in changes if c is not None]
|
|
123
|
+
if malformed:
|
|
124
|
+
raise AttentionError("a global file's markers are malformed; fix them before ejecting")
|
|
125
|
+
|
|
126
|
+
modifies = [c for c in real if not files.content_equal(c[1], c[2])]
|
|
127
|
+
recorded = (
|
|
128
|
+
configuration.claude_global is not None
|
|
129
|
+
or "global_snippet_ids" in configuration.extra
|
|
130
|
+
)
|
|
131
|
+
if not modifies and not recorded:
|
|
132
|
+
output.info("nothing to eject, the global pair is not managed by mdcompose")
|
|
133
|
+
return
|
|
134
|
+
|
|
135
|
+
for name, before, after in modifies:
|
|
136
|
+
output.content(eject_module.file_diff(name.name, before, after))
|
|
137
|
+
if recorded:
|
|
138
|
+
output.info("would clear the recorded global mode and composition from the config")
|
|
139
|
+
if not _confirmed(output, yes):
|
|
140
|
+
output.info("declined, nothing changed")
|
|
141
|
+
return
|
|
142
|
+
|
|
143
|
+
for path, _before, after in modifies:
|
|
144
|
+
files.write_text(path, after, line_ending=files.line_ending_for(path))
|
|
145
|
+
if recorded:
|
|
146
|
+
extra = {k: v for k, v in configuration.extra.items() if k != "global_snippet_ids"}
|
|
147
|
+
config_module.write_config(
|
|
148
|
+
replace(configuration, claude_global=None, extra=extra),
|
|
149
|
+
config_module.config_path(config_directory),
|
|
150
|
+
)
|
|
151
|
+
output.info("ejected the global pair")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _global_change(
|
|
155
|
+
path: Path, block_id: str, strip: bool
|
|
156
|
+
) -> tuple[Path, str, str] | None:
|
|
157
|
+
if not files.path_exists(path) or not path.is_file():
|
|
158
|
+
return (path, "", "")
|
|
159
|
+
text = files.read_text(path)
|
|
160
|
+
if managed_block.scan(text).problem is not None:
|
|
161
|
+
return None
|
|
162
|
+
return (path, text, managed_block.remove(text, block_id, path, keep_content=not strip))
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _resolve_target(directory: Path | None) -> Path:
|
|
166
|
+
root = Path.cwd() if directory is None else Path(directory).expanduser()
|
|
167
|
+
if not root.is_dir():
|
|
168
|
+
raise AttentionError(f"{root}: not a directory")
|
|
169
|
+
return root
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _confirmed(output: OutputContext, yes: bool) -> bool:
|
|
173
|
+
if yes:
|
|
174
|
+
return True
|
|
175
|
+
if output.json_mode or not is_interactive():
|
|
176
|
+
raise AttentionError(
|
|
177
|
+
"eject deletes the manifest and edits managed files. Re-run with --yes to "
|
|
178
|
+
"apply the diff shown above."
|
|
179
|
+
)
|
|
180
|
+
return typer.confirm("apply this eject?", default=False, err=True)
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
"""The import command: pull sections out of an existing CLAUDE.md or AGENTS.md.
|
|
2
|
+
|
|
3
|
+
`import` reads a file from anywhere on disk, never writing to it, lets the user
|
|
4
|
+
pick the sections worth keeping, and appends them to the current project's
|
|
5
|
+
AGENTS.md or CLAUDE.md, outside the managed block, in the region `init` never
|
|
6
|
+
touches.
|
|
7
|
+
|
|
8
|
+
The file is named ``import_cmd`` because ``import`` is a reserved word in Python.
|
|
9
|
+
The Typer command is still ``import``.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from collections.abc import Sequence
|
|
15
|
+
from datetime import UTC, datetime
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Annotated
|
|
18
|
+
|
|
19
|
+
import typer
|
|
20
|
+
|
|
21
|
+
from mdcompose.core import config as config_module
|
|
22
|
+
from mdcompose.core import files, import_ops, managed_block, sections
|
|
23
|
+
from mdcompose.core import platform as platform_module
|
|
24
|
+
from mdcompose.core import snippets as snippets_module
|
|
25
|
+
from mdcompose.core.exit_codes import AttentionError
|
|
26
|
+
from mdcompose.core.output import OutputContext
|
|
27
|
+
from mdcompose.prompts import is_interactive, output_for, prompt_choice
|
|
28
|
+
|
|
29
|
+
SourceArgument = Annotated[
|
|
30
|
+
Path,
|
|
31
|
+
typer.Argument(help="Path to the CLAUDE.md or AGENTS.md to import sections from."),
|
|
32
|
+
]
|
|
33
|
+
KeywordOption = Annotated[
|
|
34
|
+
str | None,
|
|
35
|
+
typer.Option(
|
|
36
|
+
"--keyword",
|
|
37
|
+
help="Only offer sections whose heading or content contains this text.",
|
|
38
|
+
),
|
|
39
|
+
]
|
|
40
|
+
SectionOption = Annotated[
|
|
41
|
+
list[str] | None,
|
|
42
|
+
typer.Option(
|
|
43
|
+
"--section",
|
|
44
|
+
help="Import the section with this heading. Repeatable. Bypasses the picker.",
|
|
45
|
+
),
|
|
46
|
+
]
|
|
47
|
+
ToClaudeOption = Annotated[
|
|
48
|
+
bool,
|
|
49
|
+
typer.Option("--to-claude", help="Apply the imported content to CLAUDE.md, not AGENTS.md."),
|
|
50
|
+
]
|
|
51
|
+
SaveAsSnippetOption = Annotated[
|
|
52
|
+
str | None,
|
|
53
|
+
typer.Option(
|
|
54
|
+
"--save-as-snippet",
|
|
55
|
+
help="Also save the extracted content to the snippet library under this name.",
|
|
56
|
+
),
|
|
57
|
+
]
|
|
58
|
+
CategoryOption = Annotated[
|
|
59
|
+
str | None,
|
|
60
|
+
typer.Option("--category", help="Category for the saved snippet's frontmatter."),
|
|
61
|
+
]
|
|
62
|
+
TagsOption = Annotated[
|
|
63
|
+
str | None,
|
|
64
|
+
typer.Option("--tags", help="Comma-separated tags for the saved snippet's frontmatter."),
|
|
65
|
+
]
|
|
66
|
+
OnCollisionOption = Annotated[
|
|
67
|
+
str | None,
|
|
68
|
+
typer.Option(
|
|
69
|
+
"--on-collision",
|
|
70
|
+
help="Resolve a snippet-name collision without asking: keep, overwrite, or rename.",
|
|
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 under its real name."""
|
|
79
|
+
app.command("import")(import_content)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def import_content(
|
|
83
|
+
context: typer.Context,
|
|
84
|
+
source: SourceArgument,
|
|
85
|
+
keyword: KeywordOption = None,
|
|
86
|
+
section_names: SectionOption = None,
|
|
87
|
+
to_claude: ToClaudeOption = False,
|
|
88
|
+
save_as_snippet: SaveAsSnippetOption = None,
|
|
89
|
+
category: CategoryOption = None,
|
|
90
|
+
tags: TagsOption = None,
|
|
91
|
+
on_collision: OnCollisionOption = None,
|
|
92
|
+
quiet: QuietOption = False,
|
|
93
|
+
no_colour: NoColourOption = False,
|
|
94
|
+
) -> None:
|
|
95
|
+
"""Import chosen sections of an existing file into this project's AGENTS.md.
|
|
96
|
+
|
|
97
|
+
The source is read but never modified. Imported content is appended outside
|
|
98
|
+
the target's managed block, so a later `init` neither overwrites nor removes
|
|
99
|
+
it. With --save-as-snippet the same content is also written to the library.
|
|
100
|
+
"""
|
|
101
|
+
output = output_for(context, quiet=quiet, no_colour=no_colour)
|
|
102
|
+
info = platform_module.detect_platform()
|
|
103
|
+
source_path = _resolve_source(source, info, output)
|
|
104
|
+
target = _target_path(to_claude)
|
|
105
|
+
_reject_source_is_target(source_path, target)
|
|
106
|
+
|
|
107
|
+
clean = managed_block.without_managed_blocks(files.read_text(source_path), source_path)
|
|
108
|
+
parsed = sections.parse_sections(clean, source_name=source_path.name)
|
|
109
|
+
if _nothing_importable(parsed):
|
|
110
|
+
raise AttentionError(
|
|
111
|
+
f"{source_path}: nothing to import, the file has no content outside an "
|
|
112
|
+
"mdcompose managed block"
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
names = tuple(section_names or ())
|
|
116
|
+
pool = _pool(output, parsed, keyword, names)
|
|
117
|
+
if pool is None:
|
|
118
|
+
return
|
|
119
|
+
selected = import_ops.resolve_selection(
|
|
120
|
+
pool, requested_headings=names or None, selector=_picker(output)
|
|
121
|
+
)
|
|
122
|
+
if not selected:
|
|
123
|
+
output.info("nothing selected, nothing applied")
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
content = sections.extract(selected)
|
|
127
|
+
if save_as_snippet is not None:
|
|
128
|
+
_save_snippet(
|
|
129
|
+
output, save_as_snippet, content, selected, source_path, category, tags, on_collision
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
import_ops.apply_to_target(target, content)
|
|
133
|
+
plural = "" if len(selected) == 1 else "s"
|
|
134
|
+
output.info(f"appended {len(selected)} section{plural} to {target.name}")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _save_snippet(
|
|
138
|
+
output: OutputContext,
|
|
139
|
+
name: str,
|
|
140
|
+
content: str,
|
|
141
|
+
selected: tuple[sections.Section, ...],
|
|
142
|
+
source_path: Path,
|
|
143
|
+
category: str | None,
|
|
144
|
+
tags: str | None,
|
|
145
|
+
on_collision: str | None,
|
|
146
|
+
) -> None:
|
|
147
|
+
"""Persist the extracted content to the library, so it is reusable everywhere.
|
|
148
|
+
|
|
149
|
+
A name that is already taken is resolved by the flag or by asking: an
|
|
150
|
+
identical body is a silent no-op, a different one offers keep, overwrite, or
|
|
151
|
+
a new name.
|
|
152
|
+
"""
|
|
153
|
+
library = _resolve_library()
|
|
154
|
+
heading = next((section.heading for section in selected if not section.synthetic), None)
|
|
155
|
+
snippet = import_ops.build_snippet(
|
|
156
|
+
name=name,
|
|
157
|
+
body=content,
|
|
158
|
+
source_path=source_path.as_posix(),
|
|
159
|
+
source_heading=heading,
|
|
160
|
+
category=category,
|
|
161
|
+
tags=_split_tags(tags),
|
|
162
|
+
today=datetime.now(UTC).date().isoformat(),
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
status = import_ops.snippet_status(library, snippet)
|
|
166
|
+
if status == import_ops.IDENTICAL:
|
|
167
|
+
output.info(f"snippet '{name}' is already in the library with this content")
|
|
168
|
+
return
|
|
169
|
+
if status == import_ops.NEW:
|
|
170
|
+
import_ops.write_snippet(library, snippet)
|
|
171
|
+
output.info(f"saved snippet '{name}'")
|
|
172
|
+
return
|
|
173
|
+
|
|
174
|
+
choice = _resolve_collision(output, library, snippet, on_collision)
|
|
175
|
+
if choice == import_ops.KEEP:
|
|
176
|
+
output.info(f"kept the library's existing '{name}'")
|
|
177
|
+
return
|
|
178
|
+
if choice == import_ops.OVERWRITE:
|
|
179
|
+
import_ops.write_snippet(library, snippet, overwrite=True)
|
|
180
|
+
output.info(f"overwrote snippet '{name}'")
|
|
181
|
+
return
|
|
182
|
+
renamed = import_ops.rename_snippet(snippet, _ask_new_name(output, library))
|
|
183
|
+
import_ops.write_snippet(library, renamed)
|
|
184
|
+
output.info(f"saved snippet '{renamed.id}'")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _resolve_collision(
|
|
188
|
+
output: OutputContext,
|
|
189
|
+
library: snippets_module.LibraryView,
|
|
190
|
+
snippet: snippets_module.Snippet,
|
|
191
|
+
on_collision: str | None,
|
|
192
|
+
) -> import_ops.CollisionChoice:
|
|
193
|
+
"""Decide how to handle a name already taken by different content."""
|
|
194
|
+
if on_collision is not None:
|
|
195
|
+
return _validated_collision_choice(on_collision)
|
|
196
|
+
if output.json_mode or not is_interactive():
|
|
197
|
+
raise AttentionError(
|
|
198
|
+
f"a snippet named '{snippet.id}' already exists with different content. "
|
|
199
|
+
"Pass --on-collision with keep, overwrite, or rename."
|
|
200
|
+
)
|
|
201
|
+
existing = library.require(snippet.id)
|
|
202
|
+
output.warn(f"snippet '{snippet.id}' already exists in the library with different content")
|
|
203
|
+
output.info(" library copy:")
|
|
204
|
+
output.content_indented(existing.body)
|
|
205
|
+
output.info(" imported content:")
|
|
206
|
+
output.content_indented(snippet.body)
|
|
207
|
+
answer = prompt_choice(
|
|
208
|
+
output,
|
|
209
|
+
f"snippet '{snippet.id}'",
|
|
210
|
+
options=import_ops.COLLISION_CHOICES,
|
|
211
|
+
default=import_ops.KEEP,
|
|
212
|
+
flag="--on-collision",
|
|
213
|
+
)
|
|
214
|
+
return answer # type: ignore[return-value]
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _validated_collision_choice(candidate: str) -> import_ops.CollisionChoice:
|
|
218
|
+
if candidate not in import_ops.COLLISION_CHOICES:
|
|
219
|
+
raise AttentionError(
|
|
220
|
+
f"--on-collision must be one of {', '.join(import_ops.COLLISION_CHOICES)}, "
|
|
221
|
+
f"found '{candidate}'"
|
|
222
|
+
)
|
|
223
|
+
return candidate
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _ask_new_name(
|
|
227
|
+
output: OutputContext, library: snippets_module.LibraryView
|
|
228
|
+
) -> str:
|
|
229
|
+
"""Ask for a snippet name not already in the library, refusing without a terminal."""
|
|
230
|
+
if output.json_mode or not is_interactive():
|
|
231
|
+
raise AttentionError(
|
|
232
|
+
"choosing a new snippet name needs a terminal. Re-run with a different "
|
|
233
|
+
"--save-as-snippet, or --on-collision with keep or overwrite."
|
|
234
|
+
)
|
|
235
|
+
while True:
|
|
236
|
+
answer = typer.prompt("new snippet name", err=True).strip()
|
|
237
|
+
try:
|
|
238
|
+
candidate = snippets_module.validate_id(answer)
|
|
239
|
+
except AttentionError as problem:
|
|
240
|
+
output.warn(problem.message)
|
|
241
|
+
continue
|
|
242
|
+
if library.find(candidate) is not None:
|
|
243
|
+
output.warn(f"'{candidate}' is also taken, choose another")
|
|
244
|
+
continue
|
|
245
|
+
return candidate
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _split_tags(raw: str | None) -> tuple[str, ...]:
|
|
249
|
+
if raw is None:
|
|
250
|
+
return ()
|
|
251
|
+
return tuple(tag.strip() for tag in raw.split(",") if tag.strip())
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _resolve_library() -> snippets_module.LibraryView:
|
|
255
|
+
directory = platform_module.config_dir()
|
|
256
|
+
configuration = config_module.load_config(config_module.config_path(directory))
|
|
257
|
+
return snippets_module.view(config_module.library_dir(configuration, directory))
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _resolve_source(raw: Path, info: platform_module.PlatformInfo, output: OutputContext) -> Path:
|
|
261
|
+
"""Resolve the source path, refusing what cannot be read as a file.
|
|
262
|
+
|
|
263
|
+
A missing path and a directory are named and refused here. An existing file
|
|
264
|
+
that cannot be read is refused by ``files.read_text`` with the same effect.
|
|
265
|
+
"""
|
|
266
|
+
resolved = platform_module.resolve_path(raw, info)
|
|
267
|
+
if not resolved.exists:
|
|
268
|
+
raise AttentionError(f"{resolved.path}: no such file to import from")
|
|
269
|
+
if resolved.path.is_dir():
|
|
270
|
+
raise AttentionError(f"{resolved.path}: a file is required to import from, not a directory")
|
|
271
|
+
if resolved.on_windows_mount:
|
|
272
|
+
output.warn(
|
|
273
|
+
f"{resolved.path.as_posix()} is on a Windows drive mounted under "
|
|
274
|
+
f"{platform_module.WINDOWS_MOUNT_PREFIX}, which crosses the WSL and Windows "
|
|
275
|
+
"filesystem boundary"
|
|
276
|
+
)
|
|
277
|
+
return resolved.path
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _target_path(to_claude: bool) -> Path:
|
|
281
|
+
name = platform_module.CLAUDE_MD_NAME if to_claude else platform_module.AGENTS_MD_NAME
|
|
282
|
+
return Path.cwd() / name
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _reject_source_is_target(source_path: Path, target_path: Path) -> None:
|
|
286
|
+
if files.normalized_path(source_path) == files.normalized_path(target_path):
|
|
287
|
+
raise AttentionError(f"{source_path}: a file cannot be imported into itself")
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _pool(
|
|
291
|
+
output: OutputContext,
|
|
292
|
+
parsed: tuple[sections.Section, ...],
|
|
293
|
+
keyword: str | None,
|
|
294
|
+
section_names: tuple[str, ...],
|
|
295
|
+
) -> tuple[sections.Section, ...] | None:
|
|
296
|
+
"""The sections a run may choose from, or None when there is nothing to do.
|
|
297
|
+
|
|
298
|
+
A keyword narrows the pool. Naming sections directly overrides the keyword,
|
|
299
|
+
which is then reported as unused rather than silently ignored.
|
|
300
|
+
"""
|
|
301
|
+
if section_names:
|
|
302
|
+
if keyword is not None:
|
|
303
|
+
output.warn(f"--keyword '{keyword}' was not used because --section was given")
|
|
304
|
+
return parsed
|
|
305
|
+
if keyword is not None:
|
|
306
|
+
matched = sections.filter_by_keyword(parsed, keyword)
|
|
307
|
+
if not matched:
|
|
308
|
+
output.info(f"no section matched '{keyword}'")
|
|
309
|
+
return None
|
|
310
|
+
return matched
|
|
311
|
+
return parsed
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _picker(output: OutputContext) -> import_ops.SectionSelector | None:
|
|
315
|
+
"""Return an interactive section picker, or None when nobody can be asked."""
|
|
316
|
+
if output.json_mode or not is_interactive():
|
|
317
|
+
return None
|
|
318
|
+
|
|
319
|
+
def pick(available: Sequence[sections.Section]) -> tuple[sections.Section, ...]:
|
|
320
|
+
return _run_section_picker(available)
|
|
321
|
+
|
|
322
|
+
return pick
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _run_section_picker(
|
|
326
|
+
available: Sequence[sections.Section],
|
|
327
|
+
) -> tuple[sections.Section, ...]:
|
|
328
|
+
"""Ask which sections to import, showing each one's heading level.
|
|
329
|
+
|
|
330
|
+
Imported here so the picker library loads only when a picker actually opens.
|
|
331
|
+
"""
|
|
332
|
+
import questionary
|
|
333
|
+
|
|
334
|
+
choices = [
|
|
335
|
+
questionary.Choice(
|
|
336
|
+
title=f"{'#' * section.level if section.level else '(file)'} {section.heading}",
|
|
337
|
+
value=section,
|
|
338
|
+
)
|
|
339
|
+
for section in available
|
|
340
|
+
]
|
|
341
|
+
answer = questionary.checkbox("Sections to import", choices=choices).ask()
|
|
342
|
+
if answer is None:
|
|
343
|
+
raise AttentionError("no selection made")
|
|
344
|
+
return tuple(answer)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _nothing_importable(parsed: tuple[sections.Section, ...]) -> bool:
|
|
348
|
+
"""Whether the source has no content outside a managed block worth offering."""
|
|
349
|
+
if not parsed:
|
|
350
|
+
return True
|
|
351
|
+
return all(section.synthetic and not section.content.strip() for section in parsed)
|