codexmgr 0.4.1__tar.gz → 0.5.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 (143) hide show
  1. {codexmgr-0.4.1 → codexmgr-0.5.0}/PKG-INFO +22 -9
  2. {codexmgr-0.4.1 → codexmgr-0.5.0}/README.md +20 -7
  3. {codexmgr-0.4.1 → codexmgr-0.5.0}/pyproject.toml +2 -2
  4. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/__init__.py +1 -1
  5. codexmgr-0.5.0/src/codexmgr/skills/candidates.py +138 -0
  6. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/skills/copies.py +53 -12
  7. codexmgr-0.5.0/src/codexmgr/skills/metadata.py +100 -0
  8. codexmgr-0.5.0/src/codexmgr/skills/resolution.py +119 -0
  9. codexmgr-0.5.0/src/codexmgr/skills/selector.py +240 -0
  10. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/skills/sources.py +13 -9
  11. {codexmgr-0.4.1 → codexmgr-0.5.0}/tests/test_apply_check_cli.py +4 -1
  12. {codexmgr-0.4.1 → codexmgr-0.5.0}/tests/test_codex_cli.py +6 -3
  13. {codexmgr-0.4.1 → codexmgr-0.5.0}/tests/test_packages_cli.py +17 -3
  14. {codexmgr-0.4.1 → codexmgr-0.5.0}/tests/test_skill_copies_cli.py +55 -17
  15. {codexmgr-0.4.1 → codexmgr-0.5.0}/tests/test_skill_home_sources_cli.py +4 -1
  16. codexmgr-0.5.0/tests/test_skill_metadata.py +35 -0
  17. codexmgr-0.5.0/tests/test_skill_portability_cli.py +281 -0
  18. {codexmgr-0.4.1 → codexmgr-0.5.0}/tests/test_skills_cli.py +17 -11
  19. {codexmgr-0.4.1 → codexmgr-0.5.0}/tests/test_status_doctor_cli.py +4 -1
  20. {codexmgr-0.4.1 → codexmgr-0.5.0}/tests/test_tui_app.py +4 -1
  21. {codexmgr-0.4.1 → codexmgr-0.5.0}/tests/test_tui_selection_focus.py +4 -1
  22. {codexmgr-0.4.1 → codexmgr-0.5.0}/tests/test_tui_state.py +4 -1
  23. {codexmgr-0.4.1 → codexmgr-0.5.0}/uv.lock +3 -3
  24. codexmgr-0.4.1/src/codexmgr/skills/resolution.py +0 -147
  25. {codexmgr-0.4.1 → codexmgr-0.5.0}/.github/actions/install_package/action.yml +0 -0
  26. {codexmgr-0.4.1 → codexmgr-0.5.0}/.github/workflows/py_test.yml +0 -0
  27. {codexmgr-0.4.1 → codexmgr-0.5.0}/.github/workflows/version_publish_main.yml +0 -0
  28. {codexmgr-0.4.1 → codexmgr-0.5.0}/.gitignore +0 -0
  29. {codexmgr-0.4.1 → codexmgr-0.5.0}/pytest.toml +0 -0
  30. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/agents/__init__.py +0 -0
  31. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/agents/file.py +0 -0
  32. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/agents/manager.py +0 -0
  33. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/agents/renderer.py +0 -0
  34. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/commands/__init__.py +0 -0
  35. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/commands/codex.py +0 -0
  36. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/commands/codex_jit.py +0 -0
  37. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/commands/codex_launch.py +0 -0
  38. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/commands/config_mutations.py +0 -0
  39. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/commands/health.py +0 -0
  40. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/commands/navigation.py +0 -0
  41. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/commands/rules.py +0 -0
  42. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/core/__init__.py +0 -0
  43. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/core/errors.py +0 -0
  44. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/core/managed_text.py +0 -0
  45. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/core/options.py +0 -0
  46. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/core/paths.py +0 -0
  47. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/core/toml_io.py +0 -0
  48. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/custom_agents/__init__.py +0 -0
  49. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/custom_agents/cli.py +0 -0
  50. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/custom_agents/config.py +0 -0
  51. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/custom_agents/copies.py +0 -0
  52. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/custom_agents/listing.py +0 -0
  53. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/custom_agents/resolution.py +0 -0
  54. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/custom_agents/sources.py +0 -0
  55. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/hooks/__init__.py +0 -0
  56. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/hooks/config.py +0 -0
  57. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/hooks/copies.py +0 -0
  58. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/hooks/listing.py +0 -0
  59. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/hooks/merge.py +0 -0
  60. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/hooks/resolution.py +0 -0
  61. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/hooks/sources.py +0 -0
  62. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/interface/__init__.py +0 -0
  63. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/interface/cli.py +0 -0
  64. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/interface/parser.py +0 -0
  65. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/interface/parsers/__init__.py +0 -0
  66. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/interface/parsers/agents.py +0 -0
  67. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/interface/parsers/mcp.py +0 -0
  68. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/mcp/__init__.py +0 -0
  69. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/mcp/apply.py +0 -0
  70. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/mcp/cli.py +0 -0
  71. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/mcp/config.py +0 -0
  72. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/mcp/discovery.py +0 -0
  73. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/mcp/fields.py +0 -0
  74. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/mcp/project.py +0 -0
  75. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/mcp/resolution.py +0 -0
  76. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/mcp/sources.py +0 -0
  77. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/mcp/tables.py +0 -0
  78. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/packages/__init__.py +0 -0
  79. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/packages/cli.py +0 -0
  80. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/packages/config.py +0 -0
  81. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/packages/listing.py +0 -0
  82. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/packages/mutation.py +0 -0
  83. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/packages/sources.py +0 -0
  84. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/project/__init__.py +0 -0
  85. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/project/apply.py +0 -0
  86. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/project/config.py +0 -0
  87. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/project/generated.py +0 -0
  88. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/project/gitignore.py +0 -0
  89. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/project/resolution.py +0 -0
  90. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/project/snapshot.py +0 -0
  91. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/project/state.py +0 -0
  92. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/project/sync.py +0 -0
  93. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/py.typed +0 -0
  94. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/rules/__init__.py +0 -0
  95. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/rules/config.py +0 -0
  96. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/rules/copies.py +0 -0
  97. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/rules/listing.py +0 -0
  98. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/rules/resolution.py +0 -0
  99. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/rules/sources.py +0 -0
  100. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/rules/tree.py +0 -0
  101. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/skills/__init__.py +0 -0
  102. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/skills/config.py +0 -0
  103. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/skills/listing.py +0 -0
  104. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/tui/__init__.py +0 -0
  105. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/tui/app.py +0 -0
  106. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/tui/cli.py +0 -0
  107. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/tui/diff.py +0 -0
  108. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/tui/focus.py +0 -0
  109. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/tui/items.py +0 -0
  110. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/tui/mcp_items.py +0 -0
  111. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/tui/models.py +0 -0
  112. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/tui/mutations.py +0 -0
  113. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/tui/package_refs.py +0 -0
  114. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/tui/package_selection.py +0 -0
  115. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/tui/package_state.py +0 -0
  116. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/tui/panels.py +0 -0
  117. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/tui/rendering.py +0 -0
  118. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/tui/rule_items.py +0 -0
  119. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/tui/rule_tree.py +0 -0
  120. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/tui/sections.py +0 -0
  121. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/tui/staged_packages.py +0 -0
  122. {codexmgr-0.4.1 → codexmgr-0.5.0}/src/codexmgr/tui/state.py +0 -0
  123. {codexmgr-0.4.1 → codexmgr-0.5.0}/tests/conftest.py +0 -0
  124. {codexmgr-0.4.1 → codexmgr-0.5.0}/tests/test_agents_cli.py +0 -0
  125. {codexmgr-0.4.1 → codexmgr-0.5.0}/tests/test_agents_file.py +0 -0
  126. {codexmgr-0.4.1 → codexmgr-0.5.0}/tests/test_agentsmd_list_cli.py +0 -0
  127. {codexmgr-0.4.1 → codexmgr-0.5.0}/tests/test_agentsmd_tools_cli.py +0 -0
  128. {codexmgr-0.4.1 → codexmgr-0.5.0}/tests/test_cd_cli.py +0 -0
  129. {codexmgr-0.4.1 → codexmgr-0.5.0}/tests/test_cli.py +0 -0
  130. {codexmgr-0.4.1 → codexmgr-0.5.0}/tests/test_empty_skill_config_cli.py +0 -0
  131. {codexmgr-0.4.1 → codexmgr-0.5.0}/tests/test_home_resolution_cli.py +0 -0
  132. {codexmgr-0.4.1 → codexmgr-0.5.0}/tests/test_hooks_cli.py +0 -0
  133. {codexmgr-0.4.1 → codexmgr-0.5.0}/tests/test_mcp_apply_cli.py +0 -0
  134. {codexmgr-0.4.1 → codexmgr-0.5.0}/tests/test_mcp_cli.py +0 -0
  135. {codexmgr-0.4.1 → codexmgr-0.5.0}/tests/test_package_metadata.py +0 -0
  136. {codexmgr-0.4.1 → codexmgr-0.5.0}/tests/test_paths.py +0 -0
  137. {codexmgr-0.4.1 → codexmgr-0.5.0}/tests/test_project_gitignore.py +0 -0
  138. {codexmgr-0.4.1 → codexmgr-0.5.0}/tests/test_python_compatibility.py +0 -0
  139. {codexmgr-0.4.1 → codexmgr-0.5.0}/tests/test_renderer.py +0 -0
  140. {codexmgr-0.4.1 → codexmgr-0.5.0}/tests/test_rules_cli.py +0 -0
  141. {codexmgr-0.4.1 → codexmgr-0.5.0}/tests/test_skill_list_cli.py +0 -0
  142. {codexmgr-0.4.1 → codexmgr-0.5.0}/tests/test_sync_cli.py +0 -0
  143. {codexmgr-0.4.1 → codexmgr-0.5.0}/tests/test_toml_io.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codexmgr
3
- Version: 0.4.1
3
+ Version: 0.5.0
4
4
  Summary: Manage project-local Codex configuration from reusable templates
5
5
  Project-URL: Repository, https://github.com/JulianKimmig/codexmgr/
6
6
  Keywords: agents,cli,codex,configuration,skills
@@ -14,12 +14,12 @@ Classifier: Programming Language :: Python :: 3.13
14
14
  Classifier: Topic :: Software Development
15
15
  Classifier: Typing :: Typed
16
16
  Requires-Python: >=3.11
17
+ Requires-Dist: pyyaml>=6.0.3
17
18
  Requires-Dist: textual>=8.2.7
18
19
  Requires-Dist: tomlkit>=0.15.0
19
20
  Provides-Extra: dev
20
21
  Requires-Dist: pytest-asyncio>=1.4.0; extra == 'dev'
21
22
  Requires-Dist: pytest>=9.0.3; extra == 'dev'
22
- Requires-Dist: pyyaml>=6.0.3; extra == 'dev'
23
23
  Description-Content-Type: text/markdown
24
24
 
25
25
  # codexmgr
@@ -211,17 +211,30 @@ Named `AGENTS.md` snippets resolve from
211
211
  `$CODEXMGR_HOME/agentsmd/<name>.toml`. Path-like snippet values resolve
212
212
  relative to the project unless they are absolute paths.
213
213
 
214
- Named skills resolve from `$CODEXMGR_HOME/skills/<name>/SKILL.md` or
215
- `$CODEX_HOME/skills/<name>/SKILL.md`. Duplicate names across distinct homes
216
- fail so the selected skill is not ambiguous.
214
+ Bare skill names resolve across the project `.agents/skills`,
215
+ `$CODEXMGR_HOME/skills`, and `$CODEX_HOME/skills` stores. If the same folder
216
+ name exists in more than one store, apply fails and asks for an explicit path.
217
+ The project copy recorded for an enabled `$CODEXMGR_HOME` skill is recognized
218
+ as a managed mirror and does not create a false collision on later applies.
217
219
 
218
220
  Enabled skills from `$CODEXMGR_HOME` are copied into `.agents/skills/<name>` on
219
221
  every apply. The copy overlays source files while preserving extra local files.
220
222
  Path-like skill values can point to a `SKILL.md` file or a directory containing
221
- `SKILL.md`.
222
-
223
- Missing skills are still written as name-based entries. That lets Codex resolve
224
- them later from another installed skill source.
223
+ `SKILL.md`; a path-like value that does not exist is an error.
224
+
225
+ Project-local and copied manager-home skills generate portable `name` selectors
226
+ using the `name` value in their `SKILL.md` YAML frontmatter. Selected skills in
227
+ those stores must therefore have valid frontmatter and a non-empty name. Apply
228
+ also fails if two discoverable skills declare the same generated name. Skills
229
+ resolved directly from `$CODEX_HOME` and explicit path references keep absolute
230
+ `path` selectors because those sources are machine-specific or explicitly
231
+ requested. Missing bare names remain name-based entries so Codex can resolve
232
+ them later from another installed source.
233
+
234
+ Managed skill-copy lock entries use the logical source `codexmgr_home` and a
235
+ project-relative `.agents/skills/<name>` target. Apply accepts legacy absolute
236
+ copy entries and rewrites them in this portable form, so moving or cloning a
237
+ project does not churn generated skill config or lock state.
225
238
 
226
239
  Named custom agents resolve from `$CODEXMGR_HOME/agents/<name>.toml`. Enabled
227
240
  agents are copied into `.codex/agents/<name>.toml`; disabled agents remove the
@@ -187,17 +187,30 @@ Named `AGENTS.md` snippets resolve from
187
187
  `$CODEXMGR_HOME/agentsmd/<name>.toml`. Path-like snippet values resolve
188
188
  relative to the project unless they are absolute paths.
189
189
 
190
- Named skills resolve from `$CODEXMGR_HOME/skills/<name>/SKILL.md` or
191
- `$CODEX_HOME/skills/<name>/SKILL.md`. Duplicate names across distinct homes
192
- fail so the selected skill is not ambiguous.
190
+ Bare skill names resolve across the project `.agents/skills`,
191
+ `$CODEXMGR_HOME/skills`, and `$CODEX_HOME/skills` stores. If the same folder
192
+ name exists in more than one store, apply fails and asks for an explicit path.
193
+ The project copy recorded for an enabled `$CODEXMGR_HOME` skill is recognized
194
+ as a managed mirror and does not create a false collision on later applies.
193
195
 
194
196
  Enabled skills from `$CODEXMGR_HOME` are copied into `.agents/skills/<name>` on
195
197
  every apply. The copy overlays source files while preserving extra local files.
196
198
  Path-like skill values can point to a `SKILL.md` file or a directory containing
197
- `SKILL.md`.
198
-
199
- Missing skills are still written as name-based entries. That lets Codex resolve
200
- them later from another installed skill source.
199
+ `SKILL.md`; a path-like value that does not exist is an error.
200
+
201
+ Project-local and copied manager-home skills generate portable `name` selectors
202
+ using the `name` value in their `SKILL.md` YAML frontmatter. Selected skills in
203
+ those stores must therefore have valid frontmatter and a non-empty name. Apply
204
+ also fails if two discoverable skills declare the same generated name. Skills
205
+ resolved directly from `$CODEX_HOME` and explicit path references keep absolute
206
+ `path` selectors because those sources are machine-specific or explicitly
207
+ requested. Missing bare names remain name-based entries so Codex can resolve
208
+ them later from another installed source.
209
+
210
+ Managed skill-copy lock entries use the logical source `codexmgr_home` and a
211
+ project-relative `.agents/skills/<name>` target. Apply accepts legacy absolute
212
+ copy entries and rewrites them in this portable form, so moving or cloning a
213
+ project does not churn generated skill config or lock state.
201
214
 
202
215
  Named custom agents resolve from `$CODEXMGR_HOME/agents/<name>.toml`. Enabled
203
216
  agents are copied into `.codex/agents/<name>.toml`; disabled agents remove the
@@ -1,10 +1,11 @@
1
1
  [project]
2
2
  name = "codexmgr"
3
- version = "0.4.1"
3
+ version = "0.5.0"
4
4
  description = "Manage project-local Codex configuration from reusable templates"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
7
7
  dependencies = [
8
+ "pyyaml>=6.0.3",
8
9
  "textual>=8.2.7",
9
10
  "tomlkit>=0.15.0",
10
11
  ]
@@ -30,7 +31,6 @@ codexmgr = "codexmgr.interface.cli:entrypoint"
30
31
  [project.optional-dependencies]
31
32
  dev = [
32
33
  "pytest>=9.0.3",
33
- "pyyaml>=6.0.3",
34
34
  "pytest-asyncio>=1.4.0",
35
35
  ]
36
36
 
@@ -2,4 +2,4 @@
2
2
 
3
3
  __all__ = ["__version__"]
4
4
 
5
- __version__ = "0.4.1"
5
+ __version__ = "0.5.0"
@@ -0,0 +1,138 @@
1
+ """Discover every source that may satisfy a configured skill reference."""
2
+
3
+ from pathlib import Path
4
+
5
+ from .sources import (
6
+ CODEX_HOME_SOURCE,
7
+ CODEXMGR_HOME_SOURCE,
8
+ LOCAL_SOURCE,
9
+ PATH_SOURCE,
10
+ SkillSource,
11
+ is_named_skill,
12
+ project_skill_dir,
13
+ )
14
+
15
+
16
+ def skill_reference_candidates(
17
+ skill: str,
18
+ cwd: Path,
19
+ codex_home: Path,
20
+ codexmgr_home: Path,
21
+ ) -> list[SkillSource]:
22
+ """Return every existing source matching one configured reference.
23
+
24
+ Args:
25
+ skill: Bare skill name or explicit path reference.
26
+ cwd: Project directory used for local and relative-path skills.
27
+ codex_home: Codex home directory.
28
+ codexmgr_home: codexmgr home directory.
29
+
30
+ Returns:
31
+ Matching sources in stable store-priority order.
32
+ """
33
+ if is_named_skill(skill):
34
+ return _named_skill_candidates(skill, cwd, codex_home, codexmgr_home)
35
+ source = _resolve_path_skill(skill, cwd)
36
+ return [] if source is None else [source]
37
+
38
+
39
+ def all_named_skill_sources(
40
+ cwd: Path,
41
+ codex_home: Path,
42
+ codexmgr_home: Path,
43
+ ) -> list[SkillSource]:
44
+ """Return all folder-addressable skill sources from configured stores.
45
+
46
+ Args:
47
+ cwd: Project directory whose local skills should be included.
48
+ codex_home: Codex home directory.
49
+ codexmgr_home: codexmgr home directory.
50
+
51
+ Returns:
52
+ Sources ordered by store and folder name.
53
+ """
54
+ stores = [
55
+ (CODEXMGR_HOME_SOURCE, codexmgr_home / "skills"),
56
+ (CODEX_HOME_SOURCE, codex_home / "skills"),
57
+ (LOCAL_SOURCE, cwd / ".agents" / "skills"),
58
+ ]
59
+ sources: list[SkillSource] = []
60
+ for source_type, root in stores:
61
+ for name in _skill_names(root):
62
+ skill_file = root / name / "SKILL.md"
63
+ sources.append(SkillSource(name, skill_file.resolve(), source_type))
64
+ return sources
65
+
66
+
67
+ def _named_skill_candidates(
68
+ name: str,
69
+ cwd: Path,
70
+ codex_home: Path,
71
+ codexmgr_home: Path,
72
+ ) -> list[SkillSource]:
73
+ """Return every store source whose folder matches a bare name.
74
+
75
+ Args:
76
+ name: Bare skill folder name.
77
+ cwd: Project directory used for local skills.
78
+ codex_home: Codex home directory.
79
+ codexmgr_home: codexmgr home directory.
80
+
81
+ Returns:
82
+ Existing sources in codexmgr-home, Codex-home, then project order.
83
+ """
84
+ candidates = [
85
+ SkillSource(
86
+ name,
87
+ (codexmgr_home / "skills" / name / "SKILL.md").resolve(),
88
+ CODEXMGR_HOME_SOURCE,
89
+ ),
90
+ SkillSource(
91
+ name,
92
+ (codex_home / "skills" / name / "SKILL.md").resolve(),
93
+ CODEX_HOME_SOURCE,
94
+ ),
95
+ SkillSource(
96
+ name,
97
+ (project_skill_dir(cwd, name) / "SKILL.md").resolve(),
98
+ LOCAL_SOURCE,
99
+ ),
100
+ ]
101
+ return [candidate for candidate in candidates if candidate.skill_file.is_file()]
102
+
103
+
104
+ def _resolve_path_skill(skill: str, cwd: Path) -> SkillSource | None:
105
+ """Resolve a path-like reference without applying named-store priority.
106
+
107
+ Args:
108
+ skill: Path-like skill reference.
109
+ cwd: Project directory used for relative paths.
110
+
111
+ Returns:
112
+ Resolved explicit source, or None when it does not exist.
113
+ """
114
+ path = Path(skill).expanduser()
115
+ if not path.is_absolute():
116
+ path = cwd / path
117
+ skill_file = path if path.name == "SKILL.md" else path / "SKILL.md"
118
+ if not skill_file.is_file():
119
+ return None
120
+ return SkillSource(skill, skill_file.resolve(), PATH_SOURCE)
121
+
122
+
123
+ def _skill_names(skills_dir: Path) -> list[str]:
124
+ """Return sorted child-folder names containing a skill file.
125
+
126
+ Args:
127
+ skills_dir: Store directory containing named skill folders.
128
+
129
+ Returns:
130
+ Sorted folder names with an immediate ``SKILL.md`` child.
131
+ """
132
+ if not skills_dir.is_dir():
133
+ return []
134
+ return sorted(
135
+ path.name
136
+ for path in skills_dir.iterdir()
137
+ if path.is_dir() and (path / "SKILL.md").is_file()
138
+ )
@@ -8,6 +8,7 @@ from typing import Any
8
8
 
9
9
  from ..core.errors import CommandError
10
10
  from ..core.toml_io import plain_toml_value
11
+ from .sources import CODEXMGR_HOME_SOURCE, project_skill_dir
11
12
 
12
13
 
13
14
  @dataclass(frozen=True)
@@ -38,24 +39,39 @@ class SkillCopyFile:
38
39
  content: bytes
39
40
 
40
41
 
41
- def validate_copy_targets(copies: list[SkillCopy], previous_lock: Mapping[str, Any]) -> None:
42
+ def validate_copy_targets(
43
+ copies: list[SkillCopy],
44
+ previous_lock: Mapping[str, Any],
45
+ cwd: Path,
46
+ codexmgr_home: Path,
47
+ ) -> None:
42
48
  """Reject first-time copies over unmanaged target folders.
43
49
 
44
50
  Args:
45
51
  copies: Current managed copies to create or refresh.
46
52
  previous_lock: Previous codexmgr lock data.
53
+ cwd: Current project root used to rebind portable targets.
54
+ codexmgr_home: Current codexmgr home used to rebind portable sources.
47
55
  """
48
- previous = previous_skill_copies(previous_lock)
56
+ previous = previous_skill_copies(previous_lock, cwd, codexmgr_home)
49
57
  for copy in copies:
50
58
  if copy.name not in previous and copy.target.exists():
51
- raise CommandError(f"Refusing to overwrite unmanaged skill copy: {copy.target}")
59
+ raise CommandError(
60
+ f"Refusing to overwrite unmanaged skill copy: {copy.target}"
61
+ )
52
62
 
53
63
 
54
- def previous_skill_copies(previous_lock: Mapping[str, Any]) -> dict[str, SkillCopy]:
64
+ def previous_skill_copies(
65
+ previous_lock: Mapping[str, Any],
66
+ cwd: Path,
67
+ codexmgr_home: Path,
68
+ ) -> dict[str, SkillCopy]:
55
69
  """Read managed skill-copy metadata from previous lock data.
56
70
 
57
71
  Args:
58
72
  previous_lock: Parsed .codex/codexmgr.lock data.
73
+ cwd: Current project root used to rebind portable targets.
74
+ codexmgr_home: Current codexmgr home used to rebind portable sources.
59
75
 
60
76
  Returns:
61
77
  Previous managed copies keyed by skill name.
@@ -65,7 +81,7 @@ def previous_skill_copies(previous_lock: Mapping[str, Any]) -> dict[str, SkillCo
65
81
  raise CommandError("codexmgr.lock skills.copies must be a list")
66
82
  copies: dict[str, SkillCopy] = {}
67
83
  for raw_copy in raw_copies:
68
- copy = _copy_from_lock_entry(raw_copy)
84
+ copy = _copy_from_lock_entry(raw_copy, cwd, codexmgr_home)
69
85
  copies[copy.name] = copy
70
86
  return copies
71
87
 
@@ -73,12 +89,16 @@ def previous_skill_copies(previous_lock: Mapping[str, Any]) -> dict[str, SkillCo
73
89
  def obsolete_copy_targets(
74
90
  previous_lock: Mapping[str, Any],
75
91
  current_copies: list[SkillCopy],
92
+ cwd: Path,
93
+ codexmgr_home: Path,
76
94
  ) -> list[Path]:
77
95
  """Return previous managed copy targets absent from current state.
78
96
 
79
97
  Args:
80
98
  previous_lock: Previous codexmgr lock data.
81
99
  current_copies: Current managed copies.
100
+ cwd: Current project root used to rebind portable targets.
101
+ codexmgr_home: Current codexmgr home used to rebind portable sources.
82
102
 
83
103
  Returns:
84
104
  Sorted target directories to remove.
@@ -86,7 +106,11 @@ def obsolete_copy_targets(
86
106
  current_names = {copy.name for copy in current_copies}
87
107
  return sorted(
88
108
  copy.target
89
- for name, copy in previous_skill_copies(previous_lock).items()
109
+ for name, copy in previous_skill_copies(
110
+ previous_lock,
111
+ cwd,
112
+ codexmgr_home,
113
+ ).items()
90
114
  if name not in current_names
91
115
  )
92
116
 
@@ -103,8 +127,8 @@ def copy_lock_entries(copies: list[SkillCopy]) -> list[dict[str, str]]:
103
127
  return [
104
128
  {
105
129
  "name": copy.name,
106
- "source": str(copy.source.resolve()),
107
- "target": str(copy.target.resolve()),
130
+ "source": CODEXMGR_HOME_SOURCE,
131
+ "target": f".agents/skills/{copy.name}",
108
132
  }
109
133
  for copy in copies
110
134
  ]
@@ -156,11 +180,17 @@ def remove_skill_copy_target(target: Path) -> None:
156
180
  target.unlink()
157
181
 
158
182
 
159
- def _copy_from_lock_entry(raw_copy: Any) -> SkillCopy:
183
+ def _copy_from_lock_entry(
184
+ raw_copy: Any,
185
+ cwd: Path,
186
+ codexmgr_home: Path,
187
+ ) -> SkillCopy:
160
188
  """Parse one skill-copy lock entry.
161
189
 
162
190
  Args:
163
191
  raw_copy: Plain lock entry value.
192
+ cwd: Current project root used to rebind the target.
193
+ codexmgr_home: Current codexmgr home used to rebind the source.
164
194
 
165
195
  Returns:
166
196
  Parsed managed skill copy.
@@ -170,9 +200,20 @@ def _copy_from_lock_entry(raw_copy: Any) -> SkillCopy:
170
200
  name = raw_copy.get("name")
171
201
  source = raw_copy.get("source")
172
202
  target = raw_copy.get("target")
173
- if not isinstance(name, str) or not isinstance(source, str) or not isinstance(target, str):
174
- raise CommandError("codexmgr.lock skills.copies entries must include name, source, and target")
175
- return SkillCopy(name, Path(source), Path(target))
203
+ if not all(isinstance(value, str) for value in (name, source, target)):
204
+ raise CommandError(
205
+ "codexmgr.lock skills.copies entries must include name, source, "
206
+ "and target"
207
+ )
208
+ if name in {"", ".", ".."} or Path(name).name != name:
209
+ raise CommandError(
210
+ "codexmgr.lock skills.copies entries must use a safe skill name"
211
+ )
212
+ return SkillCopy(
213
+ name,
214
+ codexmgr_home / "skills" / name,
215
+ project_skill_dir(cwd, name),
216
+ )
176
217
 
177
218
 
178
219
  def _source_dirs(source: Path) -> list[Path]:
@@ -0,0 +1,100 @@
1
+ """Read and validate metadata declared by Codex skill instruction files."""
2
+
3
+ from collections.abc import Mapping
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ import yaml
8
+ from yaml import YAMLError
9
+
10
+ from ..core.errors import CommandError
11
+
12
+
13
+ def read_skill_name(skill_file: Path) -> str:
14
+ """Return the non-empty skill name declared in YAML frontmatter.
15
+
16
+ Args:
17
+ skill_file: Existing ``SKILL.md`` file to inspect.
18
+
19
+ Returns:
20
+ The trimmed frontmatter ``name`` value.
21
+
22
+ Raises:
23
+ CommandError: When frontmatter is missing, malformed, or has no valid
24
+ ``name`` value.
25
+ """
26
+ metadata = _read_frontmatter(skill_file)
27
+ name = metadata.get("name")
28
+ if not isinstance(name, str) or not name.strip():
29
+ raise _metadata_error(skill_file, "name must be a non-empty string")
30
+ return name.strip()
31
+
32
+
33
+ def try_read_skill_name(skill_file: Path) -> str | None:
34
+ """Return a declared skill name or ``None`` for unrelated invalid files.
35
+
36
+ Args:
37
+ skill_file: Existing ``SKILL.md`` file to inspect.
38
+
39
+ Returns:
40
+ The declared name when metadata is valid, otherwise ``None``.
41
+ """
42
+ try:
43
+ return read_skill_name(skill_file)
44
+ except CommandError:
45
+ return None
46
+
47
+
48
+ def _read_frontmatter(skill_file: Path) -> Mapping[str, Any]:
49
+ """Parse the YAML mapping at the beginning of one skill file.
50
+
51
+ Args:
52
+ skill_file: Existing ``SKILL.md`` file to inspect.
53
+
54
+ Returns:
55
+ Parsed YAML frontmatter mapping.
56
+
57
+ Raises:
58
+ CommandError: When the frontmatter boundaries or YAML are invalid.
59
+ """
60
+ lines = skill_file.read_text(encoding="utf-8").splitlines()
61
+ if not lines or lines[0].strip() != "---":
62
+ raise _metadata_error(skill_file, "missing YAML frontmatter")
63
+ closing_index = _closing_boundary(lines)
64
+ if closing_index is None:
65
+ raise _metadata_error(skill_file, "missing YAML frontmatter closing boundary")
66
+ try:
67
+ parsed = yaml.safe_load("\n".join(lines[1:closing_index]))
68
+ except YAMLError as exc:
69
+ raise _metadata_error(skill_file, "invalid YAML frontmatter") from exc
70
+ if not isinstance(parsed, Mapping):
71
+ raise _metadata_error(skill_file, "YAML frontmatter must be a mapping")
72
+ return parsed
73
+
74
+
75
+ def _closing_boundary(lines: list[str]) -> int | None:
76
+ """Return the closing frontmatter-boundary index when present.
77
+
78
+ Args:
79
+ lines: Complete skill file split into lines.
80
+
81
+ Returns:
82
+ Closing boundary index, or ``None`` when it is missing.
83
+ """
84
+ for index, line in enumerate(lines[1:], start=1):
85
+ if line.strip() == "---":
86
+ return index
87
+ return None
88
+
89
+
90
+ def _metadata_error(skill_file: Path, detail: str) -> CommandError:
91
+ """Build one consistently formatted metadata error.
92
+
93
+ Args:
94
+ skill_file: Invalid skill instruction file.
95
+ detail: Focused validation failure.
96
+
97
+ Returns:
98
+ Command error containing the resolved file location and detail.
99
+ """
100
+ return CommandError(f"Invalid skill metadata in {skill_file.resolve()}: {detail}")
@@ -0,0 +1,119 @@
1
+ """Resolve project skill configuration into generated state."""
2
+
3
+ from collections.abc import Mapping
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from .config import _skill_lists
9
+ from .copies import (
10
+ SkillCopy,
11
+ obsolete_copy_targets,
12
+ previous_skill_copies,
13
+ validate_copy_targets,
14
+ )
15
+ from .selector import select_skill
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class SkillResolution:
20
+ """Resolved skill configuration state.
21
+
22
+ Attributes:
23
+ entries: Generated Codex skills.config entries.
24
+ copies: Managed project-local skill copies.
25
+ obsolete_copy_targets: Previous managed copy targets to remove.
26
+ """
27
+
28
+ entries: list[dict[str, Any]]
29
+ copies: list[SkillCopy]
30
+ obsolete_copy_targets: list[Path]
31
+
32
+
33
+ def resolve_project_skills(
34
+ project_config: Mapping[str, Any],
35
+ cwd: Path,
36
+ codex_home: Path,
37
+ codexmgr_home: Path,
38
+ previous_lock: Mapping[str, Any],
39
+ ) -> SkillResolution:
40
+ """Resolve configured skills into generated config and copy state.
41
+
42
+ Args:
43
+ project_config: Parsed project codexmgr config.
44
+ cwd: Project directory.
45
+ codex_home: Codex home directory.
46
+ codexmgr_home: codexmgr home directory.
47
+ previous_lock: Previous codexmgr lock data.
48
+
49
+ Returns:
50
+ Resolved skill state.
51
+ """
52
+ enabled_skills, disabled_skills = _skill_lists(project_config)
53
+ previous_copies = previous_skill_copies(previous_lock, cwd, codexmgr_home)
54
+ copies: list[SkillCopy] = []
55
+ entries = [
56
+ *_skill_entries(
57
+ enabled_skills,
58
+ cwd,
59
+ codex_home,
60
+ codexmgr_home,
61
+ True,
62
+ copies,
63
+ previous_copies,
64
+ ),
65
+ *_skill_entries(
66
+ disabled_skills,
67
+ cwd,
68
+ codex_home,
69
+ codexmgr_home,
70
+ False,
71
+ copies,
72
+ previous_copies,
73
+ ),
74
+ ]
75
+ validate_copy_targets(copies, previous_lock, cwd, codexmgr_home)
76
+ return SkillResolution(
77
+ entries,
78
+ copies,
79
+ obsolete_copy_targets(previous_lock, copies, cwd, codexmgr_home),
80
+ )
81
+
82
+
83
+ def _skill_entries(
84
+ skills: list[str],
85
+ cwd: Path,
86
+ codex_home: Path,
87
+ codexmgr_home: Path,
88
+ enabled: bool,
89
+ copies: list[SkillCopy],
90
+ previous_copies: dict[str, SkillCopy],
91
+ ) -> list[dict[str, Any]]:
92
+ """Resolve a list of skills into generated entries.
93
+
94
+ Args:
95
+ skills: Skill references from project config.
96
+ cwd: Project directory.
97
+ codex_home: Codex home directory.
98
+ codexmgr_home: codexmgr home directory.
99
+ enabled: Desired enabled state.
100
+ copies: Mutable copy list receiving managed copies.
101
+ previous_copies: Previously managed copies rebound to this project.
102
+
103
+ Returns:
104
+ Generated Codex skills.config entries.
105
+ """
106
+ entries: list[dict[str, Any]] = []
107
+ for skill in skills:
108
+ selection = select_skill(
109
+ skill,
110
+ cwd,
111
+ codex_home,
112
+ codexmgr_home,
113
+ enabled,
114
+ previous_copies,
115
+ )
116
+ entries.append(selection.entry)
117
+ if selection.copy is not None:
118
+ copies.append(selection.copy)
119
+ return entries