codexmgr 0.1.0__py3-none-any.whl

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/paths.py ADDED
@@ -0,0 +1,137 @@
1
+ """Filesystem path helpers for codexmgr project and home directories."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ from .errors import CommandError
7
+
8
+
9
+ def global_codex_dir() -> Path:
10
+ """Return the Codex home directory.
11
+
12
+ Returns:
13
+ CODEX_HOME when set, otherwise ~/.codex.
14
+ """
15
+ return Path(os.environ.get("CODEX_HOME", Path.home() / ".codex"))
16
+
17
+
18
+ def global_codexmgr_dir() -> Path:
19
+ """Return the codexmgr home directory.
20
+
21
+ Returns:
22
+ CODEXMGR_HOME when set, otherwise ~/.codexmgr.
23
+ """
24
+ return Path(os.environ.get("CODEXMGR_HOME", Path.home() / ".codexmgr"))
25
+
26
+
27
+ def project_codex_dir(cwd: Path) -> Path:
28
+ """Return the project-local .codex directory path.
29
+
30
+ Args:
31
+ cwd: Project directory.
32
+
33
+ Returns:
34
+ The project .codex directory path.
35
+ """
36
+ return cwd / ".codex"
37
+
38
+
39
+ def config_path(cwd: Path) -> Path:
40
+ """Return the project codexmgr.toml path.
41
+
42
+ Args:
43
+ cwd: Project directory.
44
+
45
+ Returns:
46
+ Path to .codex/codexmgr.toml.
47
+ """
48
+ return project_codex_dir(cwd) / "codexmgr.toml"
49
+
50
+
51
+ def lock_path(cwd: Path) -> Path:
52
+ """Return the project codexmgr lockfile path.
53
+
54
+ Args:
55
+ cwd: Project directory.
56
+
57
+ Returns:
58
+ Path to .codex/codexmgr.lock.
59
+ """
60
+ return project_codex_dir(cwd) / "codexmgr.lock"
61
+
62
+
63
+ def codex_config_path(cwd: Path) -> Path:
64
+ """Return the generated project Codex config path.
65
+
66
+ Args:
67
+ cwd: Project directory.
68
+
69
+ Returns:
70
+ Path to .codex/config.toml.
71
+ """
72
+ return project_codex_dir(cwd) / "config.toml"
73
+
74
+
75
+ def agents_md_path(cwd: Path) -> Path:
76
+ """Return the project AGENTS.md path.
77
+
78
+ Args:
79
+ cwd: Project directory.
80
+
81
+ Returns:
82
+ Path to AGENTS.md in the project root.
83
+ """
84
+ return cwd / "AGENTS.md"
85
+
86
+
87
+ def resolve_template(reference: str, cwd: Path, codexmgr_home: Path) -> tuple[str, Path]:
88
+ """Resolve an AGENTS.md template reference to a source id and file path.
89
+
90
+ Args:
91
+ reference: Named template reference or path-like TOML file reference.
92
+ cwd: Project directory used for relative path references.
93
+ codexmgr_home: codexmgr home used for named template references.
94
+
95
+ Returns:
96
+ The source identifier and resolved template path.
97
+ """
98
+ if _is_named_template(reference):
99
+ source_id = reference
100
+ path = codexmgr_home / "agentsmd" / f"{reference}.toml"
101
+ else:
102
+ path = _expand_path(reference, cwd)
103
+ source_id = path.stem
104
+
105
+ if not path.is_file():
106
+ raise CommandError(f"Template not found: {path}")
107
+
108
+ return source_id, path
109
+
110
+
111
+ def _is_named_template(reference: str) -> bool:
112
+ """Return whether a template reference should resolve by name.
113
+
114
+ Args:
115
+ reference: Template reference from project configuration or CLI input.
116
+
117
+ Returns:
118
+ True when the reference is a bare template name.
119
+ """
120
+ raw = reference.strip()
121
+ return "/" not in raw and "\\" not in raw and not raw.endswith(".toml")
122
+
123
+
124
+ def _expand_path(reference: str, cwd: Path) -> Path:
125
+ """Expand a path-like template reference.
126
+
127
+ Args:
128
+ reference: Path-like template reference.
129
+ cwd: Project directory used for relative references.
130
+
131
+ Returns:
132
+ Absolute or project-relative template path.
133
+ """
134
+ path = Path(reference).expanduser()
135
+ if path.is_absolute():
136
+ return path
137
+ return cwd / path
codexmgr/project.py ADDED
@@ -0,0 +1,74 @@
1
+ """Project-level codexmgr orchestration commands."""
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ from .agentsmd import resolve_locked_agents_md, write_agents_md
7
+ from .paths import codex_config_path, lock_path, project_codex_dir
8
+ from .project_config import load_required_project_config
9
+ from .skills import build_codex_skill_config, resolve_codex_skill_entries
10
+ from .toml_io import write_toml_file
11
+
12
+
13
+ def setup_project(cwd: Path) -> Path:
14
+ """Create the project .codex directory.
15
+
16
+ Args:
17
+ cwd: Project directory to initialize.
18
+
19
+ Returns:
20
+ The created or existing .codex directory path.
21
+ """
22
+ codex_dir = project_codex_dir(cwd)
23
+ codex_dir.mkdir(parents=True, exist_ok=True)
24
+ return codex_dir
25
+
26
+
27
+ def apply_project_config(cwd: Path, codex_home: Path, codexmgr_home: Path) -> None:
28
+ """Apply project codexmgr configuration to generated Codex files.
29
+
30
+ Args:
31
+ cwd: Project directory whose .codex/codexmgr.toml should be applied.
32
+ codex_home: Global Codex home used to resolve named skills.
33
+ codexmgr_home: codexmgr home used to resolve named AGENTS.md sources.
34
+ """
35
+ config = load_required_project_config(cwd)
36
+ locked_agents_md = resolve_locked_agents_md(config, cwd, codexmgr_home)
37
+ skill_entries = resolve_codex_skill_entries(config, cwd, codex_home)
38
+ skills_configured = "skills" in config
39
+ codex_config = build_codex_skill_config(
40
+ skill_entries,
41
+ cwd,
42
+ include_empty=skills_configured,
43
+ )
44
+ lock_data = _lock_data(config, locked_agents_md, skill_entries)
45
+
46
+ if lock_data:
47
+ write_toml_file(lock_path(cwd), lock_data)
48
+ if "agents_md" in config:
49
+ write_agents_md(cwd, locked_agents_md)
50
+ if codex_config is not None:
51
+ write_toml_file(codex_config_path(cwd), codex_config)
52
+
53
+
54
+ def _lock_data(
55
+ config: dict[str, Any],
56
+ locked_agents_md: dict[str, Any],
57
+ skill_entries: list[dict[str, Any]],
58
+ ) -> dict[str, Any]:
59
+ """Build lockfile data for configured AGENTS.md sources and skills.
60
+
61
+ Args:
62
+ config: Parsed project codexmgr configuration.
63
+ locked_agents_md: Resolved AGENTS.md source data.
64
+ skill_entries: Resolved Codex skill configuration entries.
65
+
66
+ Returns:
67
+ Lockfile data to write, or an empty dictionary when nothing is configured.
68
+ """
69
+ lock_data: dict[str, Any] = {}
70
+ if "agents_md" in config:
71
+ lock_data["agents_md"] = locked_agents_md
72
+ if "skills" in config:
73
+ lock_data["skills"] = {"config": skill_entries}
74
+ return lock_data
@@ -0,0 +1,70 @@
1
+ """Helpers for reading and updating project codexmgr configuration."""
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ from .errors import CommandError
7
+ from .paths import config_path, project_codex_dir
8
+ from .toml_io import load_toml_file
9
+
10
+
11
+ def require_codex_dir(cwd: Path) -> Path:
12
+ """Return the project .codex directory or fail if it is missing.
13
+
14
+ Args:
15
+ cwd: Project directory to inspect.
16
+
17
+ Returns:
18
+ The project .codex directory path.
19
+ """
20
+ codex_dir = project_codex_dir(cwd)
21
+ if not codex_dir.is_dir():
22
+ raise CommandError(f"Project .codex directory not found: {codex_dir}")
23
+ return codex_dir
24
+
25
+
26
+ def load_required_project_config(cwd: Path) -> dict[str, Any]:
27
+ """Load .codex/codexmgr.toml or fail if it is missing.
28
+
29
+ Args:
30
+ cwd: Project directory whose codexmgr.toml should be loaded.
31
+
32
+ Returns:
33
+ Parsed project configuration.
34
+ """
35
+ require_codex_dir(cwd)
36
+ path = config_path(cwd)
37
+ if not path.is_file():
38
+ raise CommandError(f"Project codexmgr.toml not found: {path}")
39
+ return load_toml_file(path)
40
+
41
+
42
+ def agents_md_sources(config: dict[str, Any]) -> list[str]:
43
+ """Return the configured AGENTS.md sources from project config.
44
+
45
+ Args:
46
+ config: Parsed .codex/codexmgr.toml content.
47
+
48
+ Returns:
49
+ The agents_md.src list.
50
+ """
51
+ agents_md = config.get("agents_md", {})
52
+ if not isinstance(agents_md, dict):
53
+ raise CommandError("codexmgr.toml [agents_md] must be a table")
54
+ sources = agents_md.get("src", [])
55
+ if not isinstance(sources, list) or not all(isinstance(item, str) for item in sources):
56
+ raise CommandError("codexmgr.toml agents_md.src must be a list of strings")
57
+ return list(sources)
58
+
59
+
60
+ def set_agents_md_sources(config: dict[str, Any], sources: list[str]) -> None:
61
+ """Set the AGENTS.md source list in project config.
62
+
63
+ Args:
64
+ config: Parsed .codex/codexmgr.toml content to mutate.
65
+ sources: Source list to write as agents_md.src.
66
+ """
67
+ agents_md = config.setdefault("agents_md", {})
68
+ if not isinstance(agents_md, dict):
69
+ raise CommandError("codexmgr.toml [agents_md] must be a table")
70
+ agents_md["src"] = sources
codexmgr/py.typed ADDED
@@ -0,0 +1 @@
1
+
codexmgr/renderer.py ADDED
@@ -0,0 +1,131 @@
1
+ """Render locked AGENTS.md template data into managed markdown."""
2
+
3
+ from collections import OrderedDict
4
+ from collections.abc import Mapping
5
+ from dataclasses import dataclass, field
6
+ from typing import Any
7
+
8
+ from .errors import CommandError
9
+
10
+
11
+ @dataclass
12
+ class RenderNode:
13
+ """A merged markdown section from one or more TOML templates.
14
+
15
+ Attributes:
16
+ texts: Ordered text blocks attached to this markdown heading.
17
+ children: Child headings keyed by their TOML table name.
18
+ """
19
+
20
+ texts: list[str] = field(default_factory=list)
21
+ children: OrderedDict[str, "RenderNode"] = field(default_factory=OrderedDict)
22
+
23
+
24
+ def render_agents_markdown(sources: Mapping[str, Any]) -> str:
25
+ """Render resolved AGENTS.md template data into markdown.
26
+
27
+ Args:
28
+ sources: Mapping of source identifiers to parsed TOML template tables.
29
+
30
+ Returns:
31
+ Markdown content for the managed AGENTS.md block.
32
+ """
33
+ root = RenderNode()
34
+
35
+ for source_data in sources.values():
36
+ if not isinstance(source_data, Mapping):
37
+ raise CommandError("Source entries must be TOML tables")
38
+ _merge_source(root, source_data)
39
+
40
+ blocks = _render_children(root, 1)
41
+ return "\n\n".join(blocks) + ("\n" if blocks else "")
42
+
43
+
44
+ def _merge_source(parent: RenderNode, source_data: Mapping[str, Any]) -> None:
45
+ """Merge one parsed template source into the render tree.
46
+
47
+ Args:
48
+ parent: Root render node that receives template sections.
49
+ source_data: Parsed TOML table for one configured source.
50
+ """
51
+ for name, value in source_data.items():
52
+ if not isinstance(value, Mapping):
53
+ raise CommandError(f"Template section must be a table: {name}")
54
+ _merge_table(parent, (name,), value)
55
+
56
+
57
+ def _merge_table(root: RenderNode, path: tuple[str, ...], table: Mapping[str, Any]) -> None:
58
+ """Merge a TOML table into the render tree.
59
+
60
+ Args:
61
+ root: Root render node that receives section data.
62
+ path: Current TOML path being merged.
63
+ table: Parsed TOML table at the current path.
64
+ """
65
+ node = _node_for_path(root, path)
66
+ text = table.get("text")
67
+ if text is not None:
68
+ if not isinstance(text, str):
69
+ raise CommandError(f"Text entry must be a string: {'.'.join(path)}")
70
+ stripped = text.strip("\n")
71
+ if stripped:
72
+ node.texts.append(stripped)
73
+
74
+ for name, value in table.items():
75
+ if name == "text":
76
+ continue
77
+ if not isinstance(value, Mapping):
78
+ section = ".".join((*path, name))
79
+ raise CommandError(f"Unsupported template entry: {section}")
80
+ _merge_table(root, (*path, name), value)
81
+
82
+
83
+ def _node_for_path(root: RenderNode, path: tuple[str, ...]) -> RenderNode:
84
+ """Return or create the render node for a TOML path.
85
+
86
+ Args:
87
+ root: Root render node to traverse from.
88
+ path: TOML table path to resolve.
89
+
90
+ Returns:
91
+ The render node for the requested path.
92
+ """
93
+ node = root
94
+ for part in path:
95
+ if part not in node.children:
96
+ node.children[part] = RenderNode()
97
+ node = node.children[part]
98
+ return node
99
+
100
+
101
+ def _render_children(parent: RenderNode, level: int) -> list[str]:
102
+ """Render all child nodes under a parent.
103
+
104
+ Args:
105
+ parent: Render node whose children should be rendered.
106
+ level: Markdown heading level to use for direct children.
107
+
108
+ Returns:
109
+ Rendered markdown blocks for all descendants.
110
+ """
111
+ blocks: list[str] = []
112
+ for name, node in parent.children.items():
113
+ blocks.extend(_render_node(name, node, level))
114
+ return blocks
115
+
116
+
117
+ def _render_node(name: str, node: RenderNode, level: int) -> list[str]:
118
+ """Render one node and its descendants.
119
+
120
+ Args:
121
+ name: Markdown heading text.
122
+ node: Render node containing body text and child sections.
123
+ level: Markdown heading level for this node.
124
+
125
+ Returns:
126
+ Rendered markdown blocks for the node and descendants.
127
+ """
128
+ heading = f"{'#' * level} {name}"
129
+ body = "\n\n".join(node.texts)
130
+ block = f"{heading}\n{body}" if body else heading
131
+ return [block, *_render_children(node, level + 1)]
codexmgr/skills.py ADDED
@@ -0,0 +1,286 @@
1
+ """Manage skill enable and disable lists in project configuration."""
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ from .errors import CommandError
7
+ from .paths import codex_config_path, config_path, project_codex_dir
8
+ from .toml_io import load_optional_toml_file, write_toml_file
9
+
10
+
11
+ def enable_skill(skill: str, cwd: Path) -> str:
12
+ """Add a skill to [skills].enabled and remove it from disabled.
13
+
14
+ Args:
15
+ skill: Skill name or path to record in the project configuration.
16
+ cwd: Project directory whose .codex/codexmgr.toml should be updated.
17
+
18
+ Returns:
19
+ The skill value that was enabled.
20
+ """
21
+ return _set_skill_state(skill, cwd, enabled=True)
22
+
23
+
24
+ def disable_skill(skill: str, cwd: Path) -> str:
25
+ """Add a skill to [skills].disabled and remove it from enabled.
26
+
27
+ Args:
28
+ skill: Skill name or path to record in the project configuration.
29
+ cwd: Project directory whose .codex/codexmgr.toml should be updated.
30
+
31
+ Returns:
32
+ The skill value that was disabled.
33
+ """
34
+ return _set_skill_state(skill, cwd, enabled=False)
35
+
36
+
37
+ def build_codex_skill_config(
38
+ entries: list[dict[str, Any]],
39
+ cwd: Path,
40
+ *,
41
+ include_empty: bool = False,
42
+ ) -> dict[str, Any] | None:
43
+ """Build .codex/config.toml content for configured skill states.
44
+
45
+ Args:
46
+ entries: Resolved skills.config entries to write.
47
+ cwd: Project directory whose .codex/config.toml should be updated.
48
+ include_empty: Whether to write an empty skills.config list when no
49
+ skill entries are configured.
50
+
51
+ Returns:
52
+ A parsed .codex/config.toml document with updated skills.config entries,
53
+ or None when no skills are configured and include_empty is false.
54
+ """
55
+ if not entries and not include_empty:
56
+ return None
57
+
58
+ codex_config = load_optional_toml_file(codex_config_path(cwd))
59
+ skills = codex_config.setdefault("skills", {})
60
+ if not isinstance(skills, dict):
61
+ raise CommandError(".codex/config.toml [skills] must be a table")
62
+ skills["config"] = entries
63
+ return codex_config
64
+
65
+
66
+ def resolve_codex_skill_entries(
67
+ project_config: dict[str, Any],
68
+ cwd: Path,
69
+ codex_home: Path,
70
+ ) -> list[dict[str, Any]]:
71
+ """Resolve configured project skills into Codex skill config entries.
72
+
73
+ Args:
74
+ project_config: Parsed .codex/codexmgr.toml content.
75
+ cwd: Project directory used to resolve relative skill paths.
76
+ codex_home: Global Codex home used to resolve named skills.
77
+
78
+ Returns:
79
+ A list of dictionaries suitable for [[skills.config]] entries.
80
+ """
81
+ enabled_skills, disabled_skills = _skill_lists(project_config)
82
+ return [
83
+ *_skill_config_entries(enabled_skills, cwd, codex_home, enabled=True),
84
+ *_skill_config_entries(disabled_skills, cwd, codex_home, enabled=False),
85
+ ]
86
+
87
+
88
+ def _set_skill_state(skill: str, cwd: Path, *, enabled: bool) -> str:
89
+ """Set one skill reference to enabled or disabled in project config.
90
+
91
+ Args:
92
+ skill: Skill name or path to update.
93
+ cwd: Project directory whose codexmgr.toml should be updated.
94
+ enabled: Desired skill state.
95
+
96
+ Returns:
97
+ The updated skill reference.
98
+ """
99
+ _require_codex_dir(cwd)
100
+ config = load_optional_toml_file(config_path(cwd))
101
+ enabled_skills, disabled_skills = _skill_lists(config)
102
+
103
+ if enabled:
104
+ enabled_skills = _append_once(enabled_skills, skill)
105
+ disabled_skills = _without(disabled_skills, skill)
106
+ else:
107
+ disabled_skills = _append_once(disabled_skills, skill)
108
+ enabled_skills = _without(enabled_skills, skill)
109
+
110
+ _set_skill_lists(config, enabled_skills, disabled_skills)
111
+ write_toml_file(config_path(cwd), config)
112
+ return skill
113
+
114
+
115
+ def _require_codex_dir(cwd: Path) -> Path:
116
+ """Return the project .codex directory or fail when it is missing.
117
+
118
+ Args:
119
+ cwd: Project directory to inspect.
120
+
121
+ Returns:
122
+ Path to the project .codex directory.
123
+ """
124
+ codex_dir = project_codex_dir(cwd)
125
+ if not codex_dir.is_dir():
126
+ raise CommandError(f"Project .codex directory not found: {codex_dir}")
127
+ return codex_dir
128
+
129
+
130
+ def _skill_lists(config: dict[str, Any]) -> tuple[list[str], list[str]]:
131
+ """Read enabled and disabled skill lists from project config.
132
+
133
+ Args:
134
+ config: Parsed project codexmgr configuration.
135
+
136
+ Returns:
137
+ The enabled list and disabled list.
138
+ """
139
+ skills = config.get("skills", {})
140
+ if not isinstance(skills, dict):
141
+ raise CommandError("codexmgr.toml [skills] must be a table")
142
+ return _string_list(skills, "enabled"), _string_list(skills, "disabled")
143
+
144
+
145
+ def _string_list(table: dict[str, Any], key: str) -> list[str]:
146
+ """Read a string list from a project config table.
147
+
148
+ Args:
149
+ table: TOML table to inspect.
150
+ key: List key to read.
151
+
152
+ Returns:
153
+ A shallow copy of the configured string list.
154
+ """
155
+ values = table.get(key, [])
156
+ if not isinstance(values, list) or not all(isinstance(item, str) for item in values):
157
+ raise CommandError(f"codexmgr.toml skills.{key} must be a list of strings")
158
+ return list(values)
159
+
160
+
161
+ def _set_skill_lists(config: dict[str, Any], enabled: list[str], disabled: list[str]) -> None:
162
+ """Write enabled and disabled skill lists into project config.
163
+
164
+ Args:
165
+ config: Parsed project codexmgr configuration to mutate.
166
+ enabled: Skill references to write to skills.enabled.
167
+ disabled: Skill references to write to skills.disabled.
168
+ """
169
+ skills = config.setdefault("skills", {})
170
+ if not isinstance(skills, dict):
171
+ raise CommandError("codexmgr.toml [skills] must be a table")
172
+ skills["enabled"] = enabled
173
+ skills["disabled"] = disabled
174
+
175
+
176
+ def _append_once(values: list[str], value: str) -> list[str]:
177
+ """Append a value to a list only when it is absent.
178
+
179
+ Args:
180
+ values: Existing values.
181
+ value: Value to append.
182
+
183
+ Returns:
184
+ A list containing the value once.
185
+ """
186
+ if value in values:
187
+ return values
188
+ return [*values, value]
189
+
190
+
191
+ def _without(values: list[str], value: str) -> list[str]:
192
+ """Remove all matching values from a list.
193
+
194
+ Args:
195
+ values: Existing values.
196
+ value: Value to remove.
197
+
198
+ Returns:
199
+ Filtered list.
200
+ """
201
+ return [item for item in values if item != value]
202
+
203
+
204
+ def _skill_config_entries(
205
+ skills: list[str],
206
+ cwd: Path,
207
+ codex_home: Path,
208
+ *,
209
+ enabled: bool,
210
+ ) -> list[dict[str, Any]]:
211
+ """Resolve a list of skill references into Codex config entries.
212
+
213
+ Args:
214
+ skills: Skill names or paths from project config.
215
+ cwd: Project directory used to resolve relative paths.
216
+ codex_home: Global Codex home used to resolve named skills.
217
+ enabled: State to write for each entry.
218
+
219
+ Returns:
220
+ Resolved Codex skills.config entries.
221
+ """
222
+ return [
223
+ _skill_config_entry(skill, cwd, codex_home, enabled=enabled)
224
+ for skill in skills
225
+ ]
226
+
227
+
228
+ def _skill_config_entry(
229
+ skill: str,
230
+ cwd: Path,
231
+ codex_home: Path,
232
+ *,
233
+ enabled: bool,
234
+ ) -> dict[str, Any]:
235
+ """Resolve one skill reference into a Codex config entry.
236
+
237
+ Args:
238
+ skill: Skill name or path from project config.
239
+ cwd: Project directory used to resolve relative paths.
240
+ codex_home: Global Codex home used to resolve named skills.
241
+ enabled: State to write for the entry.
242
+
243
+ Returns:
244
+ A path-based entry when SKILL.md exists, otherwise a name-based entry.
245
+ """
246
+ skill_file = _resolve_skill_file(skill, cwd, codex_home)
247
+ if skill_file is None:
248
+ return {"name": skill, "enabled": enabled}
249
+ return {"path": str(skill_file), "enabled": enabled}
250
+
251
+
252
+ def _resolve_skill_file(skill: str, cwd: Path, codex_home: Path) -> Path | None:
253
+ """Resolve a skill name or path to an existing SKILL.md file.
254
+
255
+ Args:
256
+ skill: Skill name or path from project config.
257
+ cwd: Project directory used to resolve relative paths.
258
+ codex_home: Global Codex home used to resolve named skills.
259
+
260
+ Returns:
261
+ Absolute SKILL.md path, or None when it cannot be resolved.
262
+ """
263
+ if _is_named_skill(skill):
264
+ skill_file = codex_home / "skills" / skill / "SKILL.md"
265
+ else:
266
+ path = Path(skill).expanduser()
267
+ if not path.is_absolute():
268
+ path = cwd / path
269
+ skill_file = path if path.name == "SKILL.md" else path / "SKILL.md"
270
+
271
+ if not skill_file.is_file():
272
+ return None
273
+ return skill_file.resolve()
274
+
275
+
276
+ def _is_named_skill(skill: str) -> bool:
277
+ """Return whether a skill reference should resolve from CODEX_HOME.
278
+
279
+ Args:
280
+ skill: Skill reference from project configuration or CLI input.
281
+
282
+ Returns:
283
+ True when the reference is a bare skill name.
284
+ """
285
+ raw = skill.strip()
286
+ return "/" not in raw and "\\" not in raw and raw != "SKILL.md"