mirror of
https://github.com/github/spec-kit.git
synced 2026-07-03 12:28:06 +08:00
* fix(codex): inject dot-to-hyphen hook command note in Codex skills Hook commands in `.specify/extensions.yml` use dotted ids like `speckit.git.commit`, but Codex skills are named with hyphens (`speckit-git-commit`). The Claude integration handles this via an explicit instruction injected into each generated SKILL.md by `ClaudeIntegration.post_process_skill_content`, but the Codex integration had no such override, so Codex would emit `/speckit.git.commit` (which does not resolve) instead of `/speckit-git-commit`. This adds the same `_inject_hook_command_note` helper and a `post_process_skill_content` override to `CodexIntegration`, plus a small `setup()` override that applies the post-process to each generated SKILL.md (mirroring the pattern in `ClaudeIntegration`). Also widens the existing `test_non_claude_post_process_is_identity` test to use `agy` (another `SkillsIntegration` with no override), since asserting identity behavior on Codex would now incorrectly fail. Tests: - New `TestCodexHookCommandNote` class mirrors `TestClaudeHookCommandNote`: setup-level injection, no-op when no hook block is present, idempotency, and indentation preservation. - `pytest tests/` → 2866 passed, 34 skipped. Signed-off-by: Chao Zhang <1175468+picklebento@users.noreply.github.com> * fix(codex): handle empty eol when instruction is final line without newline The hook-note injection regex allowed end-of-string matches via ``$``, which left the captured ``eol`` empty. When the matched indent was also empty, the substitution concatenated the note onto the same line as the instruction. Default ``eol`` to ``\n`` when the capture is empty. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Signed-off-by: Chao Zhang <1175468+picklebento@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
137 lines
4.3 KiB
Python
137 lines
4.3 KiB
Python
"""Codex CLI integration — skills-based agent.
|
|
|
|
Codex uses the ``.agents/skills/speckit-<name>/SKILL.md`` layout.
|
|
Commands are deprecated; ``--skills`` defaults to ``True``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from ..base import IntegrationOption, SkillsIntegration
|
|
from ..manifest import IntegrationManifest
|
|
|
|
# Note injected into hook sections so Codex maps dot-notation command
|
|
# names (from extensions.yml) to the hyphenated skill names it uses.
|
|
# Without this, Codex emits ``/speckit.git.commit`` (which does not
|
|
# resolve) instead of ``/speckit-git-commit``.
|
|
_HOOK_COMMAND_NOTE = (
|
|
"- When constructing slash commands from hook command names, "
|
|
"replace dots (`.`) with hyphens (`-`). "
|
|
"For example, `speckit.git.commit` → `/speckit-git-commit`.\n"
|
|
)
|
|
|
|
|
|
class CodexIntegration(SkillsIntegration):
|
|
"""Integration for OpenAI Codex CLI."""
|
|
|
|
key = "codex"
|
|
config = {
|
|
"name": "Codex CLI",
|
|
"folder": ".agents/",
|
|
"commands_subdir": "skills",
|
|
"install_url": "https://github.com/openai/codex",
|
|
"requires_cli": True,
|
|
}
|
|
registrar_config = {
|
|
"dir": ".agents/skills",
|
|
"format": "markdown",
|
|
"args": "$ARGUMENTS",
|
|
"extension": "/SKILL.md",
|
|
}
|
|
context_file = "AGENTS.md"
|
|
multi_install_safe = True
|
|
|
|
def build_exec_args(
|
|
self,
|
|
prompt: str,
|
|
*,
|
|
model: str | None = None,
|
|
output_json: bool = True,
|
|
) -> list[str] | None:
|
|
# Codex uses ``codex exec "prompt"`` for non-interactive mode.
|
|
args: list[str] = ["codex", "exec", prompt]
|
|
if model:
|
|
args.extend(["--model", model])
|
|
if output_json:
|
|
args.append("--json")
|
|
return args
|
|
|
|
@classmethod
|
|
def options(cls) -> list[IntegrationOption]:
|
|
return [
|
|
IntegrationOption(
|
|
"--skills",
|
|
is_flag=True,
|
|
default=True,
|
|
help="Install as agent skills (default for Codex)",
|
|
),
|
|
]
|
|
|
|
@staticmethod
|
|
def _inject_hook_command_note(content: str) -> str:
|
|
"""Insert a dot-to-hyphen note before each hook output instruction.
|
|
|
|
Targets the line ``- For each executable hook, output the following``
|
|
and inserts the note on the line before it, matching its indentation.
|
|
Skips if the note is already present.
|
|
"""
|
|
if "replace dots" in content:
|
|
return content
|
|
|
|
def repl(m: re.Match[str]) -> str:
|
|
indent = m.group(1)
|
|
instruction = m.group(2)
|
|
# ``eol`` is empty when the regex matched via ``$`` because the
|
|
# instruction was the final line of a file with no trailing
|
|
# newline. Default to ``\n`` so the note never collapses onto
|
|
# the same line as the instruction.
|
|
eol = m.group(3) or "\n"
|
|
return (
|
|
indent
|
|
+ _HOOK_COMMAND_NOTE.rstrip("\n")
|
|
+ eol
|
|
+ indent
|
|
+ instruction
|
|
+ eol
|
|
)
|
|
|
|
return re.sub(
|
|
r"(?m)^(\s*)(- For each executable hook, output the following[^\r\n]*)(\r\n|\n|$)",
|
|
repl,
|
|
content,
|
|
)
|
|
|
|
def post_process_skill_content(self, content: str) -> str:
|
|
"""Inject the dot-to-hyphen hook command note."""
|
|
return self._inject_hook_command_note(content)
|
|
|
|
def setup(
|
|
self,
|
|
project_root: Path,
|
|
manifest: IntegrationManifest,
|
|
parsed_options: dict[str, Any] | None = None,
|
|
**opts: Any,
|
|
) -> list[Path]:
|
|
"""Install Codex skills, then inject the hook command note."""
|
|
created = super().setup(project_root, manifest, parsed_options, **opts)
|
|
|
|
skills_dir = self.skills_dest(project_root).resolve()
|
|
for path in created:
|
|
try:
|
|
path.resolve().relative_to(skills_dir)
|
|
except ValueError:
|
|
continue
|
|
if path.name != "SKILL.md":
|
|
continue
|
|
|
|
content = path.read_bytes().decode("utf-8")
|
|
updated = self.post_process_skill_content(content)
|
|
if updated != content:
|
|
path.write_bytes(updated.encode("utf-8"))
|
|
self.record_file_in_manifest(path, project_root, manifest)
|
|
|
|
return created
|