mdcompose 0.2.1__tar.gz → 0.3.0__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.1 → mdcompose-0.3.0}/.pre-commit-config.yaml +3 -3
  2. {mdcompose-0.2.1 → mdcompose-0.3.0}/CHANGELOG.md +6 -0
  3. {mdcompose-0.2.1 → mdcompose-0.3.0}/PKG-INFO +1 -1
  4. {mdcompose-0.2.1 → mdcompose-0.3.0}/TODO.md +16 -10
  5. {mdcompose-0.2.1 → mdcompose-0.3.0}/docs/core-contract.md +25 -11
  6. {mdcompose-0.2.1 → mdcompose-0.3.0}/mdcompose/commands/init_cmd.py +13 -3
  7. {mdcompose-0.2.1 → mdcompose-0.3.0}/mdcompose/commands/skill_cmd.py +47 -14
  8. {mdcompose-0.2.1 → mdcompose-0.3.0}/mdcompose/core/init_ops.py +109 -34
  9. {mdcompose-0.2.1 → mdcompose-0.3.0}/mdcompose/core/manifest.py +51 -11
  10. {mdcompose-0.2.1 → mdcompose-0.3.0}/mdcompose/core/skill_composition.py +18 -0
  11. {mdcompose-0.2.1 → mdcompose-0.3.0}/mdcompose/core/skill_library_ops.py +97 -24
  12. {mdcompose-0.2.1 → mdcompose-0.3.0}/mdcompose/core/skills.py +110 -19
  13. {mdcompose-0.2.1 → mdcompose-0.3.0}/pyproject.toml +1 -1
  14. {mdcompose-0.2.1 → mdcompose-0.3.0}/tests/test_init.py +66 -0
  15. {mdcompose-0.2.1 → mdcompose-0.3.0}/tests/test_init_ops.py +285 -0
  16. {mdcompose-0.2.1 → mdcompose-0.3.0}/tests/test_manifest.py +140 -0
  17. {mdcompose-0.2.1 → mdcompose-0.3.0}/tests/test_skill_commands.py +162 -0
  18. {mdcompose-0.2.1 → mdcompose-0.3.0}/tests/test_skill_composition.py +14 -0
  19. {mdcompose-0.2.1 → mdcompose-0.3.0}/tests/test_skills.py +114 -0
  20. {mdcompose-0.2.1 → mdcompose-0.3.0}/uv.lock +1 -1
  21. {mdcompose-0.2.1 → mdcompose-0.3.0}/.gitattributes +0 -0
  22. {mdcompose-0.2.1 → mdcompose-0.3.0}/.github/FUNDING.yml +0 -0
  23. {mdcompose-0.2.1 → mdcompose-0.3.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  24. {mdcompose-0.2.1 → mdcompose-0.3.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  25. {mdcompose-0.2.1 → mdcompose-0.3.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  26. {mdcompose-0.2.1 → mdcompose-0.3.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  27. {mdcompose-0.2.1 → mdcompose-0.3.0}/.github/workflows/bump.yml +0 -0
  28. {mdcompose-0.2.1 → mdcompose-0.3.0}/.github/workflows/ci.yml +0 -0
  29. {mdcompose-0.2.1 → mdcompose-0.3.0}/.github/workflows/codeql.yml +0 -0
  30. {mdcompose-0.2.1 → mdcompose-0.3.0}/.github/workflows/dependency-review.yml +0 -0
  31. {mdcompose-0.2.1 → mdcompose-0.3.0}/.github/workflows/mutation.yml +0 -0
  32. {mdcompose-0.2.1 → mdcompose-0.3.0}/.github/workflows/python-eol.yml +0 -0
  33. {mdcompose-0.2.1 → mdcompose-0.3.0}/.github/workflows/release.yml +0 -0
  34. {mdcompose-0.2.1 → mdcompose-0.3.0}/.github/workflows/scorecard.yml +0 -0
  35. {mdcompose-0.2.1 → mdcompose-0.3.0}/.github/workflows/supply-chain.yml +0 -0
  36. {mdcompose-0.2.1 → mdcompose-0.3.0}/.github/zizmor.yml +0 -0
  37. {mdcompose-0.2.1 → mdcompose-0.3.0}/.gitignore +0 -0
  38. {mdcompose-0.2.1 → mdcompose-0.3.0}/CODE_OF_CONDUCT.md +0 -0
  39. {mdcompose-0.2.1 → mdcompose-0.3.0}/CONTRIBUTING.md +0 -0
  40. {mdcompose-0.2.1 → mdcompose-0.3.0}/LICENSE +0 -0
  41. {mdcompose-0.2.1 → mdcompose-0.3.0}/README.md +0 -0
  42. {mdcompose-0.2.1 → mdcompose-0.3.0}/SECURITY.md +0 -0
  43. {mdcompose-0.2.1 → mdcompose-0.3.0}/docs/benchmarks.md +0 -0
  44. {mdcompose-0.2.1 → mdcompose-0.3.0}/docs/commit-and-release-tooling.md +0 -0
  45. {mdcompose-0.2.1 → mdcompose-0.3.0}/docs/concepts.md +0 -0
  46. {mdcompose-0.2.1 → mdcompose-0.3.0}/docs/hardening-review.md +0 -0
  47. {mdcompose-0.2.1 → mdcompose-0.3.0}/docs/optional-tooling.md +0 -0
  48. {mdcompose-0.2.1 → mdcompose-0.3.0}/docs/publishing.md +0 -0
  49. {mdcompose-0.2.1 → mdcompose-0.3.0}/docs/scorecard.md +0 -0
  50. {mdcompose-0.2.1 → mdcompose-0.3.0}/docs/versioning-explained.md +0 -0
  51. {mdcompose-0.2.1 → mdcompose-0.3.0}/mdcompose/__init__.py +0 -0
  52. {mdcompose-0.2.1 → mdcompose-0.3.0}/mdcompose/cli.py +0 -0
  53. {mdcompose-0.2.1 → mdcompose-0.3.0}/mdcompose/commands/__init__.py +0 -0
  54. {mdcompose-0.2.1 → mdcompose-0.3.0}/mdcompose/commands/config_cmd.py +0 -0
  55. {mdcompose-0.2.1 → mdcompose-0.3.0}/mdcompose/commands/convert_cmd.py +0 -0
  56. {mdcompose-0.2.1 → mdcompose-0.3.0}/mdcompose/commands/eject_cmd.py +0 -0
  57. {mdcompose-0.2.1 → mdcompose-0.3.0}/mdcompose/commands/import_cmd.py +0 -0
  58. {mdcompose-0.2.1 → mdcompose-0.3.0}/mdcompose/commands/snippet_cmd.py +0 -0
  59. {mdcompose-0.2.1 → mdcompose-0.3.0}/mdcompose/commands/target_cmd.py +0 -0
  60. {mdcompose-0.2.1 → mdcompose-0.3.0}/mdcompose/core/__init__.py +0 -0
  61. {mdcompose-0.2.1 → mdcompose-0.3.0}/mdcompose/core/composition.py +0 -0
  62. {mdcompose-0.2.1 → mdcompose-0.3.0}/mdcompose/core/config.py +0 -0
  63. {mdcompose-0.2.1 → mdcompose-0.3.0}/mdcompose/core/convert_ops.py +0 -0
  64. {mdcompose-0.2.1 → mdcompose-0.3.0}/mdcompose/core/eject.py +0 -0
  65. {mdcompose-0.2.1 → mdcompose-0.3.0}/mdcompose/core/exit_codes.py +0 -0
  66. {mdcompose-0.2.1 → mdcompose-0.3.0}/mdcompose/core/files.py +0 -0
  67. {mdcompose-0.2.1 → mdcompose-0.3.0}/mdcompose/core/import_ops.py +0 -0
  68. {mdcompose-0.2.1 → mdcompose-0.3.0}/mdcompose/core/library_ops.py +0 -0
  69. {mdcompose-0.2.1 → mdcompose-0.3.0}/mdcompose/core/managed_block.py +0 -0
  70. {mdcompose-0.2.1 → mdcompose-0.3.0}/mdcompose/core/output.py +0 -0
  71. {mdcompose-0.2.1 → mdcompose-0.3.0}/mdcompose/core/platform.py +0 -0
  72. {mdcompose-0.2.1 → mdcompose-0.3.0}/mdcompose/core/report.py +0 -0
  73. {mdcompose-0.2.1 → mdcompose-0.3.0}/mdcompose/core/sections.py +0 -0
  74. {mdcompose-0.2.1 → mdcompose-0.3.0}/mdcompose/core/snippets.py +0 -0
  75. {mdcompose-0.2.1 → mdcompose-0.3.0}/mdcompose/core/targets.py +0 -0
  76. {mdcompose-0.2.1 → mdcompose-0.3.0}/mdcompose/prompts.py +0 -0
  77. {mdcompose-0.2.1 → mdcompose-0.3.0}/mdcompose/version.py +0 -0
  78. {mdcompose-0.2.1 → mdcompose-0.3.0}/renovate.json +0 -0
  79. {mdcompose-0.2.1 → mdcompose-0.3.0}/scripts/check_ascii.py +0 -0
  80. {mdcompose-0.2.1 → mdcompose-0.3.0}/scripts/check_licenses.py +0 -0
  81. {mdcompose-0.2.1 → mdcompose-0.3.0}/tests/benchmarks/conftest.py +0 -0
  82. {mdcompose-0.2.1 → mdcompose-0.3.0}/tests/benchmarks/test_core_ops.py +0 -0
  83. {mdcompose-0.2.1 → mdcompose-0.3.0}/tests/conftest.py +0 -0
  84. {mdcompose-0.2.1 → mdcompose-0.3.0}/tests/fixtures/mdcompose.lock.schema1.json +0 -0
  85. {mdcompose-0.2.1 → mdcompose-0.3.0}/tests/fixtures/plain.bom.md +0 -0
  86. {mdcompose-0.2.1 → mdcompose-0.3.0}/tests/fixtures/plain.crlf.md +0 -0
  87. {mdcompose-0.2.1 → mdcompose-0.3.0}/tests/fixtures/plain.lf.md +0 -0
  88. {mdcompose-0.2.1 → mdcompose-0.3.0}/tests/fixtures/prose.cp1252.md +0 -0
  89. {mdcompose-0.2.1 → mdcompose-0.3.0}/tests/test_cli.py +0 -0
  90. {mdcompose-0.2.1 → mdcompose-0.3.0}/tests/test_config.py +0 -0
  91. {mdcompose-0.2.1 → mdcompose-0.3.0}/tests/test_config_cmd.py +0 -0
  92. {mdcompose-0.2.1 → mdcompose-0.3.0}/tests/test_convert.py +0 -0
  93. {mdcompose-0.2.1 → mdcompose-0.3.0}/tests/test_doctor_drift.py +0 -0
  94. {mdcompose-0.2.1 → mdcompose-0.3.0}/tests/test_e2e_import_convert.py +0 -0
  95. {mdcompose-0.2.1 → mdcompose-0.3.0}/tests/test_eject.py +0 -0
  96. {mdcompose-0.2.1 → mdcompose-0.3.0}/tests/test_files.py +0 -0
  97. {mdcompose-0.2.1 → mdcompose-0.3.0}/tests/test_fuzz.py +0 -0
  98. {mdcompose-0.2.1 → mdcompose-0.3.0}/tests/test_import_cmd.py +0 -0
  99. {mdcompose-0.2.1 → mdcompose-0.3.0}/tests/test_import_ops.py +0 -0
  100. {mdcompose-0.2.1 → mdcompose-0.3.0}/tests/test_layering.py +0 -0
  101. {mdcompose-0.2.1 → mdcompose-0.3.0}/tests/test_managed_block.py +0 -0
  102. {mdcompose-0.2.1 → mdcompose-0.3.0}/tests/test_onedrive.py +0 -0
  103. {mdcompose-0.2.1 → mdcompose-0.3.0}/tests/test_output.py +0 -0
  104. {mdcompose-0.2.1 → mdcompose-0.3.0}/tests/test_platform.py +0 -0
  105. {mdcompose-0.2.1 → mdcompose-0.3.0}/tests/test_prompts.py +0 -0
  106. {mdcompose-0.2.1 → mdcompose-0.3.0}/tests/test_report.py +0 -0
  107. {mdcompose-0.2.1 → mdcompose-0.3.0}/tests/test_sections.py +0 -0
  108. {mdcompose-0.2.1 → mdcompose-0.3.0}/tests/test_snippet_commands.py +0 -0
  109. {mdcompose-0.2.1 → mdcompose-0.3.0}/tests/test_snippets.py +0 -0
  110. {mdcompose-0.2.1 → mdcompose-0.3.0}/tests/test_targets.py +0 -0
@@ -37,13 +37,13 @@ repos:
37
37
  - id: check-merge-conflict
38
38
 
39
39
  - repo: https://github.com/astral-sh/ruff-pre-commit
40
- rev: v0.16.6
40
+ rev: v0.16.7
41
41
  hooks:
42
42
  - id: ruff-check
43
43
  args: [--fix]
44
44
 
45
45
  - repo: https://github.com/gitleaks/gitleaks
46
- rev: v8.30.1
46
+ rev: v8.30.0
47
47
  hooks:
48
48
  - id: gitleaks
49
49
 
@@ -63,7 +63,7 @@ repos:
63
63
  - id: check-github-workflows
64
64
 
65
65
  - repo: https://github.com/commitizen-tools/commitizen
66
- rev: v4.18.0
66
+ rev: v4.18.1
67
67
  hooks:
68
68
  - id: commitizen
69
69
  stages: [commit-msg]
@@ -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.3.0 (2026-09-18)
7
+
8
+ ### Added
9
+
10
+ - support script-bearing skills as directory entries (#17)
11
+
6
12
  ## v0.2.1 (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.1
3
+ Version: 0.3.0
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
@@ -353,7 +353,7 @@ 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 follow-ups (base feature and command group shipped)
356
+ ### Agent skill composition (shipped)
357
357
 
358
358
  Composing a project's skill library into `.claude/skills/<id>/SKILL.md` the
359
359
  same way AGENTS.md/CLAUDE.md are composed is done (OpenSpec change
@@ -361,15 +361,21 @@ same way AGENTS.md/CLAUDE.md are composed is done (OpenSpec change
361
361
  `skill list`, `skill edit`, `skill remove`, `skill adopt` (OpenSpec change
362
362
  `skill-commands`), mirroring `mdcompose snippet` command-for-command. Both
363
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.
364
+ standing bar for this Roadmap section.
365
+
366
+ A skill bundled with an accompanying script is also done (OpenSpec change
367
+ `skill-script-bundle`). The skill library entry is now either the original
368
+ single `.md` file or a directory (`<id>/SKILL.md` plus accompanying files at
369
+ other relative paths), sharing one id namespace; `mdcompose.lock` schema
370
+ version 3 embeds a directory-shaped skill's accompanying files under the
371
+ entry's `files` field; composition writes and drift-tracks each accompanying
372
+ file by a whole-file hash, since a script has no managed-block-safe comment
373
+ syntax; `skill list/edit/remove/adopt` all handle a directory-shaped entry,
374
+ with `--file` on `skill edit` targeting one accompanying file. See the
375
+ change's design.md for the full decision log (file-or-directory-never-both,
376
+ UTF-8-text-only accompanying files, no binary support).
377
+
378
+ Nothing open in this area.
373
379
 
374
380
  ### Third-party integrations
375
381
 
@@ -421,7 +421,7 @@ An implementation does not add the manifest to any ignore file, and does not tre
421
421
 
422
422
  | Field | Meaning |
423
423
  | ----- | ------- |
424
- | schema version | the format this file was written in (2 as of skill composition) |
424
+ | schema version | the format this file was written in (3 as of the skill script bundle) |
425
425
  | generated by | the version string of the mdcompose that wrote it |
426
426
  | generated at | when it was written |
427
427
  | detected stack | the signal files found when it was composed |
@@ -429,12 +429,16 @@ An implementation does not add the manifest to any ignore file, and does not tre
429
429
  | skills | the composed skills, in order, with content embedded (see 22) |
430
430
  | files | one entry per managed file, keyed by which file it is |
431
431
 
432
- `files` is keyed `agents_md` and `claude_md` for the composed pair, and by a skill's own id for each composed skill file (see 22.2).
433
- Each file entry carries a project-relative path, a mode of `import` or `copy` (a skill entry always carries `copy`, since a skill is always materialized), the recorded hash of that file's managed block, and which block id that hash refers to.
432
+ `files` is keyed `agents_md` and `claude_md` for the composed pair, by a skill's own id for that skill's composed `SKILL.md`, and by `<skill id>:<relative path>` for each of a directory-shaped skill's composed accompanying files (see 22.2, 23.4).
433
+ Each file entry carries a project-relative path, a mode of `import` or `copy` (a skill entry always carries `copy`, since a skill is always materialized), and a recorded hash.
434
+ For an `agents_md`, `claude_md`, or `SKILL.md` entry that hash covers the entry's managed block, alongside which block id it refers to; for an accompanying-file entry it covers the whole file instead, and the entry carries no block id at all (see 10).
434
435
  An import-mode entry also records what it imports.
435
436
 
437
+ Each skill entry may carry a `files` field: a mapping from an accompanying file's path, relative to the skill's own directory, to its embedded content. It is present only for a directory-shaped skill and is empty or absent for the common, file-shaped one, so a schema-2 manifest (which never wrote it) still reads unchanged.
438
+
436
439
  A schema-1 manifest carries no `skills` list and no per-entry block id.
437
440
  It still reads: no skills are recorded, and `agents_md`/`claude_md` fall back to their well-known block ids.
441
+ A schema-2 manifest carries no `files` field on a skill entry; it still reads, with every skill resolving as file-shaped.
438
442
 
439
443
  Unrecognized fields are preserved on read.
440
444
  A schema version higher than the implementation understands is refused, naming the file and both versions, rather than read with the wrong field meanings.
@@ -474,6 +478,9 @@ Restoring a deleted file is not the same operation as reconciling an edited bloc
474
478
  Because the hash covers only the block, an edit outside the block is clean.
475
479
  And because content is normalized before hashing, a fresh clone is clean regardless of the line ending convention the checkout used.
476
480
 
481
+ A file entry with no block id at all, which is what a skill's accompanying file always records (see 9.2), is compared by a hash of its whole normalized content instead of a block's: clean or drifted only, since there is no block to remove or markers to malform.
482
+ A directory recorded where a file is expected reads as missing, the same as a genuinely absent path.
483
+
477
484
  Detection reads only.
478
485
  Neither the managed files nor the manifest is modified by determining their state.
479
486
 
@@ -915,9 +922,13 @@ The registrations themselves are kept: a later global write restores the project
915
922
 
916
923
  ## 22. The skill library
917
924
 
918
- A second library, entirely independent of the snippet library: a flat directory of markdown files, one skill per file, YAML frontmatter then body, filename minus `.md` as the id.
925
+ A second library, entirely independent of the snippet library: a flat directory of skills, one entry per skill.
919
926
  Configured and defaulted the same way as the snippet library (a `skills` sibling directory, or an explicit `skill_library_path`), and just as readable and editable without mdcompose installed.
920
927
 
928
+ A skill entry is one of two shapes. Usually it is a single markdown file, YAML frontmatter then body, filename minus `.md` as the id. It may instead be a directory, named by the id, holding `SKILL.md` (parsed the same way as the file shape) plus zero or more accompanying files (a script, a config) at other relative paths inside that directory. A directory with no `SKILL.md` is not a skill and is left alone, the same as any other unrecognized entry. Both shapes share one id namespace: a bare `<id>.md` file and an `<id>/` directory both present for the same id is refused rather than one silently winning.
929
+
930
+ An accompanying file's identity within its skill is its path relative to the skill's directory. It is read and written as UTF-8 text under the same size limit, byte order mark handling, and line-ending preservation as every other file mdcompose handles; a file past the limit, or not valid UTF-8, is refused by name. No binary asset support.
931
+
921
932
  ### 22.1 Frontmatter
922
933
 
923
934
  A skill's frontmatter supports `title`, `description`, `tags`, `stack_signals`, and `category`.
@@ -926,7 +937,7 @@ An unrecognized field is ignored; a wrong-typed known field is refused, naming t
926
937
 
927
938
  ### 22.2 mdcompose writes only skill files into the skill library
928
939
 
929
- Nothing but a skill `.md` file is written into the skill library directory: no index, no cache, no bookkeeping.
940
+ Nothing but a skill's own files is written into the skill library directory: no index, no cache, no bookkeeping. For a directory-shaped skill that means `SKILL.md` plus its accompanying files, at their recorded relative paths, inside the skill's own directory and nowhere else; a file the user placed in that directory by hand is left untouched and never composed.
930
941
  A skill id of exactly `agents_md` or `claude_md` is refused, since either would collide with the manifest's two well-known `files` keys.
931
942
 
932
943
  ### 22.3 The skill library starts empty and is independently optional
@@ -943,6 +954,7 @@ Adoption reads the manifest's `skills` list (see 9.2, 9.3) the same way `snippet
943
954
  ## 23. Skill composition
944
955
 
945
956
  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.
957
+ A directory-shaped skill additionally composes every accompanying file at the same relative path under `.claude/skills/<id>/` that it occupies in the skill library.
946
958
 
947
959
  ### 23.1 Managed block and frontmatter
948
960
 
@@ -962,16 +974,18 @@ Re-running `init` reopens the skill picker with the manifest's recorded selectio
962
974
 
963
975
  ### 23.3 Deselecting deletes
964
976
 
965
- Unlike a deselected snippet, which leaves its managed block empty, a deselected skill has its `SKILL.md` file deleted outright, and its `.claude/skills/<id>/` directory removed if that leaves it empty.
966
- A directory the user has put another file into is left in place; only the `SKILL.md` file is removed.
967
- An empty file has no meaning to a skill-loading agent, where an empty block still leaves a valid, if empty, AGENTS.md.
977
+ Unlike a deselected snippet, which leaves its managed block empty, a deselected skill has every file mdcompose composed for it deleted outright: `SKILL.md`, and, for a directory-shaped skill, every accompanying file the manifest recorded. Its `.claude/skills/<id>/` directory, and any now-empty subdirectory an accompanying file's relative path created, are removed afterward if that leaves them empty.
978
+ A directory (or subdirectory) the user has put another file into is left in place; only the files mdcompose composed are removed.
979
+ An empty `SKILL.md` has no meaning to a skill-loading agent, where an empty block still leaves a valid, if empty, AGENTS.md.
980
+
981
+ A skill can also stay selected while one of its accompanying files is dropped from the library between runs: that file alone is deleted, by the same rule, without touching `SKILL.md` or the skill's other files.
968
982
 
969
983
  ### 23.4 Manifest and drift
970
984
 
971
- A composed skill gets a `files` entry keyed by its own id (see 9.2) and an entry in the manifest's `skills` list, embedding its full content the same way a snippet's is embedded.
972
- Drift detection is the same shared operation used for every managed file; it needs nothing skill-specific, since a `files` entry already names its own block id.
985
+ A composed skill gets a `files` entry keyed by its own id for `SKILL.md` (see 9.2), one further `files` entry per accompanying file keyed `<skill id>:<relative path>`, and an entry in the manifest's `skills` list embedding `SKILL.md`'s content the same way a snippet's is embedded, plus its accompanying files' content under the entry's own `files` field.
986
+ `SKILL.md`'s drift is the same shared managed-block operation used for every managed file; an accompanying file's drift is the same operation's whole-file-hash path (see 10), since its `files` entry carries no block id.
973
987
 
974
- Composing a skill from a manifest this machine did not write (a fresh clone) follows the same confirmation flow as AGENTS.md and CLAUDE.md: the content is shown before anything is written, and a flag supplies the confirmation non-interactively.
988
+ Composing a skill from a manifest this machine did not write (a fresh clone) follows the same confirmation flow as AGENTS.md and CLAUDE.md: the content is shown before anything is written, and a flag supplies the confirmation non-interactively. For a directory-shaped skill this covers every accompanying file as well as `SKILL.md`.
975
989
 
976
990
  ## Not yet specified
977
991
 
@@ -482,14 +482,24 @@ def _show_drift(output: OutputContext, target: init_ops.FileTarget | None) -> No
482
482
  """Show what is there against what would replace it."""
483
483
  if target is None:
484
484
  return
485
- result = managed_block.read_blocks(target.path)
486
- block = result.find(target.block_id)
487
485
  output.info(" currently:")
488
- output.content_indented("" if block is None else block.content, empty=" (empty)")
486
+ output.content_indented(_current_content(target), empty=" (empty)")
489
487
  output.info(" would become:")
490
488
  output.content_indented(target.content, empty=" (empty)")
491
489
 
492
490
 
491
+ def _current_content(target: init_ops.FileTarget) -> str:
492
+ """What is on disk now, at the granularity drift for this target compares.
493
+
494
+ An accompanying file has no managed block: the whole file is what is
495
+ being compared, so the whole file is what is shown.
496
+ """
497
+ if target.block_id is None:
498
+ return files.read_text(target.path)
499
+ block = managed_block.read_blocks(target.path).find(target.block_id)
500
+ return "" if block is None else block.content
501
+
502
+
493
503
  def _validated_drift_choice(candidate: str) -> init_ops.DriftChoice:
494
504
  if candidate not in init_ops.DRIFT_CHOICES:
495
505
  raise AttentionError(
@@ -51,6 +51,13 @@ ContentOption = Annotated[
51
51
  "--content", help="Replacement file contents. Supplying this skips the editor."
52
52
  ),
53
53
  ]
54
+ FileOption = Annotated[
55
+ str | None,
56
+ typer.Option(
57
+ "--file",
58
+ help="Which file to edit, relative to the skill's own directory. Defaults to SKILL.md.",
59
+ ),
60
+ ]
54
61
  YesOption = Annotated[bool, typer.Option("--yes", "-y", help="Answer yes to every confirmation.")]
55
62
  OnCollisionOption = Annotated[
56
63
  str | None,
@@ -114,7 +121,8 @@ def list_skills(
114
121
  row_id = skill.id.ljust(id_width)
115
122
  row_name = skill.display_name.ljust(name_width)
116
123
  description = "" if skill.description is None else skill.description
117
- output.info(f" {row_id} {row_name} {description}")
124
+ marker = " [bundle]" if skill.is_bundle else ""
125
+ output.info(f" {row_id} {row_name} {description}{marker}")
118
126
 
119
127
 
120
128
  def _as_json(
@@ -131,6 +139,7 @@ def _as_json(
131
139
  "tags": list(skill.tags),
132
140
  "stack_signals": list(skill.stack_signals),
133
141
  "category": skill.category,
142
+ "is_bundle": skill.is_bundle,
134
143
  }
135
144
  for skill in matched
136
145
  ],
@@ -145,38 +154,56 @@ def edit_skill(
145
154
  context: typer.Context,
146
155
  skill_id: Annotated[str, typer.Argument(help="The skill to edit.")],
147
156
  content: ContentOption = None,
157
+ file: FileOption = None,
148
158
  quiet: QuietOption = False,
149
159
  no_colour: NoColourOption = False,
150
160
  ) -> None:
151
161
  """Open a skill in the configured editor, or replace its contents directly.
152
162
 
153
163
  The result is validated before it is installed, so a broken edit leaves
154
- the existing skill exactly as it was.
164
+ the existing skill exactly as it was. ``--file`` targets an accompanying
165
+ file of a directory-shaped skill instead of ``SKILL.md``; naming one
166
+ against a skill with no accompanying files is refused up front.
155
167
  """
156
168
  output = output_for(context, quiet=quiet, no_colour=no_colour)
157
169
  library = resolve_library()
158
170
  skill = library.require(skill_id)
171
+ if file is not None and file != skills_module.SKILL_MD_FILENAME and not skill.is_bundle:
172
+ raise AttentionError(f"skill '{skill_id}' has no accompanying files")
159
173
 
160
174
  if content is not None:
161
- skill_library_ops.replace_body(library, skill_id, content)
175
+ skill_library_ops.replace_body(library, skill_id, content, relative=file)
162
176
  output.info(f"wrote {skill_id}")
163
177
  return
164
178
 
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)
179
+ edited = _edit_in_editor(_edit_target_path(skill, file))
168
180
  if edited is None:
169
181
  output.info(f"{skill_id} unchanged")
170
182
  return
171
- skill_library_ops.replace_body(library, skill_id, edited)
183
+ skill_library_ops.replace_body(library, skill_id, edited, relative=file)
172
184
  output.info(f"wrote {skill_id}")
173
185
 
174
186
 
187
+ def _edit_target_path(skill: skills_module.Skill, file: str | None) -> Path:
188
+ """Where to read the current content from for an interactive edit.
189
+
190
+ A new accompanying file named by ``--file`` has no path yet; that is left
191
+ to ``_edit_in_editor``, which treats a nonexistent path as empty content.
192
+ """
193
+ if file is None or file == skills_module.SKILL_MD_FILENAME:
194
+ if skill.path is None:
195
+ raise AttentionError(f"skill '{skill.id}' has no file on disk to edit")
196
+ return skill.path
197
+ assert skill.path is not None # guaranteed by the is_bundle check in edit_skill
198
+ return skill.path.parent / file
199
+
200
+
175
201
  def _edit_in_editor(path: Path) -> str | None:
176
202
  """Open a copy in the editor and return the result, or None if unchanged.
177
203
 
178
204
  A copy rather than the file itself, so an edit that fails validation
179
- cannot leave the library holding it.
205
+ cannot leave the library holding it. A path that does not exist yet (a
206
+ new accompanying file) starts from empty content rather than refusing.
180
207
  """
181
208
  editor = os.environ.get("VISUAL") or os.environ.get("EDITOR")
182
209
  if not editor:
@@ -184,7 +211,7 @@ def _edit_in_editor(path: Path) -> str | None:
184
211
  "no editor configured. Set VISUAL or EDITOR, or pass --content to "
185
212
  "supply the new contents directly."
186
213
  )
187
- original = files.read_text(path)
214
+ original = files.read_text(path) if files.path_exists(path) else ""
188
215
  with tempfile.TemporaryDirectory() as scratch:
189
216
  draft = Path(scratch) / path.name
190
217
  files.write_text(draft, original)
@@ -294,11 +321,17 @@ def _validated_resolution(candidate: str) -> skill_library_ops.Resolution:
294
321
 
295
322
 
296
323
  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)
324
+ """Show both versions of every differing file, so the choice is made with
325
+ the content in view. A bundle skill's collision names each differing
326
+ accompanying file; a plain skill's names none, since there is only ever
327
+ the one file to show.
328
+ """
329
+ for path in collision.differing_paths:
330
+ label = "" if path == skills_module.SKILL_MD_FILENAME else f" ({path})"
331
+ output.info(f" skill library copy of '{collision.skill_id}'{label}:")
332
+ output.content_indented(collision.local.get(path, ""))
333
+ output.info(f" embedded copy of '{collision.skill_id}'{label}:")
334
+ output.content_indented(collision.embedded.get(path, ""))
302
335
 
303
336
 
304
337
  def _report_adoption(output: OutputContext, result: skill_library_ops.AdoptResult) -> None:
@@ -55,13 +55,21 @@ class FileTarget:
55
55
 
56
56
  ``frontmatter`` is ``None`` for AGENTS.md/CLAUDE.md, where nothing sits
57
57
  outside the managed block that mdcompose itself generates. It is set for
58
- a skill target, whose ``name``/``description`` frontmatter must precede
59
- the block and is regenerated on every write; see ``skill_composition.py``.
58
+ a skill's ``SKILL.md`` target, whose ``name``/``description`` frontmatter
59
+ must precede the block and is regenerated on every write; see
60
+ ``skill_composition.py``.
61
+
62
+ ``block_id`` is ``None`` for a directory-shaped skill's accompanying file,
63
+ which has no managed block at all: the whole file is mdcompose's, written
64
+ and drift-checked as a unit (see ``skill_composition.apply_accompanying_file``
65
+ and ``manifest.py``'s drift handling for an entry with no block id). Such a
66
+ target also carries no frontmatter, which together with a ``None``
67
+ ``block_id`` is what distinguishes it from every other target.
60
68
  """
61
69
 
62
70
  key: str
63
71
  path: Path
64
- block_id: str
72
+ block_id: str | None
65
73
  content: str
66
74
  relative_path: str
67
75
  frontmatter: str | None = None
@@ -233,10 +241,17 @@ def skills_from_manifest(manifest: manifest_module.Manifest) -> tuple[Skill, ...
233
241
  """Build a skill selection from a manifest's embedded content.
234
242
 
235
243
  Used on the clone path, where the local skill library may be empty or
236
- may not have this skill at all.
244
+ may not have this skill at all. A skill entry carrying embedded
245
+ accompanying files reconstructs as a directory-shaped skill.
237
246
  """
238
247
  return tuple(
239
- Skill(id=entry.id, body=entry.content) for entry in manifest.skills_in_order()
248
+ Skill(
249
+ id=entry.id,
250
+ body=entry.content,
251
+ is_bundle=bool(entry.files),
252
+ files=dict(entry.files),
253
+ )
254
+ for entry in manifest.skills_in_order()
240
255
  )
241
256
 
242
257
 
@@ -361,9 +376,7 @@ def plan(
361
376
  from_embedded=from_embedded,
362
377
  skill_selection=skill_selection,
363
378
  skill_selection_source=skill_source,
364
- stale_skill_paths=_stale_skill_paths(
365
- root, manifest, tuple(skill.id for skill in skill_selection)
366
- ),
379
+ stale_skill_paths=_stale_skill_paths(root, manifest, skill_selection),
367
380
  )
368
381
 
369
382
 
@@ -394,25 +407,37 @@ def _detect(
394
407
 
395
408
 
396
409
  def _stale_skill_paths(
397
- root: Path, manifest: manifest_module.Manifest | None, current_skill_ids: tuple[str, ...]
410
+ root: Path, manifest: manifest_module.Manifest | None, selection: Sequence[Skill]
398
411
  ) -> tuple[Path, ...]:
399
- """Manifest-recorded skill files not in the current selection, by full path.
412
+ """Manifest-recorded skill files no longer part of the selection, by full path.
400
413
 
401
414
  Anything in ``manifest.files`` keyed by something other than the two
402
- well-known AGENTS.md/CLAUDE.md keys is a skill entry, since this is the
403
- only feature that ever writes a third kind of key.
415
+ well-known AGENTS.md/CLAUDE.md keys is a skill entry: either a skill's own
416
+ id (its ``SKILL.md``), or ``<skill id>:<relative path>`` (one of its
417
+ accompanying files; see ``manifest.py``'s schema). A file is stale when its
418
+ owning skill is no longer selected at all, or, for an accompanying file,
419
+ when that skill is still selected but no longer carries that particular
420
+ file.
404
421
  """
405
422
  if manifest is None:
406
423
  return ()
407
- current = set(current_skill_ids)
424
+ current_files = {skill.id: set(skill.files) for skill in selection}
408
425
  return tuple(
409
426
  root / entry.path
410
427
  for key, entry in manifest.files.items()
411
428
  if key not in {manifest_module.AGENTS_MD_KEY, manifest_module.CLAUDE_MD_KEY}
412
- and key not in current
429
+ and _is_stale_skill_key(key, current_files)
413
430
  )
414
431
 
415
432
 
433
+ def _is_stale_skill_key(key: str, current_files: Mapping[str, set[str]]) -> bool:
434
+ """Whether a skill-keyed manifest entry no longer belongs to the selection."""
435
+ skill_id, _, relative = key.partition(":")
436
+ if skill_id not in current_files:
437
+ return True
438
+ return bool(relative) and relative not in current_files[skill_id]
439
+
440
+
416
441
  def _recorded_mode(manifest: manifest_module.Manifest | None) -> Mode | None:
417
442
  if manifest is None:
418
443
  return None
@@ -480,16 +505,40 @@ def _targets(
480
505
 
481
506
 
482
507
  def _skill_targets(root: Path, selection: Sequence[Skill]) -> tuple[FileTarget, ...]:
508
+ targets: list[FileTarget] = []
509
+ for skill in selection:
510
+ targets.append(
511
+ FileTarget(
512
+ key=skill.id,
513
+ path=skill_composition.skill_path(root, skill.id),
514
+ block_id=managed_block.SKILL_MANAGED_BLOCK,
515
+ content=_skill_body(skill),
516
+ relative_path=skill_composition.skill_path(Path(), skill.id).as_posix(),
517
+ frontmatter=skill_composition.render_frontmatter(skill),
518
+ )
519
+ )
520
+ targets.extend(_accompanying_targets(root, skill))
521
+ return tuple(targets)
522
+
523
+
524
+ def _accompanying_targets(root: Path, skill: Skill) -> tuple[FileTarget, ...]:
525
+ """One target per accompanying file of a directory-shaped skill.
526
+
527
+ ``block_id`` is ``None`` and ``frontmatter`` is left at its default,
528
+ which together mark this as neither a ``SKILL.md`` target nor an
529
+ AGENTS.md/CLAUDE.md target: see ``FileTarget``.
530
+ """
531
+ skill_dir = skill_composition.skill_path(root, skill.id).parent
532
+ relative_skill_dir = skill_composition.skill_path(Path(), skill.id).parent
483
533
  return tuple(
484
534
  FileTarget(
485
- key=skill.id,
486
- path=skill_composition.skill_path(root, skill.id),
487
- block_id=managed_block.SKILL_MANAGED_BLOCK,
488
- content=_skill_body(skill),
489
- relative_path=skill_composition.skill_path(Path(), skill.id).as_posix(),
490
- frontmatter=skill_composition.render_frontmatter(skill),
535
+ key=f"{skill.id}:{relative}",
536
+ path=skill_dir / relative,
537
+ block_id=None,
538
+ content=content,
539
+ relative_path=(relative_skill_dir / relative).as_posix(),
491
540
  )
492
- for skill in selection
541
+ for relative, content in sorted(skill.files.items())
493
542
  )
494
543
 
495
544
 
@@ -529,9 +578,12 @@ def apply(
529
578
  entries[target.key] = _entry_for_existing(target, plan_to_apply)
530
579
  continue
531
580
  if target.frontmatter is not None:
581
+ assert target.block_id is not None # a SKILL.md target always has one
532
582
  changed = skill_composition.apply_to_file(
533
583
  target.path, target.block_id, target.content, target.frontmatter
534
584
  )
585
+ elif target.block_id is None:
586
+ changed = skill_composition.apply_accompanying_file(target.path, target.content)
535
587
  else:
536
588
  changed = composition.apply_to_file(target.path, target.block_id, target.content)
537
589
  if changed:
@@ -563,25 +615,42 @@ def apply(
563
615
 
564
616
 
565
617
  def _delete_stale_skill_files(paths: Sequence[Path]) -> tuple[str, ...]:
566
- """Delete a manifest-recorded skill file no longer in the selection.
567
-
568
- Removes the skill's directory afterward only if it is now empty, never a
569
- directory the user has put something else into.
618
+ """Delete manifest-recorded skill files no longer part of the selection.
619
+
620
+ Removes empty directories left behind afterward, walking upward from each
621
+ deleted file's parent. The walk stops at, and never removes, the shared
622
+ `.claude/skills` directory itself, never a directory the user has put
623
+ something else into, and it revisits a directory left empty by an earlier
624
+ deletion in the same batch (a bundle skill's own directory can become
625
+ empty only after its last accompanying file is gone).
570
626
  """
571
627
  deleted: list[str] = []
572
628
  for path in paths:
573
629
  if files.path_exists(path) and path.is_file():
574
630
  path.unlink()
575
631
  deleted.append(path.as_posix())
576
- parent = path.parent
577
- if files.path_exists(parent) and parent.is_dir() and not any(parent.iterdir()):
578
- parent.rmdir()
632
+ _remove_empty_ancestors(path.parent)
579
633
  return tuple(deleted)
580
634
 
581
635
 
636
+ def _remove_empty_ancestors(directory: Path) -> None:
637
+ current = directory
638
+ while (
639
+ files.path_exists(current)
640
+ and current.is_dir()
641
+ and current.name != skill_composition.SKILLS_DIR_NAME
642
+ and not any(current.iterdir())
643
+ ):
644
+ parent = current.parent
645
+ current.rmdir()
646
+ current = parent
647
+
648
+
582
649
  def _entry_mode(target: FileTarget, plan_to_apply: InitPlan) -> Mode:
583
650
  """A skill target has no import/copy distinction; it is always materialized."""
584
- return composition.COPY if target.frontmatter is not None else plan_to_apply.mode
651
+ if target.frontmatter is not None or target.block_id is None:
652
+ return composition.COPY
653
+ return plan_to_apply.mode
585
654
 
586
655
 
587
656
  def _entry_for(target: FileTarget, plan_to_apply: InitPlan) -> manifest_module.FileEntry:
@@ -602,11 +671,15 @@ def _entry_for_existing(
602
671
  Recording what is actually there, rather than what mdcompose would have
603
672
  written, is what stops the same drift being reported on every later run. It
604
673
  also means a clone reproduces the kept version rather than the one the user
605
- deliberately replaced.
674
+ deliberately replaced. An accompanying file has no managed block to read
675
+ the kept content from: the whole file is what was kept.
606
676
  """
607
- result = managed_block.read_blocks(target.path)
608
- block = result.find(target.block_id)
609
- content = target.content if block is None else block.content
677
+ if target.block_id is None:
678
+ content = files.read_text(target.path)
679
+ else:
680
+ result = managed_block.read_blocks(target.path)
681
+ block = result.find(target.block_id)
682
+ content = target.content if block is None else block.content
610
683
  return manifest_module.FileEntry(
611
684
  path=target.relative_path,
612
685
  mode=_entry_mode(target, plan_to_apply),
@@ -630,6 +703,8 @@ def _snippet_entries(selection: Sequence[Snippet]) -> tuple[manifest_module.Snip
630
703
 
631
704
  def _skill_entries(selection: Sequence[Skill]) -> tuple[manifest_module.SkillEntry, ...]:
632
705
  return tuple(
633
- manifest_module.SkillEntry(id=skill.id, position=index, content=skill.body)
706
+ manifest_module.SkillEntry(
707
+ id=skill.id, position=index, content=skill.body, files=dict(skill.files)
708
+ )
634
709
  for index, skill in enumerate(selection)
635
710
  )