codexmgr 0.4.0__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.
- {codexmgr-0.4.0 → codexmgr-0.5.0}/PKG-INFO +35 -17
- {codexmgr-0.4.0 → codexmgr-0.5.0}/README.md +33 -15
- {codexmgr-0.4.0 → codexmgr-0.5.0}/pyproject.toml +2 -2
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/__init__.py +1 -1
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/commands/codex_launch.py +12 -8
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/core/paths.py +12 -0
- codexmgr-0.5.0/src/codexmgr/skills/candidates.py +138 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/skills/copies.py +53 -12
- codexmgr-0.5.0/src/codexmgr/skills/metadata.py +100 -0
- codexmgr-0.5.0/src/codexmgr/skills/resolution.py +119 -0
- codexmgr-0.5.0/src/codexmgr/skills/selector.py +240 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/skills/sources.py +13 -9
- {codexmgr-0.4.0 → codexmgr-0.5.0}/tests/test_apply_check_cli.py +4 -1
- {codexmgr-0.4.0 → codexmgr-0.5.0}/tests/test_codex_cli.py +32 -15
- {codexmgr-0.4.0 → codexmgr-0.5.0}/tests/test_packages_cli.py +17 -3
- {codexmgr-0.4.0 → codexmgr-0.5.0}/tests/test_paths.py +12 -1
- {codexmgr-0.4.0 → codexmgr-0.5.0}/tests/test_project_gitignore.py +3 -2
- {codexmgr-0.4.0 → codexmgr-0.5.0}/tests/test_skill_copies_cli.py +55 -17
- {codexmgr-0.4.0 → codexmgr-0.5.0}/tests/test_skill_home_sources_cli.py +4 -1
- codexmgr-0.5.0/tests/test_skill_metadata.py +35 -0
- codexmgr-0.5.0/tests/test_skill_portability_cli.py +281 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/tests/test_skills_cli.py +17 -11
- {codexmgr-0.4.0 → codexmgr-0.5.0}/tests/test_status_doctor_cli.py +4 -1
- {codexmgr-0.4.0 → codexmgr-0.5.0}/tests/test_tui_app.py +4 -1
- {codexmgr-0.4.0 → codexmgr-0.5.0}/tests/test_tui_selection_focus.py +4 -1
- {codexmgr-0.4.0 → codexmgr-0.5.0}/tests/test_tui_state.py +4 -1
- {codexmgr-0.4.0 → codexmgr-0.5.0}/uv.lock +3 -3
- codexmgr-0.4.0/src/codexmgr/skills/resolution.py +0 -147
- {codexmgr-0.4.0 → codexmgr-0.5.0}/.github/actions/install_package/action.yml +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/.github/workflows/py_test.yml +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/.github/workflows/version_publish_main.yml +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/.gitignore +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/pytest.toml +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/agents/__init__.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/agents/file.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/agents/manager.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/agents/renderer.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/commands/__init__.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/commands/codex.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/commands/codex_jit.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/commands/config_mutations.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/commands/health.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/commands/navigation.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/commands/rules.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/core/__init__.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/core/errors.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/core/managed_text.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/core/options.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/core/toml_io.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/custom_agents/__init__.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/custom_agents/cli.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/custom_agents/config.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/custom_agents/copies.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/custom_agents/listing.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/custom_agents/resolution.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/custom_agents/sources.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/hooks/__init__.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/hooks/config.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/hooks/copies.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/hooks/listing.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/hooks/merge.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/hooks/resolution.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/hooks/sources.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/interface/__init__.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/interface/cli.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/interface/parser.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/interface/parsers/__init__.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/interface/parsers/agents.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/interface/parsers/mcp.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/mcp/__init__.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/mcp/apply.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/mcp/cli.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/mcp/config.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/mcp/discovery.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/mcp/fields.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/mcp/project.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/mcp/resolution.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/mcp/sources.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/mcp/tables.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/packages/__init__.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/packages/cli.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/packages/config.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/packages/listing.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/packages/mutation.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/packages/sources.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/project/__init__.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/project/apply.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/project/config.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/project/generated.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/project/gitignore.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/project/resolution.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/project/snapshot.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/project/state.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/project/sync.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/py.typed +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/rules/__init__.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/rules/config.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/rules/copies.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/rules/listing.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/rules/resolution.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/rules/sources.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/rules/tree.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/skills/__init__.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/skills/config.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/skills/listing.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/tui/__init__.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/tui/app.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/tui/cli.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/tui/diff.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/tui/focus.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/tui/items.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/tui/mcp_items.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/tui/models.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/tui/mutations.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/tui/package_refs.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/tui/package_selection.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/tui/package_state.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/tui/panels.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/tui/rendering.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/tui/rule_items.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/tui/rule_tree.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/tui/sections.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/tui/staged_packages.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/src/codexmgr/tui/state.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/tests/conftest.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/tests/test_agents_cli.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/tests/test_agents_file.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/tests/test_agentsmd_list_cli.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/tests/test_agentsmd_tools_cli.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/tests/test_cd_cli.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/tests/test_cli.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/tests/test_empty_skill_config_cli.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/tests/test_home_resolution_cli.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/tests/test_hooks_cli.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/tests/test_mcp_apply_cli.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/tests/test_mcp_cli.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/tests/test_package_metadata.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/tests/test_python_compatibility.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/tests/test_renderer.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/tests/test_rules_cli.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/tests/test_skill_list_cli.py +0 -0
- {codexmgr-0.4.0 → codexmgr-0.5.0}/tests/test_sync_cli.py +0 -0
- {codexmgr-0.4.0 → 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.
|
|
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
|
-
|
|
215
|
-
`$CODEX_HOME/skills
|
|
216
|
-
|
|
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
|
-
|
|
224
|
-
|
|
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
|
|
@@ -474,14 +487,19 @@ codexmgr codex --simple <args...>
|
|
|
474
487
|
```
|
|
475
488
|
|
|
476
489
|
By default, `codexmgr codex` applies the current project config, launches the
|
|
477
|
-
real `codex` command with `CODEX_HOME` set to
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
490
|
+
real `codex` command with `CODEX_HOME` set to
|
|
491
|
+
`<project>/.codex/.runtime`, and forwards Codex arguments unchanged. For a
|
|
492
|
+
trusted project, Codex discovers the tracked `.codex/config.toml` as its
|
|
493
|
+
project configuration independently of `CODEX_HOME`. This keeps generated MCP
|
|
494
|
+
servers and other shared project settings in Git while mutable Codex state is
|
|
495
|
+
written below the ignored `.codex/.runtime` directory. User-provided `-c` and
|
|
496
|
+
`--config` arguments retain their normal Codex behavior.
|
|
497
|
+
|
|
498
|
+
The wrapper links the global authentication file into
|
|
499
|
+
`.codex/.runtime/auth.json`. It uses `CODEX_GLOBAL_AUTH` when that variable is
|
|
500
|
+
non-empty and otherwise uses `$HOME/.codex/auth.json`. A missing auth source
|
|
501
|
+
produces a warning but does not stop Codex, which can then authenticate within
|
|
502
|
+
the project runtime home.
|
|
485
503
|
|
|
486
504
|
Use `--simple` immediately after `codex` to run the basic command without
|
|
487
505
|
applying project state, changing the child `CODEX_HOME`, or managing an auth
|
|
@@ -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
|
-
|
|
191
|
-
`$CODEX_HOME/skills
|
|
192
|
-
|
|
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
|
-
|
|
200
|
-
|
|
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
|
|
@@ -450,14 +463,19 @@ codexmgr codex --simple <args...>
|
|
|
450
463
|
```
|
|
451
464
|
|
|
452
465
|
By default, `codexmgr codex` applies the current project config, launches the
|
|
453
|
-
real `codex` command with `CODEX_HOME` set to
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
466
|
+
real `codex` command with `CODEX_HOME` set to
|
|
467
|
+
`<project>/.codex/.runtime`, and forwards Codex arguments unchanged. For a
|
|
468
|
+
trusted project, Codex discovers the tracked `.codex/config.toml` as its
|
|
469
|
+
project configuration independently of `CODEX_HOME`. This keeps generated MCP
|
|
470
|
+
servers and other shared project settings in Git while mutable Codex state is
|
|
471
|
+
written below the ignored `.codex/.runtime` directory. User-provided `-c` and
|
|
472
|
+
`--config` arguments retain their normal Codex behavior.
|
|
473
|
+
|
|
474
|
+
The wrapper links the global authentication file into
|
|
475
|
+
`.codex/.runtime/auth.json`. It uses `CODEX_GLOBAL_AUTH` when that variable is
|
|
476
|
+
non-empty and otherwise uses `$HOME/.codex/auth.json`. A missing auth source
|
|
477
|
+
produces a warning but does not stop Codex, which can then authenticate within
|
|
478
|
+
the project runtime home.
|
|
461
479
|
|
|
462
480
|
Use `--simple` immediately after `codex` to run the basic command without
|
|
463
481
|
applying project state, changing the child `CODEX_HOME`, or managing an auth
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "codexmgr"
|
|
3
|
-
version = "0.
|
|
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
|
|
|
@@ -6,6 +6,8 @@ from dataclasses import dataclass
|
|
|
6
6
|
from pathlib import Path
|
|
7
7
|
from typing import TextIO
|
|
8
8
|
|
|
9
|
+
from ..core.paths import project_codex_dir, project_codex_runtime_dir
|
|
10
|
+
|
|
9
11
|
|
|
10
12
|
@dataclass(frozen=True)
|
|
11
13
|
class CodexLaunchRequest:
|
|
@@ -47,19 +49,21 @@ def build_project_codex_environment(
|
|
|
47
49
|
environ: Source process environment, or ``os.environ`` when omitted.
|
|
48
50
|
|
|
49
51
|
Returns:
|
|
50
|
-
A copied child environment. ``CODEX_HOME`` points to
|
|
51
|
-
|
|
52
|
+
A copied child environment. ``CODEX_HOME`` points to
|
|
53
|
+
``.codex/.runtime`` when the project ``.codex`` directory contains
|
|
54
|
+
``config.toml``.
|
|
52
55
|
"""
|
|
53
56
|
environment = dict(os.environ if environ is None else environ)
|
|
54
|
-
|
|
55
|
-
if not (
|
|
57
|
+
config_dir = project_codex_dir(cwd)
|
|
58
|
+
if not (config_dir / "config.toml").is_file():
|
|
56
59
|
return environment
|
|
57
60
|
|
|
58
|
-
|
|
59
|
-
|
|
61
|
+
runtime_dir = project_codex_runtime_dir(cwd)
|
|
62
|
+
runtime_dir.mkdir(parents=True, exist_ok=True)
|
|
63
|
+
environment["CODEX_HOME"] = str(runtime_dir)
|
|
60
64
|
global_auth = _global_auth_path(environment)
|
|
61
65
|
if global_auth.is_file():
|
|
62
|
-
_replace_auth_link(
|
|
66
|
+
_replace_auth_link(runtime_dir / "auth.json", global_auth)
|
|
63
67
|
else:
|
|
64
68
|
stderr.write(
|
|
65
69
|
"codexmgr codex: warning: no global auth found at "
|
|
@@ -87,7 +91,7 @@ def _replace_auth_link(auth_link: Path, global_auth: Path) -> None:
|
|
|
87
91
|
"""Replace the project auth entry with a link to global authentication.
|
|
88
92
|
|
|
89
93
|
Args:
|
|
90
|
-
auth_link: Project-local ``.codex/auth.json`` link path.
|
|
94
|
+
auth_link: Project-local ``.codex/.runtime/auth.json`` link path.
|
|
91
95
|
global_auth: Existing global authentication source file.
|
|
92
96
|
|
|
93
97
|
Returns:
|
|
@@ -36,6 +36,18 @@ def project_codex_dir(cwd: Path) -> Path:
|
|
|
36
36
|
return cwd / ".codex"
|
|
37
37
|
|
|
38
38
|
|
|
39
|
+
def project_codex_runtime_dir(cwd: Path) -> Path:
|
|
40
|
+
"""Return the project-local Codex runtime-home path.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
cwd: Project directory.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
The ignored ``.codex/.runtime`` directory used as ``CODEX_HOME``.
|
|
47
|
+
"""
|
|
48
|
+
return project_codex_dir(cwd) / ".runtime"
|
|
49
|
+
|
|
50
|
+
|
|
39
51
|
def config_path(cwd: Path) -> Path:
|
|
40
52
|
"""Return the project codexmgr.toml path.
|
|
41
53
|
|
|
@@ -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(
|
|
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(
|
|
59
|
+
raise CommandError(
|
|
60
|
+
f"Refusing to overwrite unmanaged skill copy: {copy.target}"
|
|
61
|
+
)
|
|
52
62
|
|
|
53
63
|
|
|
54
|
-
def previous_skill_copies(
|
|
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(
|
|
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":
|
|
107
|
-
"target":
|
|
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(
|
|
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(
|
|
174
|
-
raise CommandError(
|
|
175
|
-
|
|
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}")
|