mdcompose 0.2.0__tar.gz → 0.2.1__tar.gz
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-0.2.0 → mdcompose-0.2.1}/CHANGELOG.md +6 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/PKG-INFO +7 -4
- {mdcompose-0.2.0 → mdcompose-0.2.1}/README.md +6 -3
- {mdcompose-0.2.0 → mdcompose-0.2.1}/TODO.md +17 -22
- {mdcompose-0.2.0 → mdcompose-0.2.1}/docs/concepts.md +2 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/docs/core-contract.md +6 -1
- {mdcompose-0.2.0 → mdcompose-0.2.1}/mdcompose/cli.py +2 -0
- mdcompose-0.2.1/mdcompose/commands/skill_cmd.py +313 -0
- mdcompose-0.2.1/mdcompose/core/skill_library_ops.py +180 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/pyproject.toml +1 -1
- mdcompose-0.2.1/tests/test_skill_commands.py +483 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/uv.lock +1 -1
- {mdcompose-0.2.0 → mdcompose-0.2.1}/.gitattributes +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/.github/FUNDING.yml +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/.github/workflows/bump.yml +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/.github/workflows/ci.yml +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/.github/workflows/codeql.yml +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/.github/workflows/dependency-review.yml +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/.github/workflows/mutation.yml +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/.github/workflows/python-eol.yml +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/.github/workflows/release.yml +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/.github/workflows/scorecard.yml +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/.github/workflows/supply-chain.yml +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/.github/zizmor.yml +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/.gitignore +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/.pre-commit-config.yaml +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/CODE_OF_CONDUCT.md +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/CONTRIBUTING.md +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/LICENSE +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/SECURITY.md +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/docs/benchmarks.md +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/docs/commit-and-release-tooling.md +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/docs/hardening-review.md +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/docs/optional-tooling.md +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/docs/publishing.md +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/docs/scorecard.md +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/docs/versioning-explained.md +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/mdcompose/__init__.py +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/mdcompose/commands/__init__.py +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/mdcompose/commands/config_cmd.py +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/mdcompose/commands/convert_cmd.py +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/mdcompose/commands/eject_cmd.py +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/mdcompose/commands/import_cmd.py +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/mdcompose/commands/init_cmd.py +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/mdcompose/commands/snippet_cmd.py +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/mdcompose/commands/target_cmd.py +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/mdcompose/core/__init__.py +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/mdcompose/core/composition.py +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/mdcompose/core/config.py +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/mdcompose/core/convert_ops.py +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/mdcompose/core/eject.py +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/mdcompose/core/exit_codes.py +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/mdcompose/core/files.py +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/mdcompose/core/import_ops.py +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/mdcompose/core/init_ops.py +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/mdcompose/core/library_ops.py +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/mdcompose/core/managed_block.py +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/mdcompose/core/manifest.py +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/mdcompose/core/output.py +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/mdcompose/core/platform.py +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/mdcompose/core/report.py +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/mdcompose/core/sections.py +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/mdcompose/core/skill_composition.py +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/mdcompose/core/skills.py +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/mdcompose/core/snippets.py +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/mdcompose/core/targets.py +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/mdcompose/prompts.py +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/mdcompose/version.py +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/renovate.json +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/scripts/check_ascii.py +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/scripts/check_licenses.py +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/tests/benchmarks/conftest.py +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/tests/benchmarks/test_core_ops.py +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/tests/conftest.py +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/tests/fixtures/mdcompose.lock.schema1.json +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/tests/fixtures/plain.bom.md +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/tests/fixtures/plain.crlf.md +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/tests/fixtures/plain.lf.md +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/tests/fixtures/prose.cp1252.md +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/tests/test_cli.py +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/tests/test_config.py +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/tests/test_config_cmd.py +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/tests/test_convert.py +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/tests/test_doctor_drift.py +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/tests/test_e2e_import_convert.py +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/tests/test_eject.py +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/tests/test_files.py +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/tests/test_fuzz.py +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/tests/test_import_cmd.py +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/tests/test_import_ops.py +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/tests/test_init.py +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/tests/test_init_ops.py +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/tests/test_layering.py +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/tests/test_managed_block.py +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/tests/test_manifest.py +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/tests/test_onedrive.py +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/tests/test_output.py +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/tests/test_platform.py +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/tests/test_prompts.py +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/tests/test_report.py +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/tests/test_sections.py +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/tests/test_skill_composition.py +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/tests/test_skills.py +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/tests/test_snippet_commands.py +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/tests/test_snippets.py +0 -0
- {mdcompose-0.2.0 → mdcompose-0.2.1}/tests/test_targets.py +0 -0
|
@@ -3,6 +3,12 @@
|
|
|
3
3
|
All notable changes to mdcompose are recorded here.
|
|
4
4
|
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
5
5
|
|
|
6
|
+
## v0.2.1 (2026-09-14)
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- add mdcompose skill command group
|
|
11
|
+
|
|
6
12
|
## v0.2.0 (2026-09-14)
|
|
7
13
|
|
|
8
14
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: mdcompose
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: Keep CLAUDE.md and AGENTS.md in sync and composed from a personal snippet library, without hand-editing every project
|
|
5
5
|
Project-URL: Homepage, https://github.com/Rovetown/mdcompose
|
|
6
6
|
Project-URL: Repository, https://github.com/Rovetown/mdcompose
|
|
@@ -58,8 +58,7 @@ mdcompose keeps those as small snippets in one place and builds each project's `
|
|
|
58
58
|
- **A committed lockfile.** `mdcompose.lock` embeds each snippet's content, so a clone or fork reproduces the same files with an empty library.
|
|
59
59
|
- **Correct paths on Windows, WSL, and Linux**, including a warning when a managed file sits inside a OneDrive-synced folder.
|
|
60
60
|
- **No install required.** `uvx mdcompose doctor` or `pipx run mdcompose doctor` run the real thing in a throwaway environment -- try it, or use it in a one-off script, without adding anything to your machine.
|
|
61
|
-
- **Skills cost tokens too,
|
|
62
|
-
A skill file -- frontmatter, a body, sometimes a script -- is already snippet-shaped, so curating which skills a project loads, the same way AGENTS.md and CLAUDE.md are curated today, is the natural next composition target: real token savings, not just tidiness.
|
|
61
|
+
- **Skills cost tokens too, so they're composed the same way.** A second, independent library curates which Claude Code Skills a project loads into `.claude/skills/`, so an agent stops paying context for skills a given project never needed.
|
|
63
62
|
See [docs/concepts.md](https://github.com/Rovetown/mdcompose/blob/main/docs/concepts.md).
|
|
64
63
|
|
|
65
64
|
It makes no network requests and collects no telemetry.
|
|
@@ -146,6 +145,10 @@ mdcompose snippet list the snippets in your library
|
|
|
146
145
|
mdcompose snippet edit <id> open one in your editor
|
|
147
146
|
mdcompose snippet remove <id> delete one
|
|
148
147
|
mdcompose snippet adopt save a project's embedded snippets to your library
|
|
148
|
+
mdcompose skill list the skills in your skill library
|
|
149
|
+
mdcompose skill edit <id> open one in your editor
|
|
150
|
+
mdcompose skill remove <id> delete one
|
|
151
|
+
mdcompose skill adopt save a project's embedded skills to your skill library
|
|
149
152
|
mdcompose import <file> pull sections out of an existing CLAUDE.md or AGENTS.md
|
|
150
153
|
mdcompose convert <src> <dst> move content between this project's AGENTS.md and CLAUDE.md
|
|
151
154
|
mdcompose eject stop managing a directory: remove markers, delete the lock
|
|
@@ -217,7 +220,7 @@ A few projects cover the same idea of a personal snippet library composed per pr
|
|
|
217
220
|
What mdcompose does that they do not: import-versus-copy tied to Claude Code's real `@import`; drift detection by hashing one owned block; a committed lock with embedded content so a fork works without the author's library; and real Windows, WSL, and mounted-drive path correctness.
|
|
218
221
|
It deliberately does no format translation: a target receives Markdown, not Cursor rules or Copilot instructions.
|
|
219
222
|
|
|
220
|
-
**None of them go past agent instructions -- mdcompose
|
|
223
|
+
**None of them go past agent instructions -- mdcompose does.** A skill file is markdown plus frontmatter, same as a snippet; a curated skill selection is composed per project into `.claude/skills/` the same way AGENTS.md and CLAUDE.md are, so an agent stops paying context for skills a given project never needed loaded in the first place.
|
|
221
224
|
|
|
222
225
|
See [docs/concepts.md](https://github.com/Rovetown/mdcompose/blob/main/docs/concepts.md) for the fuller case for each point.
|
|
223
226
|
|
|
@@ -28,8 +28,7 @@ mdcompose keeps those as small snippets in one place and builds each project's `
|
|
|
28
28
|
- **A committed lockfile.** `mdcompose.lock` embeds each snippet's content, so a clone or fork reproduces the same files with an empty library.
|
|
29
29
|
- **Correct paths on Windows, WSL, and Linux**, including a warning when a managed file sits inside a OneDrive-synced folder.
|
|
30
30
|
- **No install required.** `uvx mdcompose doctor` or `pipx run mdcompose doctor` run the real thing in a throwaway environment -- try it, or use it in a one-off script, without adding anything to your machine.
|
|
31
|
-
- **Skills cost tokens too,
|
|
32
|
-
A skill file -- frontmatter, a body, sometimes a script -- is already snippet-shaped, so curating which skills a project loads, the same way AGENTS.md and CLAUDE.md are curated today, is the natural next composition target: real token savings, not just tidiness.
|
|
31
|
+
- **Skills cost tokens too, so they're composed the same way.** A second, independent library curates which Claude Code Skills a project loads into `.claude/skills/`, so an agent stops paying context for skills a given project never needed.
|
|
33
32
|
See [docs/concepts.md](https://github.com/Rovetown/mdcompose/blob/main/docs/concepts.md).
|
|
34
33
|
|
|
35
34
|
It makes no network requests and collects no telemetry.
|
|
@@ -116,6 +115,10 @@ mdcompose snippet list the snippets in your library
|
|
|
116
115
|
mdcompose snippet edit <id> open one in your editor
|
|
117
116
|
mdcompose snippet remove <id> delete one
|
|
118
117
|
mdcompose snippet adopt save a project's embedded snippets to your library
|
|
118
|
+
mdcompose skill list the skills in your skill library
|
|
119
|
+
mdcompose skill edit <id> open one in your editor
|
|
120
|
+
mdcompose skill remove <id> delete one
|
|
121
|
+
mdcompose skill adopt save a project's embedded skills to your skill library
|
|
119
122
|
mdcompose import <file> pull sections out of an existing CLAUDE.md or AGENTS.md
|
|
120
123
|
mdcompose convert <src> <dst> move content between this project's AGENTS.md and CLAUDE.md
|
|
121
124
|
mdcompose eject stop managing a directory: remove markers, delete the lock
|
|
@@ -187,7 +190,7 @@ A few projects cover the same idea of a personal snippet library composed per pr
|
|
|
187
190
|
What mdcompose does that they do not: import-versus-copy tied to Claude Code's real `@import`; drift detection by hashing one owned block; a committed lock with embedded content so a fork works without the author's library; and real Windows, WSL, and mounted-drive path correctness.
|
|
188
191
|
It deliberately does no format translation: a target receives Markdown, not Cursor rules or Copilot instructions.
|
|
189
192
|
|
|
190
|
-
**None of them go past agent instructions -- mdcompose
|
|
193
|
+
**None of them go past agent instructions -- mdcompose does.** A skill file is markdown plus frontmatter, same as a snippet; a curated skill selection is composed per project into `.claude/skills/` the same way AGENTS.md and CLAUDE.md are, so an agent stops paying context for skills a given project never needed loaded in the first place.
|
|
191
194
|
|
|
192
195
|
See [docs/concepts.md](https://github.com/Rovetown/mdcompose/blob/main/docs/concepts.md) for the fuller case for each point.
|
|
193
196
|
|
|
@@ -353,28 +353,23 @@ and parser fuzzing. `platform.py` and the interactive questionary pickers in
|
|
|
353
353
|
`init` and `import` are the accepted remaining coverage gap; their flag paths
|
|
354
354
|
are fully covered.
|
|
355
355
|
|
|
356
|
-
### Agent skill composition (
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
becomes more than a pitch.
|
|
374
|
-
- [ ] Revisit once v1 (AGENTS.md/CLAUDE.md composition) is stable. A second
|
|
375
|
-
composed-file family is exactly the kind of expansion that has to prove it
|
|
376
|
-
reuses the existing core rather than forking it, the standing bar for
|
|
377
|
-
everything in this Roadmap section.
|
|
356
|
+
### Agent skill composition follow-ups (base feature and command group shipped)
|
|
357
|
+
|
|
358
|
+
Composing a project's skill library into `.claude/skills/<id>/SKILL.md` the
|
|
359
|
+
same way AGENTS.md/CLAUDE.md are composed is done (OpenSpec change
|
|
360
|
+
`agent-skill-composition`), and so is managing the skill library itself:
|
|
361
|
+
`skill list`, `skill edit`, `skill remove`, `skill adopt` (OpenSpec change
|
|
362
|
+
`skill-commands`), mirroring `mdcompose snippet` command-for-command. Both
|
|
363
|
+
reused the managed-block-and-manifest core rather than forking it, per the
|
|
364
|
+
standing bar for this Roadmap section. One piece was deliberately cut from
|
|
365
|
+
the base change and is the real remaining backlog:
|
|
366
|
+
|
|
367
|
+
- [ ] A skill bundled with an accompanying script. The skill library and
|
|
368
|
+
`mdcompose.lock` model shipped is single-file-per-item throughout, the same
|
|
369
|
+
as the snippet library; a script-bearing skill needs a directory-shaped
|
|
370
|
+
library entry, which is a real extension of the "library holds nothing but
|
|
371
|
+
`.md` files" invariant and deserves its own proposal rather than folding
|
|
372
|
+
into the base change.
|
|
378
373
|
|
|
379
374
|
### Third-party integrations
|
|
380
375
|
|
|
@@ -61,6 +61,8 @@ The frontmatter is regenerated from the library on every run, even when only the
|
|
|
61
61
|
Deselecting a skill deletes its file outright, and its now-empty `.claude/skills/<id>/` directory with it, rather than leaving an empty block the way AGENTS.md would: an empty `SKILL.md` means nothing to a skill-loading agent.
|
|
62
62
|
An empty or unconfigured skill library never blocks `init`, and the reverse holds too -- an empty snippet library does not stop skills from composing.
|
|
63
63
|
|
|
64
|
+
The skill library is managed the same way the snippet library is, not only by hand-editing files: `skill list`, `skill edit`, `skill remove`, and `skill adopt` mirror `snippet list`/`snippet edit`/`snippet remove`/`snippet adopt` command-for-command, including `skill adopt` pulling the skills embedded in a cloned project's manifest into your local skill library, with the same explicit, never-automatic collision handling.
|
|
65
|
+
|
|
64
66
|
## Drift detection: normalization and hashing
|
|
65
67
|
|
|
66
68
|
A managed block's hash has to mean the same thing on Windows, under WSL, and on Linux, or a committed lock file could never be trusted across a team.
|
|
@@ -935,6 +935,11 @@ No skill is shipped, generated, or seeded.
|
|
|
935
935
|
An empty skill library, or one that was never configured, never blocks `init`: AGENTS.md and CLAUDE.md compose exactly as they would without this feature.
|
|
936
936
|
The reverse holds too: an empty snippet library does not stop skills from composing.
|
|
937
937
|
|
|
938
|
+
### 22.4 The skill commands
|
|
939
|
+
|
|
940
|
+
`skill list`, `skill edit`, `skill remove`, and `skill adopt` mirror the snippet commands (section 13) exactly, over the skill library instead: same filtering, same editor and non-interactive editing path, same confirm-before-delete, same adopt-from-manifest collision handling with the same keep/overwrite choice and the same normalization-aware identical-content check.
|
|
941
|
+
Adoption reads the manifest's `skills` list (see 9.2, 9.3) the same way `snippet adopt` reads `snippets`, and is exactly as explicit: no other operation ever writes into the skill library, and a manifest with an empty `skills` list is reported as embedding nothing rather than treated as an error or as "no manifest."
|
|
942
|
+
|
|
938
943
|
## 23. Skill composition
|
|
939
944
|
|
|
940
945
|
Each skill selected in a target directory is composed into its own file at `.claude/skills/<id>/SKILL.md`, never blended into a shared block the way snippet bodies are.
|
|
@@ -971,4 +976,4 @@ Composing a skill from a manifest this machine did not write (a fresh clone) fol
|
|
|
971
976
|
## Not yet specified
|
|
972
977
|
|
|
973
978
|
Nothing.
|
|
974
|
-
Every planned behavior for v1 is implemented and described above: `doctor`, `init` and `init --global`, `snippet list`, `snippet edit`, `snippet remove`, `snippet adopt`, `import`, `convert`, `config show`, `config set`, `config unset`, `config edit`, `eject` and `eject --global`, `target add`, `target list`, `target remove`, and skill composition as part of `init` (sections 22-23).
|
|
979
|
+
Every planned behavior for v1 is implemented and described above: `doctor`, `init` and `init --global`, `snippet list`, `snippet edit`, `snippet remove`, `snippet adopt`, `skill list`, `skill edit`, `skill remove`, `skill adopt`, `import`, `convert`, `config show`, `config set`, `config unset`, `config edit`, `eject` and `eject --global`, `target add`, `target list`, `target remove`, and skill composition as part of `init` (sections 22-23).
|
|
@@ -24,6 +24,7 @@ from mdcompose.commands import (
|
|
|
24
24
|
eject_cmd,
|
|
25
25
|
import_cmd,
|
|
26
26
|
init_cmd,
|
|
27
|
+
skill_cmd,
|
|
27
28
|
snippet_cmd,
|
|
28
29
|
target_cmd,
|
|
29
30
|
)
|
|
@@ -84,6 +85,7 @@ def main(
|
|
|
84
85
|
|
|
85
86
|
|
|
86
87
|
app.add_typer(snippet_cmd.app, name="snippet")
|
|
88
|
+
app.add_typer(skill_cmd.app, name="skill")
|
|
87
89
|
app.add_typer(config_cmd.app, name="config")
|
|
88
90
|
app.add_typer(target_cmd.app, name="target")
|
|
89
91
|
init_cmd.register(app)
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
"""The skill command group: list, edit, remove, adopt.
|
|
2
|
+
|
|
3
|
+
Mirrors ``snippet_cmd.py`` command-for-command, over the skill library instead
|
|
4
|
+
of the snippet library. A thin adapter, same as its sibling: each command
|
|
5
|
+
parses arguments, resolves the skill library, calls into core, and formats
|
|
6
|
+
the result.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import shlex
|
|
14
|
+
import subprocess
|
|
15
|
+
import tempfile
|
|
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, skill_library_ops
|
|
23
|
+
from mdcompose.core import manifest as manifest_module
|
|
24
|
+
from mdcompose.core import platform as platform_module
|
|
25
|
+
from mdcompose.core import skills as skills_module
|
|
26
|
+
from mdcompose.core.exit_codes import AttentionError
|
|
27
|
+
from mdcompose.core.output import OutputContext
|
|
28
|
+
from mdcompose.prompts import confirm, output_for, prompt_choice
|
|
29
|
+
|
|
30
|
+
app = typer.Typer(
|
|
31
|
+
name="skill",
|
|
32
|
+
help="Manage the personal skill library.",
|
|
33
|
+
rich_markup_mode=None,
|
|
34
|
+
no_args_is_help=True,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
QuietOption = Annotated[
|
|
38
|
+
bool, typer.Option("--quiet", "-q", help="Suppress informational output.")
|
|
39
|
+
]
|
|
40
|
+
JsonOption = Annotated[
|
|
41
|
+
bool, typer.Option("--json", help="Emit one JSON document on stdout and nothing else.")
|
|
42
|
+
]
|
|
43
|
+
NoColourOption = Annotated[bool, typer.Option("--no-color", help="Never colourize output.")]
|
|
44
|
+
TagOption = Annotated[str | None, typer.Option("--tag", help="Only skills carrying this tag.")]
|
|
45
|
+
CategoryOption = Annotated[
|
|
46
|
+
str | None, typer.Option("--category", help="Only skills in this category.")
|
|
47
|
+
]
|
|
48
|
+
ContentOption = Annotated[
|
|
49
|
+
str | None,
|
|
50
|
+
typer.Option(
|
|
51
|
+
"--content", help="Replacement file contents. Supplying this skips the editor."
|
|
52
|
+
),
|
|
53
|
+
]
|
|
54
|
+
YesOption = Annotated[bool, typer.Option("--yes", "-y", help="Answer yes to every confirmation.")]
|
|
55
|
+
OnCollisionOption = Annotated[
|
|
56
|
+
str | None,
|
|
57
|
+
typer.Option(
|
|
58
|
+
"--on-collision",
|
|
59
|
+
help="Resolve every collision without asking: keep or overwrite.",
|
|
60
|
+
),
|
|
61
|
+
]
|
|
62
|
+
IdsArgument = Annotated[
|
|
63
|
+
list[str] | None,
|
|
64
|
+
typer.Argument(help="Skill ids to adopt. Defaults to every embedded skill."),
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def resolve_library() -> skills_module.LibraryView:
|
|
69
|
+
"""Read the configured skill library once."""
|
|
70
|
+
directory = platform_module.config_dir()
|
|
71
|
+
configuration = config_module.load_config(config_module.config_path(directory))
|
|
72
|
+
return skills_module.view(config_module.skill_library_dir(configuration, directory))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@app.command("list")
|
|
76
|
+
def list_skills(
|
|
77
|
+
context: typer.Context,
|
|
78
|
+
tag: TagOption = None,
|
|
79
|
+
category: CategoryOption = None,
|
|
80
|
+
quiet: QuietOption = False,
|
|
81
|
+
json_output: JsonOption = False,
|
|
82
|
+
no_colour: NoColourOption = False,
|
|
83
|
+
) -> None:
|
|
84
|
+
"""List the skills in the skill library.
|
|
85
|
+
|
|
86
|
+
Filters narrow rather than widen: supplying both a tag and a category
|
|
87
|
+
lists only skills matching both. An empty skill library is reported, not
|
|
88
|
+
an error.
|
|
89
|
+
"""
|
|
90
|
+
output = output_for(context, quiet=quiet, json_output=json_output, no_colour=no_colour)
|
|
91
|
+
library = resolve_library()
|
|
92
|
+
matched = skills_module.filter_skills(library.skills, tag=tag, category=category)
|
|
93
|
+
|
|
94
|
+
if output.json_mode:
|
|
95
|
+
output.emit(_as_json(library, matched))
|
|
96
|
+
return
|
|
97
|
+
|
|
98
|
+
if library.is_empty:
|
|
99
|
+
output.info(
|
|
100
|
+
f"the skill library at {library.directory.as_posix()} is empty. "
|
|
101
|
+
"Add one by writing a markdown file there, or 'mdcompose skill adopt'."
|
|
102
|
+
)
|
|
103
|
+
return
|
|
104
|
+
if not matched:
|
|
105
|
+
output.info("no skill matches that filter")
|
|
106
|
+
return
|
|
107
|
+
|
|
108
|
+
id_width = max(len(skill.id) for skill in matched)
|
|
109
|
+
name_width = max(len(skill.display_name) for skill in matched)
|
|
110
|
+
id_width = max(id_width, len("id"))
|
|
111
|
+
name_width = max(name_width, len("name"))
|
|
112
|
+
output.info(f" {'id'.ljust(id_width)} {'name'.ljust(name_width)} description")
|
|
113
|
+
for skill in matched:
|
|
114
|
+
row_id = skill.id.ljust(id_width)
|
|
115
|
+
row_name = skill.display_name.ljust(name_width)
|
|
116
|
+
description = "" if skill.description is None else skill.description
|
|
117
|
+
output.info(f" {row_id} {row_name} {description}")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _as_json(
|
|
121
|
+
library: skills_module.LibraryView, matched: tuple[skills_module.Skill, ...]
|
|
122
|
+
) -> str:
|
|
123
|
+
return json.dumps(
|
|
124
|
+
{
|
|
125
|
+
"library": library.directory.as_posix(),
|
|
126
|
+
"skills": [
|
|
127
|
+
{
|
|
128
|
+
"id": skill.id,
|
|
129
|
+
"title": skill.title,
|
|
130
|
+
"description": skill.description,
|
|
131
|
+
"tags": list(skill.tags),
|
|
132
|
+
"stack_signals": list(skill.stack_signals),
|
|
133
|
+
"category": skill.category,
|
|
134
|
+
}
|
|
135
|
+
for skill in matched
|
|
136
|
+
],
|
|
137
|
+
},
|
|
138
|
+
indent=2,
|
|
139
|
+
ensure_ascii=True,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@app.command("edit")
|
|
144
|
+
def edit_skill(
|
|
145
|
+
context: typer.Context,
|
|
146
|
+
skill_id: Annotated[str, typer.Argument(help="The skill to edit.")],
|
|
147
|
+
content: ContentOption = None,
|
|
148
|
+
quiet: QuietOption = False,
|
|
149
|
+
no_colour: NoColourOption = False,
|
|
150
|
+
) -> None:
|
|
151
|
+
"""Open a skill in the configured editor, or replace its contents directly.
|
|
152
|
+
|
|
153
|
+
The result is validated before it is installed, so a broken edit leaves
|
|
154
|
+
the existing skill exactly as it was.
|
|
155
|
+
"""
|
|
156
|
+
output = output_for(context, quiet=quiet, no_colour=no_colour)
|
|
157
|
+
library = resolve_library()
|
|
158
|
+
skill = library.require(skill_id)
|
|
159
|
+
|
|
160
|
+
if content is not None:
|
|
161
|
+
skill_library_ops.replace_body(library, skill_id, content)
|
|
162
|
+
output.info(f"wrote {skill_id}")
|
|
163
|
+
return
|
|
164
|
+
|
|
165
|
+
if skill.path is None:
|
|
166
|
+
raise AttentionError(f"skill '{skill_id}' has no file on disk to edit")
|
|
167
|
+
edited = _edit_in_editor(skill.path)
|
|
168
|
+
if edited is None:
|
|
169
|
+
output.info(f"{skill_id} unchanged")
|
|
170
|
+
return
|
|
171
|
+
skill_library_ops.replace_body(library, skill_id, edited)
|
|
172
|
+
output.info(f"wrote {skill_id}")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _edit_in_editor(path: Path) -> str | None:
|
|
176
|
+
"""Open a copy in the editor and return the result, or None if unchanged.
|
|
177
|
+
|
|
178
|
+
A copy rather than the file itself, so an edit that fails validation
|
|
179
|
+
cannot leave the library holding it.
|
|
180
|
+
"""
|
|
181
|
+
editor = os.environ.get("VISUAL") or os.environ.get("EDITOR")
|
|
182
|
+
if not editor:
|
|
183
|
+
raise AttentionError(
|
|
184
|
+
"no editor configured. Set VISUAL or EDITOR, or pass --content to "
|
|
185
|
+
"supply the new contents directly."
|
|
186
|
+
)
|
|
187
|
+
original = files.read_text(path)
|
|
188
|
+
with tempfile.TemporaryDirectory() as scratch:
|
|
189
|
+
draft = Path(scratch) / path.name
|
|
190
|
+
files.write_text(draft, original)
|
|
191
|
+
completed = subprocess.run([*shlex.split(editor), str(draft)], check=False)
|
|
192
|
+
if completed.returncode != 0:
|
|
193
|
+
raise AttentionError(f"editor exited with status {completed.returncode}")
|
|
194
|
+
edited = files.read_text(draft)
|
|
195
|
+
return None if files.content_equal(edited, original) else edited
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@app.command("remove")
|
|
199
|
+
def remove_skill(
|
|
200
|
+
context: typer.Context,
|
|
201
|
+
skill_id: Annotated[str, typer.Argument(help="The skill to delete.")],
|
|
202
|
+
yes: YesOption = False,
|
|
203
|
+
quiet: QuietOption = False,
|
|
204
|
+
no_colour: NoColourOption = False,
|
|
205
|
+
) -> None:
|
|
206
|
+
"""Delete a skill from the skill library.
|
|
207
|
+
|
|
208
|
+
Confirms first. Never touches a project that already composed it:
|
|
209
|
+
composition copies content, so a composed file keeps working without its
|
|
210
|
+
source skill.
|
|
211
|
+
"""
|
|
212
|
+
output = output_for(context, quiet=quiet, no_colour=no_colour)
|
|
213
|
+
library = resolve_library()
|
|
214
|
+
library.require(skill_id)
|
|
215
|
+
|
|
216
|
+
if not confirm(output, f"delete skill '{skill_id}' from the skill library?", assume_yes=yes):
|
|
217
|
+
output.info(f"kept {skill_id}")
|
|
218
|
+
return
|
|
219
|
+
skill_library_ops.remove_skill(library, skill_id)
|
|
220
|
+
output.info(f"removed {skill_id}")
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@app.command("adopt")
|
|
224
|
+
def adopt_skills(
|
|
225
|
+
context: typer.Context,
|
|
226
|
+
ids: IdsArgument = None,
|
|
227
|
+
on_collision: OnCollisionOption = None,
|
|
228
|
+
quiet: QuietOption = False,
|
|
229
|
+
no_colour: NoColourOption = False,
|
|
230
|
+
) -> None:
|
|
231
|
+
"""Save the skills embedded in this project's manifest into the skill library.
|
|
232
|
+
|
|
233
|
+
Always explicit, never a side effect of composing, because pulling
|
|
234
|
+
someone else's skills into a personal library should be a deliberate act.
|
|
235
|
+
Touches no project file and no manifest.
|
|
236
|
+
"""
|
|
237
|
+
output = output_for(context, quiet=quiet, no_colour=no_colour)
|
|
238
|
+
project_root = Path.cwd()
|
|
239
|
+
manifest = manifest_module.load_manifest(manifest_module.manifest_path(project_root))
|
|
240
|
+
if manifest is None:
|
|
241
|
+
raise AttentionError(
|
|
242
|
+
f"{project_root.as_posix()} has no {manifest_module.MANIFEST_FILENAME}, "
|
|
243
|
+
"so there is nothing to adopt"
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
library = resolve_library()
|
|
247
|
+
plan = skill_library_ops.plan_adopt(manifest, library, only=tuple(ids or ()))
|
|
248
|
+
if plan.is_empty:
|
|
249
|
+
output.info("the manifest embeds no skills")
|
|
250
|
+
return
|
|
251
|
+
|
|
252
|
+
resolutions = _resolve_collisions(output, plan, on_collision)
|
|
253
|
+
result = skill_library_ops.apply_adopt(plan, library.directory, resolutions=resolutions)
|
|
254
|
+
_report_adoption(output, result)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _resolve_collisions(
|
|
258
|
+
output: OutputContext,
|
|
259
|
+
plan: skill_library_ops.AdoptPlan,
|
|
260
|
+
on_collision: str | None,
|
|
261
|
+
) -> dict[str, skill_library_ops.Resolution]:
|
|
262
|
+
"""Decide what to do about each collision, by flag or by asking."""
|
|
263
|
+
if not plan.collisions:
|
|
264
|
+
return {}
|
|
265
|
+
if on_collision is not None:
|
|
266
|
+
decision = _validated_resolution(on_collision)
|
|
267
|
+
return {collision.skill_id: decision for collision in plan.collisions}
|
|
268
|
+
|
|
269
|
+
resolutions: dict[str, skill_library_ops.Resolution] = {}
|
|
270
|
+
for collision in plan.collisions:
|
|
271
|
+
output.warn(
|
|
272
|
+
f"skill '{collision.skill_id}' already exists in the skill library with "
|
|
273
|
+
"different content"
|
|
274
|
+
)
|
|
275
|
+
_show_difference(output, collision)
|
|
276
|
+
answer = prompt_choice(
|
|
277
|
+
output,
|
|
278
|
+
f"skill '{collision.skill_id}'",
|
|
279
|
+
options=skill_library_ops.RESOLUTIONS,
|
|
280
|
+
default=skill_library_ops.KEEP,
|
|
281
|
+
flag="--on-collision",
|
|
282
|
+
)
|
|
283
|
+
resolutions[collision.skill_id] = answer # type: ignore[assignment]
|
|
284
|
+
return resolutions
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _validated_resolution(candidate: str) -> skill_library_ops.Resolution:
|
|
288
|
+
if candidate not in skill_library_ops.RESOLUTIONS:
|
|
289
|
+
raise AttentionError(
|
|
290
|
+
f"--on-collision must be one of {', '.join(skill_library_ops.RESOLUTIONS)}, "
|
|
291
|
+
f"found '{candidate}'"
|
|
292
|
+
)
|
|
293
|
+
return candidate
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _show_difference(output: OutputContext, collision: skill_library_ops.Collision) -> None:
|
|
297
|
+
"""Show both versions, so the choice is made with the content in view."""
|
|
298
|
+
output.info(f" skill library copy of '{collision.skill_id}':")
|
|
299
|
+
output.content_indented(collision.local)
|
|
300
|
+
output.info(f" embedded copy of '{collision.skill_id}':")
|
|
301
|
+
output.content_indented(collision.embedded)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _report_adoption(output: OutputContext, result: skill_library_ops.AdoptResult) -> None:
|
|
305
|
+
for outcome, phrasing in (
|
|
306
|
+
("written", "adopted"),
|
|
307
|
+
("overwritten", "overwrote"),
|
|
308
|
+
("already-present", "already present"),
|
|
309
|
+
("kept", "kept the skill library copy of"),
|
|
310
|
+
):
|
|
311
|
+
named = result.ids_with(outcome) # type: ignore[arg-type]
|
|
312
|
+
if named:
|
|
313
|
+
output.info(f" {phrasing}: {', '.join(named)}")
|