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.
- {mdcompose-0.2.1 → mdcompose-0.3.0}/.pre-commit-config.yaml +3 -3
- {mdcompose-0.2.1 → mdcompose-0.3.0}/CHANGELOG.md +6 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/PKG-INFO +1 -1
- {mdcompose-0.2.1 → mdcompose-0.3.0}/TODO.md +16 -10
- {mdcompose-0.2.1 → mdcompose-0.3.0}/docs/core-contract.md +25 -11
- {mdcompose-0.2.1 → mdcompose-0.3.0}/mdcompose/commands/init_cmd.py +13 -3
- {mdcompose-0.2.1 → mdcompose-0.3.0}/mdcompose/commands/skill_cmd.py +47 -14
- {mdcompose-0.2.1 → mdcompose-0.3.0}/mdcompose/core/init_ops.py +109 -34
- {mdcompose-0.2.1 → mdcompose-0.3.0}/mdcompose/core/manifest.py +51 -11
- {mdcompose-0.2.1 → mdcompose-0.3.0}/mdcompose/core/skill_composition.py +18 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/mdcompose/core/skill_library_ops.py +97 -24
- {mdcompose-0.2.1 → mdcompose-0.3.0}/mdcompose/core/skills.py +110 -19
- {mdcompose-0.2.1 → mdcompose-0.3.0}/pyproject.toml +1 -1
- {mdcompose-0.2.1 → mdcompose-0.3.0}/tests/test_init.py +66 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/tests/test_init_ops.py +285 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/tests/test_manifest.py +140 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/tests/test_skill_commands.py +162 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/tests/test_skill_composition.py +14 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/tests/test_skills.py +114 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/uv.lock +1 -1
- {mdcompose-0.2.1 → mdcompose-0.3.0}/.gitattributes +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/.github/FUNDING.yml +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/.github/workflows/bump.yml +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/.github/workflows/ci.yml +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/.github/workflows/codeql.yml +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/.github/workflows/dependency-review.yml +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/.github/workflows/mutation.yml +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/.github/workflows/python-eol.yml +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/.github/workflows/release.yml +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/.github/workflows/scorecard.yml +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/.github/workflows/supply-chain.yml +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/.github/zizmor.yml +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/.gitignore +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/CODE_OF_CONDUCT.md +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/CONTRIBUTING.md +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/LICENSE +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/README.md +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/SECURITY.md +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/docs/benchmarks.md +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/docs/commit-and-release-tooling.md +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/docs/concepts.md +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/docs/hardening-review.md +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/docs/optional-tooling.md +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/docs/publishing.md +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/docs/scorecard.md +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/docs/versioning-explained.md +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/mdcompose/__init__.py +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/mdcompose/cli.py +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/mdcompose/commands/__init__.py +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/mdcompose/commands/config_cmd.py +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/mdcompose/commands/convert_cmd.py +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/mdcompose/commands/eject_cmd.py +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/mdcompose/commands/import_cmd.py +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/mdcompose/commands/snippet_cmd.py +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/mdcompose/commands/target_cmd.py +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/mdcompose/core/__init__.py +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/mdcompose/core/composition.py +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/mdcompose/core/config.py +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/mdcompose/core/convert_ops.py +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/mdcompose/core/eject.py +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/mdcompose/core/exit_codes.py +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/mdcompose/core/files.py +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/mdcompose/core/import_ops.py +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/mdcompose/core/library_ops.py +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/mdcompose/core/managed_block.py +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/mdcompose/core/output.py +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/mdcompose/core/platform.py +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/mdcompose/core/report.py +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/mdcompose/core/sections.py +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/mdcompose/core/snippets.py +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/mdcompose/core/targets.py +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/mdcompose/prompts.py +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/mdcompose/version.py +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/renovate.json +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/scripts/check_ascii.py +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/scripts/check_licenses.py +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/tests/benchmarks/conftest.py +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/tests/benchmarks/test_core_ops.py +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/tests/conftest.py +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/tests/fixtures/mdcompose.lock.schema1.json +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/tests/fixtures/plain.bom.md +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/tests/fixtures/plain.crlf.md +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/tests/fixtures/plain.lf.md +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/tests/fixtures/prose.cp1252.md +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/tests/test_cli.py +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/tests/test_config.py +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/tests/test_config_cmd.py +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/tests/test_convert.py +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/tests/test_doctor_drift.py +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/tests/test_e2e_import_convert.py +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/tests/test_eject.py +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/tests/test_files.py +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/tests/test_fuzz.py +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/tests/test_import_cmd.py +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/tests/test_import_ops.py +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/tests/test_layering.py +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/tests/test_managed_block.py +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/tests/test_onedrive.py +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/tests/test_output.py +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/tests/test_platform.py +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/tests/test_prompts.py +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/tests/test_report.py +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/tests/test_sections.py +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/tests/test_snippet_commands.py +0 -0
- {mdcompose-0.2.1 → mdcompose-0.3.0}/tests/test_snippets.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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 (
|
|
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,
|
|
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),
|
|
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
|
|
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
|
|
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
|
|
966
|
-
A directory the user has put another file into is left in place; only the
|
|
967
|
-
An empty
|
|
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
|
|
972
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
|
59
|
-
the block and is regenerated on every write; see
|
|
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(
|
|
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,
|
|
410
|
+
root: Path, manifest: manifest_module.Manifest | None, selection: Sequence[Skill]
|
|
398
411
|
) -> tuple[Path, ...]:
|
|
399
|
-
"""Manifest-recorded skill files
|
|
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
|
|
403
|
-
|
|
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
|
-
|
|
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
|
|
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=
|
|
487
|
-
block_id=
|
|
488
|
-
content=
|
|
489
|
-
relative_path=
|
|
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
|
|
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
|
|
567
|
-
|
|
568
|
-
Removes
|
|
569
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
608
|
-
|
|
609
|
-
|
|
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(
|
|
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
|
)
|