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.
Files changed (110) hide show
  1. {mdcompose-0.2.0 → mdcompose-0.2.1}/CHANGELOG.md +6 -0
  2. {mdcompose-0.2.0 → mdcompose-0.2.1}/PKG-INFO +7 -4
  3. {mdcompose-0.2.0 → mdcompose-0.2.1}/README.md +6 -3
  4. {mdcompose-0.2.0 → mdcompose-0.2.1}/TODO.md +17 -22
  5. {mdcompose-0.2.0 → mdcompose-0.2.1}/docs/concepts.md +2 -0
  6. {mdcompose-0.2.0 → mdcompose-0.2.1}/docs/core-contract.md +6 -1
  7. {mdcompose-0.2.0 → mdcompose-0.2.1}/mdcompose/cli.py +2 -0
  8. mdcompose-0.2.1/mdcompose/commands/skill_cmd.py +313 -0
  9. mdcompose-0.2.1/mdcompose/core/skill_library_ops.py +180 -0
  10. {mdcompose-0.2.0 → mdcompose-0.2.1}/pyproject.toml +1 -1
  11. mdcompose-0.2.1/tests/test_skill_commands.py +483 -0
  12. {mdcompose-0.2.0 → mdcompose-0.2.1}/uv.lock +1 -1
  13. {mdcompose-0.2.0 → mdcompose-0.2.1}/.gitattributes +0 -0
  14. {mdcompose-0.2.0 → mdcompose-0.2.1}/.github/FUNDING.yml +0 -0
  15. {mdcompose-0.2.0 → mdcompose-0.2.1}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  16. {mdcompose-0.2.0 → mdcompose-0.2.1}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  17. {mdcompose-0.2.0 → mdcompose-0.2.1}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  18. {mdcompose-0.2.0 → mdcompose-0.2.1}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  19. {mdcompose-0.2.0 → mdcompose-0.2.1}/.github/workflows/bump.yml +0 -0
  20. {mdcompose-0.2.0 → mdcompose-0.2.1}/.github/workflows/ci.yml +0 -0
  21. {mdcompose-0.2.0 → mdcompose-0.2.1}/.github/workflows/codeql.yml +0 -0
  22. {mdcompose-0.2.0 → mdcompose-0.2.1}/.github/workflows/dependency-review.yml +0 -0
  23. {mdcompose-0.2.0 → mdcompose-0.2.1}/.github/workflows/mutation.yml +0 -0
  24. {mdcompose-0.2.0 → mdcompose-0.2.1}/.github/workflows/python-eol.yml +0 -0
  25. {mdcompose-0.2.0 → mdcompose-0.2.1}/.github/workflows/release.yml +0 -0
  26. {mdcompose-0.2.0 → mdcompose-0.2.1}/.github/workflows/scorecard.yml +0 -0
  27. {mdcompose-0.2.0 → mdcompose-0.2.1}/.github/workflows/supply-chain.yml +0 -0
  28. {mdcompose-0.2.0 → mdcompose-0.2.1}/.github/zizmor.yml +0 -0
  29. {mdcompose-0.2.0 → mdcompose-0.2.1}/.gitignore +0 -0
  30. {mdcompose-0.2.0 → mdcompose-0.2.1}/.pre-commit-config.yaml +0 -0
  31. {mdcompose-0.2.0 → mdcompose-0.2.1}/CODE_OF_CONDUCT.md +0 -0
  32. {mdcompose-0.2.0 → mdcompose-0.2.1}/CONTRIBUTING.md +0 -0
  33. {mdcompose-0.2.0 → mdcompose-0.2.1}/LICENSE +0 -0
  34. {mdcompose-0.2.0 → mdcompose-0.2.1}/SECURITY.md +0 -0
  35. {mdcompose-0.2.0 → mdcompose-0.2.1}/docs/benchmarks.md +0 -0
  36. {mdcompose-0.2.0 → mdcompose-0.2.1}/docs/commit-and-release-tooling.md +0 -0
  37. {mdcompose-0.2.0 → mdcompose-0.2.1}/docs/hardening-review.md +0 -0
  38. {mdcompose-0.2.0 → mdcompose-0.2.1}/docs/optional-tooling.md +0 -0
  39. {mdcompose-0.2.0 → mdcompose-0.2.1}/docs/publishing.md +0 -0
  40. {mdcompose-0.2.0 → mdcompose-0.2.1}/docs/scorecard.md +0 -0
  41. {mdcompose-0.2.0 → mdcompose-0.2.1}/docs/versioning-explained.md +0 -0
  42. {mdcompose-0.2.0 → mdcompose-0.2.1}/mdcompose/__init__.py +0 -0
  43. {mdcompose-0.2.0 → mdcompose-0.2.1}/mdcompose/commands/__init__.py +0 -0
  44. {mdcompose-0.2.0 → mdcompose-0.2.1}/mdcompose/commands/config_cmd.py +0 -0
  45. {mdcompose-0.2.0 → mdcompose-0.2.1}/mdcompose/commands/convert_cmd.py +0 -0
  46. {mdcompose-0.2.0 → mdcompose-0.2.1}/mdcompose/commands/eject_cmd.py +0 -0
  47. {mdcompose-0.2.0 → mdcompose-0.2.1}/mdcompose/commands/import_cmd.py +0 -0
  48. {mdcompose-0.2.0 → mdcompose-0.2.1}/mdcompose/commands/init_cmd.py +0 -0
  49. {mdcompose-0.2.0 → mdcompose-0.2.1}/mdcompose/commands/snippet_cmd.py +0 -0
  50. {mdcompose-0.2.0 → mdcompose-0.2.1}/mdcompose/commands/target_cmd.py +0 -0
  51. {mdcompose-0.2.0 → mdcompose-0.2.1}/mdcompose/core/__init__.py +0 -0
  52. {mdcompose-0.2.0 → mdcompose-0.2.1}/mdcompose/core/composition.py +0 -0
  53. {mdcompose-0.2.0 → mdcompose-0.2.1}/mdcompose/core/config.py +0 -0
  54. {mdcompose-0.2.0 → mdcompose-0.2.1}/mdcompose/core/convert_ops.py +0 -0
  55. {mdcompose-0.2.0 → mdcompose-0.2.1}/mdcompose/core/eject.py +0 -0
  56. {mdcompose-0.2.0 → mdcompose-0.2.1}/mdcompose/core/exit_codes.py +0 -0
  57. {mdcompose-0.2.0 → mdcompose-0.2.1}/mdcompose/core/files.py +0 -0
  58. {mdcompose-0.2.0 → mdcompose-0.2.1}/mdcompose/core/import_ops.py +0 -0
  59. {mdcompose-0.2.0 → mdcompose-0.2.1}/mdcompose/core/init_ops.py +0 -0
  60. {mdcompose-0.2.0 → mdcompose-0.2.1}/mdcompose/core/library_ops.py +0 -0
  61. {mdcompose-0.2.0 → mdcompose-0.2.1}/mdcompose/core/managed_block.py +0 -0
  62. {mdcompose-0.2.0 → mdcompose-0.2.1}/mdcompose/core/manifest.py +0 -0
  63. {mdcompose-0.2.0 → mdcompose-0.2.1}/mdcompose/core/output.py +0 -0
  64. {mdcompose-0.2.0 → mdcompose-0.2.1}/mdcompose/core/platform.py +0 -0
  65. {mdcompose-0.2.0 → mdcompose-0.2.1}/mdcompose/core/report.py +0 -0
  66. {mdcompose-0.2.0 → mdcompose-0.2.1}/mdcompose/core/sections.py +0 -0
  67. {mdcompose-0.2.0 → mdcompose-0.2.1}/mdcompose/core/skill_composition.py +0 -0
  68. {mdcompose-0.2.0 → mdcompose-0.2.1}/mdcompose/core/skills.py +0 -0
  69. {mdcompose-0.2.0 → mdcompose-0.2.1}/mdcompose/core/snippets.py +0 -0
  70. {mdcompose-0.2.0 → mdcompose-0.2.1}/mdcompose/core/targets.py +0 -0
  71. {mdcompose-0.2.0 → mdcompose-0.2.1}/mdcompose/prompts.py +0 -0
  72. {mdcompose-0.2.0 → mdcompose-0.2.1}/mdcompose/version.py +0 -0
  73. {mdcompose-0.2.0 → mdcompose-0.2.1}/renovate.json +0 -0
  74. {mdcompose-0.2.0 → mdcompose-0.2.1}/scripts/check_ascii.py +0 -0
  75. {mdcompose-0.2.0 → mdcompose-0.2.1}/scripts/check_licenses.py +0 -0
  76. {mdcompose-0.2.0 → mdcompose-0.2.1}/tests/benchmarks/conftest.py +0 -0
  77. {mdcompose-0.2.0 → mdcompose-0.2.1}/tests/benchmarks/test_core_ops.py +0 -0
  78. {mdcompose-0.2.0 → mdcompose-0.2.1}/tests/conftest.py +0 -0
  79. {mdcompose-0.2.0 → mdcompose-0.2.1}/tests/fixtures/mdcompose.lock.schema1.json +0 -0
  80. {mdcompose-0.2.0 → mdcompose-0.2.1}/tests/fixtures/plain.bom.md +0 -0
  81. {mdcompose-0.2.0 → mdcompose-0.2.1}/tests/fixtures/plain.crlf.md +0 -0
  82. {mdcompose-0.2.0 → mdcompose-0.2.1}/tests/fixtures/plain.lf.md +0 -0
  83. {mdcompose-0.2.0 → mdcompose-0.2.1}/tests/fixtures/prose.cp1252.md +0 -0
  84. {mdcompose-0.2.0 → mdcompose-0.2.1}/tests/test_cli.py +0 -0
  85. {mdcompose-0.2.0 → mdcompose-0.2.1}/tests/test_config.py +0 -0
  86. {mdcompose-0.2.0 → mdcompose-0.2.1}/tests/test_config_cmd.py +0 -0
  87. {mdcompose-0.2.0 → mdcompose-0.2.1}/tests/test_convert.py +0 -0
  88. {mdcompose-0.2.0 → mdcompose-0.2.1}/tests/test_doctor_drift.py +0 -0
  89. {mdcompose-0.2.0 → mdcompose-0.2.1}/tests/test_e2e_import_convert.py +0 -0
  90. {mdcompose-0.2.0 → mdcompose-0.2.1}/tests/test_eject.py +0 -0
  91. {mdcompose-0.2.0 → mdcompose-0.2.1}/tests/test_files.py +0 -0
  92. {mdcompose-0.2.0 → mdcompose-0.2.1}/tests/test_fuzz.py +0 -0
  93. {mdcompose-0.2.0 → mdcompose-0.2.1}/tests/test_import_cmd.py +0 -0
  94. {mdcompose-0.2.0 → mdcompose-0.2.1}/tests/test_import_ops.py +0 -0
  95. {mdcompose-0.2.0 → mdcompose-0.2.1}/tests/test_init.py +0 -0
  96. {mdcompose-0.2.0 → mdcompose-0.2.1}/tests/test_init_ops.py +0 -0
  97. {mdcompose-0.2.0 → mdcompose-0.2.1}/tests/test_layering.py +0 -0
  98. {mdcompose-0.2.0 → mdcompose-0.2.1}/tests/test_managed_block.py +0 -0
  99. {mdcompose-0.2.0 → mdcompose-0.2.1}/tests/test_manifest.py +0 -0
  100. {mdcompose-0.2.0 → mdcompose-0.2.1}/tests/test_onedrive.py +0 -0
  101. {mdcompose-0.2.0 → mdcompose-0.2.1}/tests/test_output.py +0 -0
  102. {mdcompose-0.2.0 → mdcompose-0.2.1}/tests/test_platform.py +0 -0
  103. {mdcompose-0.2.0 → mdcompose-0.2.1}/tests/test_prompts.py +0 -0
  104. {mdcompose-0.2.0 → mdcompose-0.2.1}/tests/test_report.py +0 -0
  105. {mdcompose-0.2.0 → mdcompose-0.2.1}/tests/test_sections.py +0 -0
  106. {mdcompose-0.2.0 → mdcompose-0.2.1}/tests/test_skill_composition.py +0 -0
  107. {mdcompose-0.2.0 → mdcompose-0.2.1}/tests/test_skills.py +0 -0
  108. {mdcompose-0.2.0 → mdcompose-0.2.1}/tests/test_snippet_commands.py +0 -0
  109. {mdcompose-0.2.0 → mdcompose-0.2.1}/tests/test_snippets.py +0 -0
  110. {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.0
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, and that's next (planned).** Every skill an agent does not need loaded for a project is context spent before it has done any work.
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 plans to.** A skill file is markdown plus optional scripts, same as a snippet; composing a curated skill selection per project the way AGENTS.md and CLAUDE.md are composed today means an agent stops paying context for skills a given project never needed loaded in the first place.
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, and that's next (planned).** Every skill an agent does not need loaded for a project is context spent before it has done any work.
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 plans to.** A skill file is markdown plus optional scripts, same as a snippet; composing a curated skill selection per project the way AGENTS.md and CLAUDE.md are composed today means an agent stops paying context for skills a given project never needed loaded in the first place.
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 (Claude Code Skills and equivalents)
357
-
358
- Not scoped. Raised during the README rewrite as a marketing point worth
359
- having a real backing plan for, not yet an OpenSpec change.
360
-
361
- - [ ] Explore composing a project's agent-skill files the same way AGENTS.md
362
- and CLAUDE.md are composed today: a personal library of reusable skills,
363
- picked per project instead of an agent loading every skill unconditionally.
364
- A skill (a `SKILL.md`-shaped file: frontmatter plus a body, occasionally a
365
- script alongside it) is already close enough to a snippet in shape that the
366
- existing managed-block-and-manifest machinery may extend to it directly
367
- rather than needing a second mechanism.
368
- - [ ] The actual argument for doing this, not just the organizational one:
369
- every skill an agent does not need loaded for a given project is context it
370
- never has to spend, so a curated per-project skill selection is a
371
- token-efficiency win. That is the pitch in the README and
372
- [`docs/concepts.md`](docs/concepts.md); it has to survive contact with a real design before it
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)}")