Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion docs/integrations/cline.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,14 @@ Invoke them in Cline's chat as `/speckit-specify` or
`/speckit-bug-assess` for the installed bug extension, not dotted commands.

Handoff references and extension/preset command content are also normalized
to hyphenated command names.
to hyphenated command names, so `/speckit.git.commit` in an extension command
becomes `/speckit-git-commit`. A `speckit.*.md` file name inside a path, such
as `.specify/extensions/git/commands/speckit.git.commit.md`,
`C:\speckit.foo.md`, `C:\Program Files (x86)\speckit.foo.md`,
`%TEMP%\speckit.foo.md`, or `./commands[old]/speckit.foo.md`, keeps its dots
and `.md` extension. A bare or
slash-prefixed token such as `/speckit.foo.md` is treated as a command
reference and hyphenated. Core command content is not rewritten this way.

## Workflow Limitation

Expand Down
36 changes: 5 additions & 31 deletions src/specify_cli/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,33 +92,6 @@ def _ensure_configs(cls) -> None:
except ImportError:
pass # Circular import during module init; retry on next access

@staticmethod
def _hyphenate_frontmatter_refs(val: Any) -> Any:
"""Recursively find any dotted references starting with speckit. and hyphenate them."""
if isinstance(val, dict):
return {
k: CommandRegistrar._hyphenate_frontmatter_refs(v)
for k, v in val.items()
}
elif isinstance(val, list):
return [CommandRegistrar._hyphenate_frontmatter_refs(x) for x in val]
elif isinstance(val, str):
return re.sub(
r"\bspeckit\.[A-Za-z0-9-_]+(?:\.[A-Za-z0-9-_]+)*\b",
lambda m: m.group(0).replace(".", "-"),
val,
)
return val

@staticmethod
def _hyphenate_body_refs(body: str) -> str:
"""Hyphenate dotted speckit references in command body text."""
return re.sub(
r"\bspeckit\.[A-Za-z0-9-_]+(?:\.[A-Za-z0-9-_]+)*\b",
lambda m: m.group(0).replace(".", "-"),
body,
)

@staticmethod
def parse_frontmatter(content: str) -> tuple[dict, str]:
"""Parse YAML frontmatter from Markdown content.
Expand Down Expand Up @@ -715,7 +688,6 @@ def register_commands(
commands_dir.mkdir(parents=True, exist_ok=True)

registered = []
is_cline_ext = agent_name == "cline" and source_id != "core"
source_root = source_dir.resolve()

# Resolve the command-reference separator for the file THIS registrar
Expand All @@ -738,6 +710,7 @@ def register_commands(
# ``.bob/commands``.
_sep = agent_config.get("invoke_separator", ".")
registrar_writes_skills = agent_config.get("extension") == "/SKILL.md"
_integ = None
try:
from specify_cli.integrations import get_integration # noqa: PLC0415

Expand Down Expand Up @@ -828,9 +801,10 @@ def register_commands(
format_name = agent_config.get("format_name")
frontmatter["name"] = format_name(cmd_name) if format_name else cmd_name

if is_cline_ext:
frontmatter = self._hyphenate_frontmatter_refs(frontmatter)
body = self._hyphenate_body_refs(body)
if _integ is not None:
frontmatter, body = _integ.process_registered_command(
frontmatter, body, source_id=source_id
)

body = self._convert_argument_placeholder(
body, "$ARGUMENTS", agent_config["args"]
Expand Down
20 changes: 20 additions & 0 deletions src/specify_cli/integrations/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,26 @@ def post_process_command_content(self, content: str) -> str:
"""
return content

def process_registered_command(
self, frontmatter: dict[str, Any], body: str, *, source_id: str
) -> tuple[dict[str, Any], str]:
"""Transform a command's frontmatter and body before it is rendered.

Called by ``CommandRegistrar.register_commands()`` for every command it
registers for this integration, after script paths are adjusted and the
``name`` field is injected, and before ``$ARGUMENTS`` and
``__SPECKIT_COMMAND_*__`` tokens are resolved and the command is
rendered. Unlike ``post_process_command_content()``, which only sees
the rendered text, this hook receives the parsed frontmatter and the
*source_id* (``"core"`` or an extension/preset id), so an integration
can apply rewrites that depend on where a command came from.

Subclasses may override it; they should return new objects rather
than mutating *frontmatter* in place. The default implementation
returns both values unchanged.
"""
return frontmatter, body

# -- Public API -------------------------------------------------------

@classmethod
Expand Down
120 changes: 120 additions & 0 deletions src/specify_cli/integrations/cline/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,110 @@ def format_cline_command_name(cmd_name: str) -> str:
return cmd_name


# Dotted ``speckit.*`` references in extension and preset command content.
_SPECKIT_DOTTED_REF = re.compile(r"\bspeckit\.[A-Za-z0-9-_]+(?:\.[A-Za-z0-9-_]+)*\b")


def _is_path_char(ch: str) -> bool:
"""Whether *ch* can end the directory part of a path before a separator."""
return ch.isalnum() or ch in "._-~/\\}$"
Comment thread
Copilot marked this conversation as resolved.
Resolved


# A ``%VAR%`` environment variable reference ending a directory name
# (``%TEMP%``, ``%ProgramFiles(x86)%``).
_ENV_VAR_DIR = re.compile(r"%[A-Za-z_][A-Za-z0-9_()]*%\Z")

_BRACKET_OPENERS = {")": "(", "]": "["}


def _ends_directory(text: str, sep: int) -> bool:
"""Whether the text before the separator at *sep* ends a directory name.

Besides plain path-name characters this accepts a closing bracket that
closes a whitespace-free group (``Program Files (x86)``,
``commands[old]``) and a ``%VAR%`` environment variable reference
(``%TEMP%``). An opening bracket, or a closing one around prose such as
``(see docs)``, does not end a directory, so ``[/speckit.foo.md]`` and
``(/speckit.foo.md)`` stay command references.
"""
ch = text[sep - 1]
if _is_path_char(ch):
return True
if ch in _BRACKET_OPENERS:
opener = text.rfind(_BRACKET_OPENERS[ch], 0, sep - 1)
group = text[opener + 1 : sep - 1]
return (
opener >= 0
and bool(group)
and ch not in group
and not any(c.isspace() for c in group)
)
if ch == "%":
return _ENV_VAR_DIR.search(text, 0, sep) is not None
return False


def _is_drive_prefix(text: str, sep: int) -> bool:
"""Whether a Windows drive (``C:``) ends right before the separator at *sep*.

The drive letter must stand alone, so ``C:\\speckit.foo.md`` and
``C:/speckit.foo.md`` count as root-level paths while ``ab:/speckit.foo.md``
does not.
"""
return (
sep >= 2
and text[sep - 1] == ":"
and text[sep - 2].isascii()
and text[sep - 2].isalpha()
and (sep == 2 or not _is_path_char(text[sep - 3]))
)


def _hyphenate_ref_match(match: re.Match[str]) -> str:
"""Hyphenate one dotted speckit reference, leaving ``.md`` file names alone.

Command names may themselves end in an ``md`` segment: extension
commands are ``speckit.<extension>.<command>`` and preset command names
may contain further dots, so ``/speckit.foo.md`` and
``speckit.foo.bar.md`` can both be valid command references. The
``.md`` suffix and the segment count therefore cannot tell a file name
apart from a command. A ``.md`` token is kept as a file name only when
it sits inside a path (``commands/speckit.git.commit.md``), that is,
when the separator before it ends a directory name (see
``_ends_directory``: ``commands/``, ``C:\\Program Files (x86)\\``,
``%TEMP%\\``, ``commands[old]/``) or follows a Windows drive
(``C:\\speckit.foo.md``, ``C:/speckit.foo.md``). A bare or
slash-prefixed token is treated as a command reference, including after
an opening bracket or other punctuation (``[/speckit.foo.md]``,
``(/speckit.foo.md)``).
"""
ref = match.group(0)
if ref.endswith(".md"):
start = match.start()
text = match.string
if start >= 2 and text[start - 1] in "/\\" and (
_ends_directory(text, start - 1) or _is_drive_prefix(text, start - 1)
):
return ref
return ref.replace(".", "-")


def hyphenate_command_refs(text: str) -> str:
"""Hyphenate dotted ``speckit.*`` command references in *text*."""
return _SPECKIT_DOTTED_REF.sub(_hyphenate_ref_match, text)


def _hyphenate_frontmatter_refs(val: Any) -> Any:
"""Recursively hyphenate dotted ``speckit.*`` references in frontmatter values."""
if isinstance(val, dict):
return {k: _hyphenate_frontmatter_refs(v) for k, v in val.items()}
if isinstance(val, list):
return [_hyphenate_frontmatter_refs(x) for x in val]
if isinstance(val, str):
return hyphenate_command_refs(val)
return val


class ClineIntegration(MarkdownIntegration):
"""Integration for Cline IDE."""

Expand Down Expand Up @@ -154,6 +258,22 @@ def _rewrite_handoff_references(content: str) -> str:
content,
)

def process_registered_command(
self, frontmatter: dict[str, Any], body: str, *, source_id: str
) -> tuple[dict[str, Any], str]:
"""Hyphenate dotted command references in extension and preset commands.

Cline only registers hyphenated slash commands, so references such as
``/speckit.git.commit`` in extension and preset command content are
rewritten to ``/speckit-git-commit``. ``speckit.*.md`` file names
inside a path keep their extension. Core commands (``source_id ==
"core"``) are left unchanged; their references are rendered through
``invoke_separator`` instead.
"""
if source_id == "core":
return frontmatter, body
return _hyphenate_frontmatter_refs(frontmatter), hyphenate_command_refs(body)

def post_process_command_content(self, content: str) -> str:
"""Apply Cline-specific transformations to command content.

Expand Down
Loading
Loading